palworld added
This commit is contained in:
File diff suppressed because one or more lines are too long
172
ActivityLog.jsx
Normal file
172
ActivityLog.jsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useUser } from '../context/UserContext'
|
||||||
|
import { getActivityLog } from '../api'
|
||||||
|
|
||||||
|
const actionLabels = {
|
||||||
|
server_start: 'Server gestartet',
|
||||||
|
server_stop: 'Server gestoppt',
|
||||||
|
server_restart: 'Server neugestartet',
|
||||||
|
rcon_command: 'RCON Befehl',
|
||||||
|
autoshutdown_config: 'Auto-Shutdown geändert',
|
||||||
|
zomboid_config: 'Config geändert',
|
||||||
|
factorio_world_create: 'Welt erstellt',
|
||||||
|
factorio_world_delete: 'Welt gelöscht'
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionIcons = {
|
||||||
|
server_start: '▶️',
|
||||||
|
server_stop: '⏹️',
|
||||||
|
server_restart: '🔄',
|
||||||
|
rcon_command: '💻',
|
||||||
|
autoshutdown_config: '⏱️',
|
||||||
|
zomboid_config: '📝',
|
||||||
|
factorio_world_create: '🌍',
|
||||||
|
factorio_world_delete: '🗑️'
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverLabels = {
|
||||||
|
minecraft: 'Minecraft',
|
||||||
|
factorio: 'Factorio',
|
||||||
|
zomboid: 'Project Zomboid',
|
||||||
|
vrising: 'V Rising'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvatarUrl(discordId, avatar) {
|
||||||
|
if (!discordId || !avatar) return null
|
||||||
|
return `https://cdn.discordapp.com/avatars/${discordId}/${avatar}.png?size=32`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiscordProfileUrl(discordId) {
|
||||||
|
return `https://discord.com/users/${discordId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityLog({ onClose }) {
|
||||||
|
const { token } = useUser()
|
||||||
|
const [logs, setLogs] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getActivityLog(token, 100)
|
||||||
|
setLogs(data)
|
||||||
|
setError('')
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden des Activity Logs')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchLogs()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
const date = new Date(dateStr + 'Z')
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - date
|
||||||
|
|
||||||
|
if (diff < 60000) return 'Gerade eben'
|
||||||
|
if (diff < 3600000) return Math.floor(diff / 60000) + ' Min'
|
||||||
|
if (diff < 86400000) return Math.floor(diff / 3600000) + ' Std'
|
||||||
|
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop fade-in" onClick={onClose}>
|
||||||
|
<div className="modal fade-in-scale" style={{ maxWidth: '42rem' }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">Activity Log</h2>
|
||||||
|
<button onClick={onClose} className="btn btn-ghost">
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-error mb-4">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-4 text-neutral-400">Laden...</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-neutral-500">Noch keine Aktivitäten</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{logs.map((log) => {
|
||||||
|
const avatarUrl = getAvatarUrl(log.discord_id, log.avatar)
|
||||||
|
const profileUrl = log.discord_id ? getDiscordProfileUrl(log.discord_id) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={log.id} className="card p-3 flex items-start gap-3">
|
||||||
|
{/* Avatar or Action Icon */}
|
||||||
|
{avatarUrl ? (
|
||||||
|
<a
|
||||||
|
href={profileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-8 h-8 rounded-full hover:ring-2 hover:ring-blue-500 transition-all"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-neutral-700 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-neutral-400 text-xs">
|
||||||
|
{log.username?.charAt(0)?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{profileUrl ? (
|
||||||
|
<a
|
||||||
|
href={profileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-white font-medium hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
{log.username}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-white font-medium">{log.username}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-neutral-500">
|
||||||
|
{actionIcons[log.action] || '📋'} {actionLabels[log.action] || log.action}
|
||||||
|
</span>
|
||||||
|
{log.target && (
|
||||||
|
<span className="text-blue-400">
|
||||||
|
{serverLabels[log.target] || log.target}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{log.details && (
|
||||||
|
<div className="text-sm text-neutral-500 mt-1 truncate font-mono">
|
||||||
|
{log.details}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-neutral-500 whitespace-nowrap">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
App.jsx
Normal file
156
App.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate, useSearchParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { UserProvider } from './context/UserContext'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import ServerDetail from './pages/ServerDetail'
|
||||||
|
|
||||||
|
// OAuth Callback Handler
|
||||||
|
function AuthCallback({ onLogin }) {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = searchParams.get('token')
|
||||||
|
const errorParam = searchParams.get('error')
|
||||||
|
|
||||||
|
if (errorParam) {
|
||||||
|
const errorMessages = {
|
||||||
|
'discord_denied': 'Discord-Anmeldung abgebrochen',
|
||||||
|
'no_code': 'Kein Autorisierungscode erhalten',
|
||||||
|
'not_in_guild': 'Du bist nicht Mitglied des Discord-Servers',
|
||||||
|
'oauth_failed': 'Anmeldung fehlgeschlagen'
|
||||||
|
}
|
||||||
|
setError(errorMessages[errorParam] || 'Unbekannter Fehler')
|
||||||
|
setTimeout(() => navigate('/'), 3000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
onLogin(token)
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
}, [searchParams, onLogin, navigate])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-neutral-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-red-400 text-xl mb-2">{error}</div>
|
||||||
|
<div className="text-neutral-500">Weiterleitung...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-neutral-950">
|
||||||
|
<div className="text-neutral-400">Anmeldung wird verarbeitet...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login page for unauthenticated users
|
||||||
|
function LoginPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const error = searchParams.get('error')
|
||||||
|
|
||||||
|
const errorMessages = {
|
||||||
|
'discord_denied': 'Discord-Anmeldung abgebrochen',
|
||||||
|
'no_code': 'Kein Autorisierungscode erhalten',
|
||||||
|
'not_in_guild': 'Du bist nicht Mitglied des Discord-Servers',
|
||||||
|
'oauth_failed': 'Anmeldung fehlgeschlagen'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDiscordLogin = () => {
|
||||||
|
window.location.href = '/api/auth/discord'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-neutral-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<img src="/navbarlogoweiß.png" alt="Logo" className="h-16 mx-auto mb-6" />
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">Gameserver Management</h1>
|
||||||
|
<p className="text-neutral-400 mb-8">Melde dich mit Discord an, um fortzufahren</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
|
||||||
|
{errorMessages[error] || 'Anmeldung fehlgeschlagen'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDiscordLogin}
|
||||||
|
className="flex items-center gap-3 mx-auto px-6 py-3 bg-[#5865F2] hover:bg-[#4752C4] text-white font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||||
|
</svg>
|
||||||
|
Mit Discord anmelden
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="mt-8 text-sm text-neutral-600">
|
||||||
|
Nur für Mitglieder des Discord-Servers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [token, setToken] = useState(localStorage.getItem('gsm_token'))
|
||||||
|
|
||||||
|
const handleLogin = (newToken) => {
|
||||||
|
localStorage.setItem('gsm_token', newToken)
|
||||||
|
setToken(newToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('gsm_token')
|
||||||
|
setToken(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserProvider token={token}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
token ? (
|
||||||
|
<Dashboard onLogout={handleLogout} />
|
||||||
|
) : (
|
||||||
|
<LoginPage />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/server/:serverId"
|
||||||
|
element={
|
||||||
|
token ? (
|
||||||
|
<ServerDetail onLogout={handleLogout} />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/auth/callback"
|
||||||
|
element={<AuthCallback onLogin={handleLogin} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/auth/discord/callback"
|
||||||
|
element={<AuthCallback onLogin={handleLogin} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={<LoginPage />}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</UserProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
CLAUDE.md
21
CLAUDE.md
@@ -14,6 +14,9 @@ The homelab consists of:
|
|||||||
- **Gameserver Monitor (192.168.2.30)**: React/Node.js webapp for monitoring game servers (LXC)
|
- **Gameserver Monitor (192.168.2.30)**: React/Node.js webapp for monitoring game servers (LXC)
|
||||||
- **Factorio Server (192.168.2.50)**: Docker-based game server (LXC)
|
- **Factorio Server (192.168.2.50)**: Docker-based game server (LXC)
|
||||||
- **Minecraft Server (192.168.2.51)**: ATM10 modded server running via screen (VM)
|
- **Minecraft Server (192.168.2.51)**: ATM10 modded server running via screen (VM)
|
||||||
|
- **V Rising Server (192.168.2.52)**: Dedicated server (LXC)
|
||||||
|
- **Palworld Server (192.168.2.53)**: Dedicated server with systemd (LXC)
|
||||||
|
- **Project Zomboid Server (10.0.30.66)**: Dedicated server (external VM)
|
||||||
|
|
||||||
## Key Technical Details
|
## Key Technical Details
|
||||||
|
|
||||||
@@ -21,12 +24,22 @@ The homelab consists of:
|
|||||||
|
|
||||||
**Gameserver Monitor Rollensystem**:
|
**Gameserver Monitor Rollensystem**:
|
||||||
- `user`: Kann nur Server-Metriken sehen (CPU, RAM, Players, Uptime)
|
- `user`: Kann nur Server-Metriken sehen (CPU, RAM, Players, Uptime)
|
||||||
- `moderator`: Zusätzlich Konsole, RCON, Server Start/Stop/Restart
|
- `moderator`: Zusätzlich Konsole, RCON, Server Start/Stop/Restart, Auto-Shutdown, Config-Editoren
|
||||||
- `superadmin`: Zusätzlich Nutzerverwaltung (User anlegen/löschen, Rollen ändern)
|
- `superadmin`: Zusätzlich Nutzerverwaltung, Activity Log, Anzeigeeinstellungen (Verbindungsadresse/Hinweis pro Server)
|
||||||
|
|
||||||
**Domain**: dimension47.de with subdomains managed via Cloudflare DDNS
|
**GSM Features**:
|
||||||
|
- Dashboard mit Server-Karten (Status, Metriken, Spieleranzahl)
|
||||||
|
- Server-Detailansicht mit Tabs (Übersicht, Metriken, Konsole, Einstellungen, etc.)
|
||||||
|
- Auto-Shutdown: Server stoppt automatisch wenn keine Spieler online sind
|
||||||
|
- Config-Editoren: Palworld (INI), Project Zomboid (INI/Lua)
|
||||||
|
- Factorio Weltverwaltung: Spielstände erstellen/löschen/laden
|
||||||
|
- Minecraft Whitelist-Verwaltung via RCON
|
||||||
|
- Anzeigeeinstellungen: Superadmins können Verbindungsadresse und Hinweis pro Server anpassen
|
||||||
|
- Activity Log: Protokolliert alle Aktionen mit Discord-Avatar
|
||||||
|
|
||||||
**SSH Access**: The monitor server (.30) has SSH key access to Proxmox and both game servers for remote management.
|
**Domain**: zeasy.dev with subdomains managed via Cloudflare DDNS (gsm.zeasy.dev, factorio.zeasy.dev, palworld.zeasy.dev, pz.zeasy.dev)
|
||||||
|
|
||||||
|
**SSH Access**: The monitor server (.30) has SSH key access to Proxmox and all game servers for remote management.
|
||||||
|
|
||||||
## Language Note
|
## Language Note
|
||||||
|
|
||||||
|
|||||||
164
Dashboard.jsx
Normal file
164
Dashboard.jsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { getServers } from '../api'
|
||||||
|
import { useUser } from '../context/UserContext'
|
||||||
|
import ServerCard from '../components/ServerCard'
|
||||||
|
import UserManagement from '../components/UserManagement'
|
||||||
|
import ActivityLog from '../components/ActivityLog'
|
||||||
|
|
||||||
|
export default function Dashboard({ onLogout }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, token, loading: userLoading, isSuperadmin, role, avatarUrl } = useUser()
|
||||||
|
const [servers, setServers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [showUserMgmt, setShowUserMgmt] = useState(false)
|
||||||
|
const [showActivityLog, setShowActivityLog] = useState(false)
|
||||||
|
|
||||||
|
const fetchServers = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getServers(token)
|
||||||
|
setServers(data)
|
||||||
|
setError('')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('401') || err.message.includes('403')) {
|
||||||
|
onLogout()
|
||||||
|
} else {
|
||||||
|
setError('Verbindung zum Server fehlgeschlagen')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userLoading && token) {
|
||||||
|
fetchServers()
|
||||||
|
const interval = setInterval(fetchServers, 10000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [token, userLoading])
|
||||||
|
|
||||||
|
const roleLabels = {
|
||||||
|
user: 'Viewer',
|
||||||
|
moderator: 'Operator',
|
||||||
|
superadmin: 'Admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-neutral-400">Laden...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlineCount = servers.filter(s => s.running).length
|
||||||
|
document.title = 'Dashboard | Zeasy GSM'
|
||||||
|
const totalPlayers = servers.reduce((sum, s) => sum + (s.players?.online || 0), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen page-enter">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-neutral-800 bg-neutral-900/50 backdrop-blur-sm sticky top-0 z-10">
|
||||||
|
<div className="container-main py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a href="https://zeasy.software" target="_blank" rel="noopener noreferrer" className="relative block group">
|
||||||
|
<img src="/navbarlogoweiß.png" alt="Logo" className="h-8 transition-opacity duration-300 group-hover:opacity-0" />
|
||||||
|
<img src="/navbarlogograuer.png" alt="Logo" className="h-8 absolute top-0 left-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
||||||
|
</a>
|
||||||
|
<span className="text-xl font-semibold text-white hidden sm:inline">Gameserver Management</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex items-center gap-4 text-sm text-neutral-400">
|
||||||
|
<span>
|
||||||
|
<span className="text-white font-medium">{onlineCount}</span>/{servers.length} online
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-600">|</span>
|
||||||
|
<span>
|
||||||
|
<span className="text-white font-medium">{totalPlayers}</span> Spieler
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* User info with Discord avatar */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{avatarUrl && (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="hidden sm:block text-right">
|
||||||
|
<div className="text-sm text-white">{user?.username}</div>
|
||||||
|
<div className="text-xs text-neutral-500">{roleLabels[role]}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSuperadmin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowActivityLog(true)}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
>
|
||||||
|
Log
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUserMgmt(true)}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
>
|
||||||
|
Benutzer
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="btn btn-outline"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="container-main py-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 alert alert-error fade-in">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-neutral-400">Server werden geladen...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{servers.map((server, index) => (
|
||||||
|
<div
|
||||||
|
key={server.id}
|
||||||
|
className="fade-in-up"
|
||||||
|
style={{ animationDelay: index * 50 + 'ms', animationFillMode: 'both' }}
|
||||||
|
>
|
||||||
|
<ServerCard
|
||||||
|
server={server}
|
||||||
|
onClick={() => navigate('/server/' + server.id)}
|
||||||
|
isAuthenticated={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showUserMgmt && (
|
||||||
|
<UserManagement onClose={() => setShowUserMgmt(false)} />
|
||||||
|
)}
|
||||||
|
{showActivityLog && (
|
||||||
|
<ActivityLog onClose={() => setShowActivityLog(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
UserContext.jsx
Normal file
61
UserContext.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import { getMe } from '../api'
|
||||||
|
|
||||||
|
const UserContext = createContext(null)
|
||||||
|
|
||||||
|
export function UserProvider({ children, token }) {
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setUser(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getMe(token)
|
||||||
|
.then(data => {
|
||||||
|
setUser(data)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Token invalid, clear it
|
||||||
|
localStorage.removeItem('gsm_token')
|
||||||
|
setUser(null)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
// Get Discord avatar URL
|
||||||
|
const getAvatarUrl = () => {
|
||||||
|
if (!user?.discordId || !user?.avatar) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
loading,
|
||||||
|
role: user?.role || 'user',
|
||||||
|
isModerator: ['moderator', 'superadmin'].includes(user?.role),
|
||||||
|
isSuperadmin: user?.role === 'superadmin',
|
||||||
|
avatarUrl: getAvatarUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser() {
|
||||||
|
const context = useContext(UserContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useUser must be used within UserProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
102
UserManagement.jsx
Normal file
102
UserManagement.jsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useUser } from '../context/UserContext'
|
||||||
|
import { getUsers } from '../api'
|
||||||
|
|
||||||
|
export default function UserManagement({ onClose }) {
|
||||||
|
const { token } = useUser()
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getUsers(token)
|
||||||
|
setUsers(data)
|
||||||
|
setError('')
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden der Benutzer')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchUsers()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
const roleLabels = {
|
||||||
|
user: 'Viewer',
|
||||||
|
moderator: 'Operator',
|
||||||
|
superadmin: 'Admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleColors = {
|
||||||
|
user: 'text-neutral-400',
|
||||||
|
moderator: 'text-blue-400',
|
||||||
|
superadmin: 'text-yellow-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAvatarUrl = (user) => {
|
||||||
|
if (!user.discord_id || !user.avatar) return null
|
||||||
|
return `https://cdn.discordapp.com/avatars/${user.discord_id}/${user.avatar}.png?size=64`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop fade-in" onClick={onClose}>
|
||||||
|
<div className="modal fade-in-scale" style={{ maxWidth: '32rem' }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">Benutzerliste</h2>
|
||||||
|
<button onClick={onClose} className="btn btn-ghost">
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-error mb-4">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm text-neutral-500 mb-4">
|
||||||
|
Benutzer, die sich über Discord angemeldet haben. Rollen werden durch Discord-Rollen bestimmt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-4 text-neutral-400">Laden...</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-neutral-500">Noch keine Benutzer angemeldet</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div key={user.id} className="card p-3 flex items-center gap-3">
|
||||||
|
{getAvatarUrl(user) ? (
|
||||||
|
<img
|
||||||
|
src={getAvatarUrl(user)}
|
||||||
|
alt=""
|
||||||
|
className="w-10 h-10 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-700 flex items-center justify-center">
|
||||||
|
<span className="text-neutral-400 text-sm">
|
||||||
|
{user.username?.charAt(0)?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-white font-medium truncate">{user.username}</div>
|
||||||
|
<div className="text-xs text-neutral-500 truncate">
|
||||||
|
{user.discord_id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={`text-sm font-medium ${roleColors[user.role]}`}>
|
||||||
|
{roleLabels[user.role]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
ZeasyWG-Alex.conf
Normal file
10
ZeasyWG-Alex.conf
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Interface]
|
||||||
|
PrivateKey = WC2HEXQ10sm5sE9Apj5wBBv2/CSRmpQNUovT1xSO8kM=
|
||||||
|
Address = 10.0.200.201/32
|
||||||
|
DNS = 10.0.0.1
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = wgP8/WMTzhr45bH2GFabcYUhycCLF/pPczghWgLJN0Q=
|
||||||
|
Endpoint = beeck.zeasy.dev:47199
|
||||||
|
AllowedIPs = 10.0.0.0/16
|
||||||
|
PersistentKeepalive = 60
|
||||||
326
ZomboidConfigEditor.jsx
Normal file
326
ZomboidConfigEditor.jsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { FileText, Save, RefreshCw, AlertTriangle, Check, X, ChevronDown } from 'lucide-react'
|
||||||
|
import { getZomboidConfigs, getZomboidConfig, saveZomboidConfig } from '../api'
|
||||||
|
|
||||||
|
export default function ZomboidConfigEditor({ token }) {
|
||||||
|
const [files, setFiles] = useState([])
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null)
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [originalContent, setOriginalContent] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [success, setSuccess] = useState(null)
|
||||||
|
const [hasChanges, setHasChanges] = useState(false)
|
||||||
|
const textareaRef = useRef(null)
|
||||||
|
const highlightRef = useRef(null)
|
||||||
|
|
||||||
|
// Load file list
|
||||||
|
useEffect(() => {
|
||||||
|
loadFiles()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
// Track changes
|
||||||
|
useEffect(() => {
|
||||||
|
setHasChanges(content !== originalContent)
|
||||||
|
}, [content, originalContent])
|
||||||
|
|
||||||
|
// Sync scroll between textarea and highlight div
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (highlightRef.current && textareaRef.current) {
|
||||||
|
highlightRef.current.scrollTop = textareaRef.current.scrollTop
|
||||||
|
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await getZomboidConfigs(token)
|
||||||
|
setFiles(data.files || [])
|
||||||
|
if (data.files?.length > 0 && !selectedFile) {
|
||||||
|
loadFile(data.files[0].filename)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden der Config-Dateien: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFile(filename) {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
const data = await getZomboidConfig(token, filename)
|
||||||
|
setSelectedFile(filename)
|
||||||
|
setContent(data.content)
|
||||||
|
setOriginalContent(data.content)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!selectedFile || !hasChanges) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
await saveZomboidConfig(token, selectedFile, content)
|
||||||
|
setOriginalContent(content)
|
||||||
|
setSuccess('Config gespeichert! Server-Neustart erforderlich für Änderungen.')
|
||||||
|
setTimeout(() => setSuccess(null), 5000)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Speichern: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
setContent(originalContent)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileDescription(filename) {
|
||||||
|
const descriptions = {
|
||||||
|
'Project.ini': 'Server-Einstellungen (PVP, Spieler, Netzwerk)',
|
||||||
|
'Project_SandboxVars.lua': 'Gameplay-Einstellungen (Zombies, Loot, Schwierigkeit)',
|
||||||
|
'Project_spawnpoints.lua': 'Spawn-Punkte für neue Spieler',
|
||||||
|
'Project_spawnregions.lua': 'Spawn-Regionen Konfiguration'
|
||||||
|
}
|
||||||
|
return descriptions[filename] || filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight syntax based on file type
|
||||||
|
function highlightSyntax(text, filename) {
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const isLua = filename?.endsWith('.lua')
|
||||||
|
const lines = text.split('\n')
|
||||||
|
|
||||||
|
return lines.map((line, i) => {
|
||||||
|
let highlighted = line
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
if (isLua) {
|
||||||
|
// Lua: -- comments
|
||||||
|
if (line.trim().startsWith('--')) {
|
||||||
|
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||||
|
} else if (line.includes('--')) {
|
||||||
|
const idx = line.indexOf('--')
|
||||||
|
const code = highlighted.substring(0, idx)
|
||||||
|
const comment = highlighted.substring(idx)
|
||||||
|
highlighted = `${code}<span class="text-emerald-500">${comment}</span>`
|
||||||
|
}
|
||||||
|
// Highlight true/false/nil
|
||||||
|
highlighted = highlighted
|
||||||
|
.replace(/\b(true|false|nil)\b/g, '<span class="text-orange-400">$1</span>')
|
||||||
|
// Highlight numbers
|
||||||
|
highlighted = highlighted
|
||||||
|
.replace(/\b(\d+\.?\d*)\b/g, '<span class="text-cyan-400">$1</span>')
|
||||||
|
} else {
|
||||||
|
// INI: # comments
|
||||||
|
if (line.trim().startsWith('#')) {
|
||||||
|
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||||
|
}
|
||||||
|
// Highlight key=value
|
||||||
|
else if (line.includes('=')) {
|
||||||
|
const idx = line.indexOf('=')
|
||||||
|
const key = highlighted.substring(0, idx)
|
||||||
|
const value = highlighted.substring(idx + 1)
|
||||||
|
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${value}</span>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlighted
|
||||||
|
}).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && files.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||||
|
<span className="ml-2 text-gray-400">Lade Config-Dateien...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* File selector */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<select
|
||||||
|
value={selectedFile || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (hasChanges) {
|
||||||
|
if (!confirm('Ungespeicherte Änderungen verwerfen?')) return
|
||||||
|
}
|
||||||
|
loadFile(e.target.value)
|
||||||
|
}}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 pr-10 text-white appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{files.map(file => (
|
||||||
|
<option key={file.filename} value={file.filename}>
|
||||||
|
{file.filename}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => loadFile(selectedFile)}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Neu laden"
|
||||||
|
>
|
||||||
|
<RefreshCw className={"w-5 h-5 " + (loading ? 'animate-spin' : '')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File description */}
|
||||||
|
{selectedFile && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>{getFileDescription(selectedFile)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error/Success messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
|
||||||
|
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400">
|
||||||
|
<Check className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editor with syntax highlighting */}
|
||||||
|
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
|
||||||
|
{/* Highlighted background layer */}
|
||||||
|
<pre
|
||||||
|
ref={highlightRef}
|
||||||
|
className="absolute inset-0 p-4 m-0 text-sm font-mono bg-gray-900 text-gray-100 overflow-auto pointer-events-none whitespace-pre-wrap break-words"
|
||||||
|
style={{ wordBreak: 'break-word' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlightSyntax(content, selectedFile) + '\n' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Transparent textarea for editing */}
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="absolute inset-0 w-full h-full p-4 text-sm font-mono bg-transparent text-transparent caret-white resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ caretColor: 'white' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Change indicator */}
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded z-10">
|
||||||
|
Ungespeichert
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{selectedFile && files.find(f => f.filename === selectedFile)?.modified && (
|
||||||
|
<span>Zuletzt geändert: {files.find(f => f.filename === selectedFile).modified}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{hasChanges && (
|
||||||
|
<button
|
||||||
|
onClick={handleDiscard}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Verwerfen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
||||||
|
(hasChanges && !saving
|
||||||
|
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
Speichern...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Speichern
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||||
|
<h4 className="text-sm font-medium text-gray-300 mb-2">Legende</h4>
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-emerald-500"></span>
|
||||||
|
<span className="text-gray-400">Kommentare</span>
|
||||||
|
</div>
|
||||||
|
{selectedFile?.endsWith('.ini') && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-blue-400"></span>
|
||||||
|
<span className="text-gray-400">Einstellung</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-amber-300"></span>
|
||||||
|
<span className="text-gray-400">Wert</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedFile?.endsWith('.lua') && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-orange-400"></span>
|
||||||
|
<span className="text-gray-400">Boolean/Nil</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-cyan-400"></span>
|
||||||
|
<span className="text-gray-400">Zahlen</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-3">Änderungen werden erst nach Server-Neustart aktiv. Ein Backup wird automatisch erstellt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
211
auth.js
Normal file
211
auth.js
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { db, VALID_ROLES, initDiscordUsers } from '../db/init.js';
|
||||||
|
import { authenticateToken, requireRole } from '../middleware/auth.js';
|
||||||
|
import { getDiscordAuthUrl, exchangeCode, getDiscordUser, getGuildMember, getUserRole } from '../services/discord.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Initialize Discord users table
|
||||||
|
initDiscordUsers();
|
||||||
|
|
||||||
|
// ===== Discord OAuth2 =====
|
||||||
|
|
||||||
|
// Start Discord OAuth2 flow
|
||||||
|
router.get('/discord', (req, res) => {
|
||||||
|
res.redirect(getDiscordAuthUrl());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Discord OAuth2 callback
|
||||||
|
router.get('/discord/callback', async (req, res) => {
|
||||||
|
const { code, error } = req.query;
|
||||||
|
|
||||||
|
// Redirect URL for frontend
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || 'https://gsm.dimension47.de';
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return res.redirect(`${frontendUrl}/login?error=discord_denied`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return res.redirect(`${frontendUrl}/login?error=no_code`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Exchange code for access token
|
||||||
|
const tokenData = await exchangeCode(code);
|
||||||
|
|
||||||
|
// Get Discord user info
|
||||||
|
const discordUser = await getDiscordUser(tokenData.access_token);
|
||||||
|
|
||||||
|
// Check if user is in the guild
|
||||||
|
const member = await getGuildMember(discordUser.id);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return res.redirect(`${frontendUrl}/login?error=not_in_guild`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine role based on Discord roles
|
||||||
|
const role = getUserRole(member.roles);
|
||||||
|
|
||||||
|
// Get display name (nickname or username)
|
||||||
|
const displayName = member.nick || discordUser.global_name || discordUser.username;
|
||||||
|
|
||||||
|
// Upsert user in database
|
||||||
|
const existingUser = db.prepare('SELECT * FROM discord_users WHERE discord_id = ?').get(discordUser.id);
|
||||||
|
|
||||||
|
let userId;
|
||||||
|
if (existingUser) {
|
||||||
|
// Update existing user
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE discord_users
|
||||||
|
SET username = ?, discriminator = ?, avatar = ?, role = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE discord_id = ?
|
||||||
|
`).run(displayName, discordUser.discriminator || '0', discordUser.avatar, role, discordUser.id);
|
||||||
|
userId = existingUser.id;
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO discord_users (discord_id, username, discriminator, avatar, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(discordUser.id, displayName, discordUser.discriminator || '0', discordUser.avatar, role);
|
||||||
|
userId = result.lastInsertRowid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JWT token
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
id: userId,
|
||||||
|
discordId: discordUser.id,
|
||||||
|
username: displayName,
|
||||||
|
role,
|
||||||
|
avatar: discordUser.avatar
|
||||||
|
},
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redirect to frontend with token
|
||||||
|
res.redirect(`${frontendUrl}/auth/callback?token=${token}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Discord OAuth error:', err);
|
||||||
|
res.redirect(`${frontendUrl}/login?error=oauth_failed`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user info
|
||||||
|
router.get('/me', authenticateToken, (req, res) => {
|
||||||
|
// Check if it's a Discord user
|
||||||
|
if (req.user.discordId) {
|
||||||
|
const user = db.prepare('SELECT id, discord_id, username, avatar, role FROM discord_users WHERE id = ?').get(req.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
return res.json({
|
||||||
|
id: user.id,
|
||||||
|
discordId: user.discord_id,
|
||||||
|
username: user.username,
|
||||||
|
avatar: user.avatar,
|
||||||
|
role: user.role
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for old users (shouldn't happen after migration)
|
||||||
|
const user = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
res.json({ id: user.id, username: user.username, role: user.role });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh user role from Discord (useful if roles changed)
|
||||||
|
router.post('/refresh-role', authenticateToken, async (req, res) => {
|
||||||
|
if (!req.user.discordId) {
|
||||||
|
return res.status(400).json({ error: 'Not a Discord user' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const member = await getGuildMember(req.user.discordId);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return res.status(403).json({ error: 'No longer in guild' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRole = getUserRole(member.roles);
|
||||||
|
|
||||||
|
db.prepare('UPDATE discord_users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE discord_id = ?')
|
||||||
|
.run(newRole, req.user.discordId);
|
||||||
|
|
||||||
|
// Generate new token with updated role
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
id: req.user.id,
|
||||||
|
discordId: req.user.discordId,
|
||||||
|
username: req.user.username,
|
||||||
|
role: newRole,
|
||||||
|
avatar: req.user.avatar
|
||||||
|
},
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ token, role: newRole });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to refresh role:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to refresh role' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== User Management (superadmin only) =====
|
||||||
|
|
||||||
|
// Get all Discord users
|
||||||
|
router.get('/users', authenticateToken, requireRole('superadmin'), (req, res) => {
|
||||||
|
const users = db.prepare(`
|
||||||
|
SELECT id, discord_id, username, avatar, role, created_at, updated_at
|
||||||
|
FROM discord_users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all();
|
||||||
|
res.json(users);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user role (override Discord role)
|
||||||
|
router.patch('/users/:id/role', authenticateToken, requireRole('superadmin'), (req, res) => {
|
||||||
|
const userId = parseInt(req.params.id);
|
||||||
|
const { role } = req.body;
|
||||||
|
|
||||||
|
if (!VALID_ROLES.includes(role)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid role' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId === req.user.id) {
|
||||||
|
return res.status(400).json({ error: 'Cannot change your own role' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = db.prepare('SELECT id FROM discord_users WHERE id = ?').get(userId);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('UPDATE discord_users SET role = ? WHERE id = ?').run(role, userId);
|
||||||
|
res.json({ message: 'Role updated' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
router.delete('/users/:id', authenticateToken, requireRole('superadmin'), (req, res) => {
|
||||||
|
const userId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
if (userId === req.user.id) {
|
||||||
|
return res.status(400).json({ error: 'Cannot delete yourself' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = db.prepare('SELECT id FROM discord_users WHERE id = ?').get(userId);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM discord_users WHERE id = ?').run(userId);
|
||||||
|
res.json({ message: 'User deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
82
discord.js
Normal file
82
discord.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Discord OAuth2 Service
|
||||||
|
const DISCORD_API = 'https://discord.com/api/v10';
|
||||||
|
|
||||||
|
export function getDiscordAuthUrl() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: process.env.DISCORD_CLIENT_ID,
|
||||||
|
redirect_uri: process.env.DISCORD_REDIRECT_URI,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'identify guilds.members.read'
|
||||||
|
});
|
||||||
|
return `https://discord.com/oauth2/authorize?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCode(code) {
|
||||||
|
const response = await fetch(`${DISCORD_API}/oauth2/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: process.env.DISCORD_CLIENT_ID,
|
||||||
|
client_secret: process.env.DISCORD_CLIENT_SECRET,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: process.env.DISCORD_REDIRECT_URI
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`Failed to exchange code: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDiscordUser(accessToken) {
|
||||||
|
const response = await fetch(`${DISCORD_API}/users/@me`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to get Discord user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGuildMember(userId) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${DISCORD_API}/guilds/${process.env.DISCORD_GUILD_ID}/members/${userId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null; // User not in guild
|
||||||
|
}
|
||||||
|
throw new Error('Failed to get guild member');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRole(memberRoles) {
|
||||||
|
const adminRoleId = process.env.DISCORD_ADMIN_ROLE_ID;
|
||||||
|
const modRoleId = process.env.DISCORD_MOD_ROLE_ID;
|
||||||
|
|
||||||
|
if (memberRoles.includes(adminRoleId)) {
|
||||||
|
return 'superadmin';
|
||||||
|
}
|
||||||
|
if (memberRoles.includes(modRoleId)) {
|
||||||
|
return 'moderator';
|
||||||
|
}
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
353
discordBot.js
Normal file
353
discordBot.js
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import { Client, GatewayIntentBits, EmbedBuilder } from 'discord.js';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { getServerStatus } from './ssh.js';
|
||||||
|
import { getPlayers, getPlayerList } from './rcon.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
let client = null;
|
||||||
|
let statusMessageId = null;
|
||||||
|
let statusChannelId = null;
|
||||||
|
let infoChannelId = null;
|
||||||
|
let alertChannelId = null;
|
||||||
|
|
||||||
|
// State tracking for alerts
|
||||||
|
const previousServerState = new Map();
|
||||||
|
const previousPlayerLists = new Map();
|
||||||
|
|
||||||
|
// Server display config
|
||||||
|
const serverDisplay = {
|
||||||
|
minecraft: { name: 'Minecraft ATM10', icon: '⛏️', color: 0x7B5E3C, address: 'minecraft.zeasy.dev' },
|
||||||
|
factorio: { name: 'Factorio', icon: '⚙️', color: 0xF97316, address: 'factorio.zeasy.dev' },
|
||||||
|
zomboid: { name: 'Project Zomboid', icon: '🧟', color: 0x4ADE80, address: 'zomboid.zeasy.dev' },
|
||||||
|
vrising: { name: 'V Rising', icon: '🧛', color: 0xDC2626, address: 'vrising.zeasy.dev' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAlert(embed) {
|
||||||
|
if (!client || !alertChannelId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = await client.channels.fetch(alertChannelId);
|
||||||
|
if (channel) {
|
||||||
|
await channel.send({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DiscordBot] Error sending alert:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAndSendAlerts(serverStatuses) {
|
||||||
|
for (const server of serverStatuses) {
|
||||||
|
const display = serverDisplay[server.id] || { name: server.name, icon: '🖥️', color: 0x6B7280 };
|
||||||
|
const prevState = previousServerState.get(server.id);
|
||||||
|
const prevPlayers = previousPlayerLists.get(server.id) || [];
|
||||||
|
|
||||||
|
// Check server status changes
|
||||||
|
if (prevState !== undefined && prevState !== server.status) {
|
||||||
|
let embed;
|
||||||
|
|
||||||
|
if (server.status === 'online' && prevState !== 'online') {
|
||||||
|
embed = new EmbedBuilder()
|
||||||
|
.setTitle(display.icon + ' Server gestartet')
|
||||||
|
.setDescription('**' + display.name + '** ist jetzt online')
|
||||||
|
.setColor(0x22C55E)
|
||||||
|
.setTimestamp();
|
||||||
|
} else if (server.status === 'offline' && prevState === 'online') {
|
||||||
|
embed = new EmbedBuilder()
|
||||||
|
.setTitle(display.icon + ' Server gestoppt')
|
||||||
|
.setDescription('**' + display.name + '** ist jetzt offline')
|
||||||
|
.setColor(0xEF4444)
|
||||||
|
.setTimestamp();
|
||||||
|
} else if (server.status === 'unreachable' && prevState !== 'unreachable') {
|
||||||
|
embed = new EmbedBuilder()
|
||||||
|
.setTitle('⚠️ Server nicht erreichbar')
|
||||||
|
.setDescription('**' + display.name + '** ist nicht erreichbar')
|
||||||
|
.setColor(0xF59E0B)
|
||||||
|
.setTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embed) {
|
||||||
|
await sendAlert(embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check player changes (only if server is online)
|
||||||
|
if (server.status === 'online' && server.playerList) {
|
||||||
|
const currentPlayers = server.playerList;
|
||||||
|
|
||||||
|
// Find players who joined
|
||||||
|
for (const player of currentPlayers) {
|
||||||
|
if (!prevPlayers.includes(player)) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('➡️ Spieler beigetreten')
|
||||||
|
.setDescription('**' + player + '** hat **' + display.name + '** betreten')
|
||||||
|
.setColor(0x22C55E)
|
||||||
|
.setTimestamp();
|
||||||
|
await sendAlert(embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find players who left
|
||||||
|
for (const player of prevPlayers) {
|
||||||
|
if (!currentPlayers.includes(player)) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('⬅️ Spieler verlassen')
|
||||||
|
.setDescription('**' + player + '** hat **' + display.name + '** verlassen')
|
||||||
|
.setColor(0xF59E0B)
|
||||||
|
.setTimestamp();
|
||||||
|
await sendAlert(embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousPlayerLists.set(server.id, [...currentPlayers]);
|
||||||
|
} else {
|
||||||
|
previousPlayerLists.set(server.id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update previous state
|
||||||
|
previousServerState.set(server.id, server.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupInfoMessage() {
|
||||||
|
try {
|
||||||
|
const channel = await client.channels.fetch(infoChannelId);
|
||||||
|
if (!channel) {
|
||||||
|
console.error('[DiscordBot] Info channel not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await channel.messages.fetch({ limit: 10 });
|
||||||
|
const botMessage = messages.find(m => m.author.id === client.user.id);
|
||||||
|
|
||||||
|
const infoEmbed = new EmbedBuilder()
|
||||||
|
.setTitle('🎮 Zeasy Gameserver Management')
|
||||||
|
.setDescription(
|
||||||
|
'Verwalte und überwache unsere Gameserver bequem über das Web-Interface.\n\n' +
|
||||||
|
'**Features:**\n' +
|
||||||
|
'• Server starten, stoppen & neustarten\n' +
|
||||||
|
'• Live Server-Status & Spielerlisten\n' +
|
||||||
|
'• Server-Konsole & RCON-Befehle\n' +
|
||||||
|
'• CPU, RAM & Uptime Metriken\n' +
|
||||||
|
'• Welten-Verwaltung (Factorio)\n' +
|
||||||
|
'• Config-Editor (Project Zomboid)\n\n' +
|
||||||
|
'**Zugang:**\n' +
|
||||||
|
'Melde dich mit deinem Discord-Account an. Deine Berechtigungen werden automatisch über deine Discord-Rollen bestimmt.'
|
||||||
|
)
|
||||||
|
.setColor(0x5865F2)
|
||||||
|
.addFields({
|
||||||
|
name: '🔗 Web-Interface',
|
||||||
|
value: '[server.zeasy.dev](https://server.zeasy.dev)',
|
||||||
|
inline: false
|
||||||
|
})
|
||||||
|
.setFooter({ text: 'Zeasy Software' })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
if (botMessage) {
|
||||||
|
await botMessage.edit({ embeds: [infoEmbed] });
|
||||||
|
console.log('[DiscordBot] Updated info message');
|
||||||
|
} else {
|
||||||
|
await channel.send({ embeds: [infoEmbed] });
|
||||||
|
console.log('[DiscordBot] Created info message');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DiscordBot] Error setting up info message:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initDiscordBot() {
|
||||||
|
const token = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
statusChannelId = process.env.DISCORD_STATUS_CHANNEL_ID;
|
||||||
|
infoChannelId = process.env.DISCORD_INFO_CHANNEL_ID;
|
||||||
|
alertChannelId = process.env.DISCORD_ALERT_CHANNEL_ID;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.log('[DiscordBot] Missing DISCORD_BOT_TOKEN, bot disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds]
|
||||||
|
});
|
||||||
|
|
||||||
|
client.once('ready', async () => {
|
||||||
|
console.log('[DiscordBot] Logged in as ' + client.user.tag);
|
||||||
|
|
||||||
|
// Setup info channel
|
||||||
|
if (infoChannelId) {
|
||||||
|
await setupInfoMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup status channel and alerts
|
||||||
|
if (statusChannelId) {
|
||||||
|
await findOrCreateStatusMessage();
|
||||||
|
// First run - just populate state without sending alerts
|
||||||
|
await updateStatusMessage(true);
|
||||||
|
// Then start regular updates with alerts
|
||||||
|
setInterval(() => updateStatusMessage(false), 60000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.login(token).catch(err => {
|
||||||
|
console.error('[DiscordBot] Failed to login:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateStatusMessage() {
|
||||||
|
try {
|
||||||
|
const channel = await client.channels.fetch(statusChannelId);
|
||||||
|
if (!channel) {
|
||||||
|
console.error('[DiscordBot] Status channel not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await channel.messages.fetch({ limit: 10 });
|
||||||
|
const botMessage = messages.find(m => m.author.id === client.user.id);
|
||||||
|
|
||||||
|
if (botMessage) {
|
||||||
|
statusMessageId = botMessage.id;
|
||||||
|
console.log('[DiscordBot] Found existing status message');
|
||||||
|
} else {
|
||||||
|
const msg = await channel.send({ embeds: [createLoadingEmbed()] });
|
||||||
|
statusMessageId = msg.id;
|
||||||
|
console.log('[DiscordBot] Created new status message');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DiscordBot] Error finding/creating status message:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLoadingEmbed() {
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle('🎮 Gameserver Status')
|
||||||
|
.setDescription('Lade Server-Status...')
|
||||||
|
.setColor(0x6B7280)
|
||||||
|
.setTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatusMessage(skipAlerts = false) {
|
||||||
|
if (!client || !statusMessageId || !statusChannelId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = await client.channels.fetch(statusChannelId);
|
||||||
|
const message = await channel.messages.fetch(statusMessageId);
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const serverStatuses = await Promise.all(config.servers.map(async (server) => {
|
||||||
|
try {
|
||||||
|
const status = await getServerStatus(server);
|
||||||
|
const running = status === 'online';
|
||||||
|
|
||||||
|
let players = { online: 0, max: null };
|
||||||
|
let playerList = { players: [] };
|
||||||
|
|
||||||
|
if (running && server.rconPassword) {
|
||||||
|
try {
|
||||||
|
players = await getPlayers(server);
|
||||||
|
playerList = await getPlayerList(server);
|
||||||
|
} catch (e) {
|
||||||
|
// RCON might fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
type: server.type,
|
||||||
|
status: running ? 'online' : 'offline',
|
||||||
|
running,
|
||||||
|
players: players.online || 0,
|
||||||
|
maxPlayers: players.max,
|
||||||
|
playerList: playerList.players || []
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
type: server.type,
|
||||||
|
status: 'unreachable',
|
||||||
|
running: false,
|
||||||
|
players: 0,
|
||||||
|
maxPlayers: null,
|
||||||
|
playerList: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Send alerts if enabled
|
||||||
|
if (!skipAlerts && alertChannelId) {
|
||||||
|
await checkAndSendAlerts(serverStatuses);
|
||||||
|
} else if (skipAlerts) {
|
||||||
|
// Just populate initial state
|
||||||
|
for (const server of serverStatuses) {
|
||||||
|
previousServerState.set(server.id, server.status);
|
||||||
|
previousPlayerLists.set(server.id, server.playerList || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const embeds = [];
|
||||||
|
|
||||||
|
const onlineCount = serverStatuses.filter(s => s.running).length;
|
||||||
|
const totalPlayers = serverStatuses.reduce((sum, s) => sum + s.players, 0);
|
||||||
|
|
||||||
|
const headerEmbed = new EmbedBuilder()
|
||||||
|
.setTitle('🎮 Gameserver Status')
|
||||||
|
.setDescription('**' + onlineCount + '/' + serverStatuses.length + '** Server online • **' + totalPlayers + '** Spieler')
|
||||||
|
.setColor(onlineCount > 0 ? 0x22C55E : 0xEF4444)
|
||||||
|
.setTimestamp()
|
||||||
|
.setFooter({ text: 'Aktualisiert alle 60 Sekunden' });
|
||||||
|
|
||||||
|
embeds.push(headerEmbed);
|
||||||
|
|
||||||
|
for (const server of serverStatuses) {
|
||||||
|
const display = serverDisplay[server.id] || { name: server.name, icon: '🖥️', color: 0x6B7280, address: '' };
|
||||||
|
|
||||||
|
const serverEmbed = new EmbedBuilder()
|
||||||
|
.setTitle(display.icon + ' ' + display.name)
|
||||||
|
.setColor(server.running ? display.color : 0x4B5563);
|
||||||
|
|
||||||
|
if (server.running) {
|
||||||
|
let description = '✅ **Online**\n';
|
||||||
|
|
||||||
|
if (display.address) {
|
||||||
|
description += '```' + display.address + '```';
|
||||||
|
}
|
||||||
|
|
||||||
|
description += '👥 **Spieler:** ' + server.players;
|
||||||
|
if (server.maxPlayers) {
|
||||||
|
description += '/' + server.maxPlayers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.playerList && server.playerList.length > 0) {
|
||||||
|
const names = server.playerList.slice(0, 15).join(', ');
|
||||||
|
description += '\n' + names;
|
||||||
|
if (server.playerList.length > 15) {
|
||||||
|
description += ' *+' + (server.playerList.length - 15) + ' mehr*';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serverEmbed.setDescription(description);
|
||||||
|
} else {
|
||||||
|
serverEmbed.setDescription('❌ **Offline**');
|
||||||
|
}
|
||||||
|
|
||||||
|
embeds.push(serverEmbed);
|
||||||
|
}
|
||||||
|
|
||||||
|
await message.edit({ embeds });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DiscordBot] Error updating status message:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDiscordClient() {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
354
docs/gameserver-hinzufuegen.md
Normal file
354
docs/gameserver-hinzufuegen.md
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
# Neuen Gameserver zum GSM hinzufügen
|
||||||
|
|
||||||
|
Diese Anleitung beschreibt alle Schritte, um einen neuen Gameserver in das Gameserver Management (GSM) System zu integrieren.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Proxmox LXC Container oder VM für den Gameserver
|
||||||
|
- SSH-Zugang vom GSM Server (192.168.2.30) zum neuen Server
|
||||||
|
- Freie IP-Adresse im Netzwerk (192.168.2.x)
|
||||||
|
|
||||||
|
## Schritt 1: Server aufsetzen
|
||||||
|
|
||||||
|
### 1.1 LXC Container / VM erstellen
|
||||||
|
|
||||||
|
Auf Proxmox (192.168.2.20) einen neuen Container oder VM erstellen:
|
||||||
|
- **LXC** für leichtgewichtige Server (Factorio, Palworld, etc.)
|
||||||
|
- **VM** für Server die spezielle Kernel-Features brauchen (Minecraft mit hohem RAM)
|
||||||
|
|
||||||
|
Empfohlene Ressourcen je nach Spiel:
|
||||||
|
| Spiel | CPU Cores | RAM | Speicher |
|
||||||
|
|-------|-----------|-----|----------|
|
||||||
|
| Factorio | 2 | 2 GB | 20 GB |
|
||||||
|
| Minecraft (Modded) | 4-6 | 8-16 GB | 50 GB |
|
||||||
|
| Palworld | 4 | 16 GB | 30 GB |
|
||||||
|
| Project Zomboid | 4 | 8 GB | 30 GB |
|
||||||
|
| V Rising | 2 | 4 GB | 20 GB |
|
||||||
|
|
||||||
|
### 1.2 Basis-Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# System updaten
|
||||||
|
apt update && apt upgrade -y
|
||||||
|
|
||||||
|
# Grundlegende Pakete
|
||||||
|
apt install -y curl wget screen htop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 SteamCMD installieren (für Steam-Spiele)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 32-bit Bibliotheken
|
||||||
|
apt install -y lib32gcc-s1
|
||||||
|
|
||||||
|
# Steam User erstellen
|
||||||
|
useradd -m -s /bin/bash steam
|
||||||
|
|
||||||
|
# SteamCMD installieren
|
||||||
|
su - steam
|
||||||
|
mkdir steamcmd && cd steamcmd
|
||||||
|
wget https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz
|
||||||
|
tar -xvzf steamcmd_linux.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Dedicated Server installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Als steam User
|
||||||
|
./steamcmd.sh +force_install_dir /opt/<spielname> +login anonymous +app_update <APP_ID> validate +quit
|
||||||
|
```
|
||||||
|
|
||||||
|
Steam App IDs:
|
||||||
|
- Palworld: `2394010`
|
||||||
|
- Project Zomboid: `380870`
|
||||||
|
- V Rising: `1829350`
|
||||||
|
- Factorio: `427520` (oder manuell von factorio.com)
|
||||||
|
|
||||||
|
## Schritt 2: Systemd Service erstellen
|
||||||
|
|
||||||
|
Erstelle `/etc/systemd/system/<spielname>.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=<Spielname> Dedicated Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=steam
|
||||||
|
Group=steam
|
||||||
|
WorkingDirectory=/opt/<spielname>
|
||||||
|
ExecStart=/opt/<spielname>/start.sh
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Aktivieren:
|
||||||
|
```bash
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable <spielname>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schritt 3: Node Exporter installieren
|
||||||
|
|
||||||
|
Für Metriken im GSM Dashboard:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install -y prometheus-node-exporter
|
||||||
|
systemctl enable prometheus-node-exporter
|
||||||
|
systemctl start prometheus-node-exporter
|
||||||
|
```
|
||||||
|
|
||||||
|
Prüfen ob Port 9100 erreichbar ist:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:9100/metrics | head
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schritt 4: SSH-Zugang einrichten
|
||||||
|
|
||||||
|
Auf dem GSM Server (192.168.2.30):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Public Key kopieren
|
||||||
|
ssh-copy-id -i /root/.ssh/id_ed25519.pub root@192.168.2.XX
|
||||||
|
```
|
||||||
|
|
||||||
|
Testen:
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.2.XX "hostname"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schritt 5: DNS Eintrag erstellen
|
||||||
|
|
||||||
|
### 5.1 Cloudflare DDNS Container aktualisieren
|
||||||
|
|
||||||
|
Auf dem Raspberry Pi (192.168.2.10):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alex
|
||||||
|
nano docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Neue Domain zur `CF_DOMAINS` Umgebungsvariable hinzufügen:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- CF_DOMAINS=gsm.zeasy.dev,factorio.zeasy.dev,<neuer-name>.zeasy.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Container neu starten:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d cloudflare-ddns
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Port Forwarding im Router
|
||||||
|
|
||||||
|
Benötigte Ports im Router freigeben:
|
||||||
|
| Spiel | Ports |
|
||||||
|
|-------|-------|
|
||||||
|
| Palworld | 8211/UDP, 27015/UDP |
|
||||||
|
| Minecraft | 25565/TCP |
|
||||||
|
| Factorio | 34197/UDP |
|
||||||
|
| Project Zomboid | 16261/UDP, 16262/UDP |
|
||||||
|
| V Rising | 9876/UDP, 9877/UDP |
|
||||||
|
|
||||||
|
## Schritt 6: GSM Backend konfigurieren
|
||||||
|
|
||||||
|
### 6.1 config.json erweitern
|
||||||
|
|
||||||
|
Auf dem GSM Server `/opt/gameserver-monitor/backend/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "<spielname>",
|
||||||
|
"name": "<Anzeigename>",
|
||||||
|
"host": "192.168.2.XX",
|
||||||
|
"type": "<spielname>",
|
||||||
|
"runtime": "systemd",
|
||||||
|
"serviceName": "<service-name>",
|
||||||
|
"rconPort": XXXXX,
|
||||||
|
"rconPassword": "<passwort>",
|
||||||
|
"workDir": "/opt/<spielname>",
|
||||||
|
"configPath": "/pfad/zur/config"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 SSH-Funktionen hinzufügen (optional)
|
||||||
|
|
||||||
|
Falls Config-Editor benötigt wird, in `backend/services/ssh.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const <SPIELNAME>_CONFIG_PATH = "/pfad/zu/configs";
|
||||||
|
const <SPIELNAME>_ALLOWED_FILES = ["config1.ini", "config2.ini"];
|
||||||
|
|
||||||
|
export async function list<Spielname>Configs(server) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function read<Spielname>Config(server, filename) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function write<Spielname>Config(server, filename, content) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Routes hinzufügen (optional)
|
||||||
|
|
||||||
|
In `backend/routes/servers.js` neue Endpoints für Config-Editor.
|
||||||
|
|
||||||
|
### 6.4 Backend neustarten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 restart gameserver-backend
|
||||||
|
pm2 logs gameserver-backend --lines 20
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schritt 7: Prometheus konfigurieren
|
||||||
|
|
||||||
|
Auf dem GSM Server `/etc/prometheus/prometheus.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- job_name: "<spielname>"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["192.168.2.XX:9100"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Prometheus neustarten:
|
||||||
|
```bash
|
||||||
|
systemctl restart prometheus
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schritt 8: Frontend erweitern
|
||||||
|
|
||||||
|
### 8.1 Server-Info in ServerCard.jsx
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const serverInfo = {
|
||||||
|
// ...bestehende Server
|
||||||
|
<spielname>: {
|
||||||
|
address: '<spielname>.zeasy.dev:<port>',
|
||||||
|
logo: '/<spielname>.png',
|
||||||
|
links: [
|
||||||
|
{ label: 'Steam', url: 'https://store.steampowered.com/app/XXXXXX' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In getServerInfo():
|
||||||
|
if (name.includes('<spielname>')) return serverInfo.<spielname>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Logo in ServerDetail.jsx
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (name.includes("<spielname>")) return "/<spielname>.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 ActivityLog Labels
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const serverLabels = {
|
||||||
|
// ...
|
||||||
|
<spielname>: '<Anzeigename>'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 API-Funktionen (falls Config-Editor)
|
||||||
|
|
||||||
|
In `frontend/src/api.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export async function get<Spielname>Configs(token) {
|
||||||
|
return fetchAPI('/servers/<spielname>/config', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.5 Config-Editor Komponente (optional)
|
||||||
|
|
||||||
|
Neue Datei `frontend/src/components/<Spielname>ConfigEditor.jsx` erstellen (basierend auf bestehenden Editoren).
|
||||||
|
|
||||||
|
### 8.6 Icon hinzufügen
|
||||||
|
|
||||||
|
PNG-Datei nach `frontend/public/<spielname>.png` kopieren (idealerweise 64x64 oder 128x128 Pixel).
|
||||||
|
|
||||||
|
### 8.7 Frontend bauen und deployen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/gameserver-monitor/frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schritt 9: Anzeigeeinstellungen konfigurieren (optional)
|
||||||
|
|
||||||
|
Nach dem Hinzufügen eines Servers können Superadmins die Anzeigeeinstellungen direkt im Web-Interface bearbeiten.
|
||||||
|
|
||||||
|
### 9.1 Anzeigeeinstellungen im Frontend
|
||||||
|
|
||||||
|
1. Als Superadmin einloggen
|
||||||
|
2. Server-Detailansicht öffnen
|
||||||
|
3. Tab **"Anzeige"** auswählen
|
||||||
|
4. Folgende Felder können bearbeitet werden:
|
||||||
|
- **Verbindungsadresse**: Die Adresse die Spielern zum Verbinden angezeigt wird (z.B. `palworld.zeasy.dev:8211`)
|
||||||
|
- **Hinweis**: Zusätzliche Informationen wie Passwort, Version, etc. (z.B. `Passwort: geheim123`)
|
||||||
|
5. Speichern klicken
|
||||||
|
|
||||||
|
### 9.2 Verhalten der Anzeigeeinstellungen
|
||||||
|
|
||||||
|
- **Datenbankwerte haben Priorität**: Wenn Anzeigeeinstellungen in der Datenbank gesetzt sind, überschreiben diese die Standardwerte aus dem Code
|
||||||
|
- **Fallback auf Standardwerte**: Wenn keine Datenbankeinstellungen existieren, werden die hartkodierten Standardwerte aus `ServerCard.jsx` verwendet
|
||||||
|
- **Minecraft Sonderfall**: Der Standard-Hinweis "Whitelist erforderlich" wird nur angezeigt, wenn kein eigener Hinweis gesetzt wurde
|
||||||
|
|
||||||
|
### 9.3 Technische Details
|
||||||
|
|
||||||
|
**Datenbank-Tabelle** (`users.sqlite`):
|
||||||
|
```sql
|
||||||
|
CREATE TABLE server_display_settings (
|
||||||
|
server_id TEXT PRIMARY KEY,
|
||||||
|
address TEXT,
|
||||||
|
hint TEXT,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**API-Endpoints**:
|
||||||
|
- `GET /api/servers/display-settings` - Alle Einstellungen abrufen (für Dashboard)
|
||||||
|
- `GET /api/servers/:id/display-settings` - Einstellungen für einen Server (Superadmin)
|
||||||
|
- `PUT /api/servers/:id/display-settings` - Einstellungen speichern (Superadmin)
|
||||||
|
|
||||||
|
**Dateien**:
|
||||||
|
- Backend: `backend/routes/servers.js`, `backend/db/init.js`
|
||||||
|
- Frontend: `frontend/src/pages/ServerDetail.jsx`, `frontend/src/components/ServerCard.jsx`, `frontend/src/api.js`
|
||||||
|
|
||||||
|
## Checkliste
|
||||||
|
|
||||||
|
- [ ] Server aufgesetzt und Spiel installiert
|
||||||
|
- [ ] Systemd Service erstellt und aktiviert
|
||||||
|
- [ ] Node Exporter installiert
|
||||||
|
- [ ] SSH-Zugang vom GSM Server eingerichtet
|
||||||
|
- [ ] DNS Eintrag erstellt
|
||||||
|
- [ ] Port Forwarding konfiguriert
|
||||||
|
- [ ] Backend config.json erweitert
|
||||||
|
- [ ] Prometheus Target hinzugefügt
|
||||||
|
- [ ] Frontend ServerCard/ServerDetail erweitert
|
||||||
|
- [ ] Server-Icon hinzugefügt
|
||||||
|
- [ ] Frontend gebaut
|
||||||
|
- [ ] Backend neugestartet
|
||||||
|
- [ ] Anzeigeeinstellungen konfiguriert (Verbindungsadresse, Hinweis)
|
||||||
|
|
||||||
|
## Aktuelle Server-Übersicht
|
||||||
|
|
||||||
|
| Server | IP | Typ | Ports |
|
||||||
|
|--------|-----|-----|-------|
|
||||||
|
| GSM Monitor | 192.168.2.30 | LXC | 3000, 9090 |
|
||||||
|
| Factorio | 192.168.2.50 | LXC | 34197/UDP |
|
||||||
|
| Minecraft | 192.168.2.51 | VM | 25565/TCP |
|
||||||
|
| V Rising | 192.168.2.52 | LXC | 9876-9877/UDP |
|
||||||
|
| Palworld | 192.168.2.53 | LXC | 8211/UDP, 27015/UDP |
|
||||||
|
| Project Zomboid | 10.0.30.66 | VM (extern) | 16261-16262/UDP |
|
||||||
677
gsm-backend/routes/servers.js
Normal file
677
gsm-backend/routes/servers.js
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { authenticateToken, optionalAuth, requireRole } from '../middleware/auth.js';
|
||||||
|
import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig } from '../services/ssh.js';
|
||||||
|
import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js';
|
||||||
|
import { getServerMetricsHistory, getCurrentMetrics } from '../services/prometheus.js';
|
||||||
|
import { initWhitelistCache, getCachedWhitelist, setCachedWhitelist, initFactorioTemplates, getFactorioTemplates, createFactorioTemplate, deleteFactorioTemplate, initFactorioWorldSettings, getFactorioWorldSettings, saveFactorioWorldSettings, deleteFactorioWorldSettings, initAutoShutdownSettings, getAutoShutdownSettings, setAutoShutdownSettings, initActivityLog, logActivity, getActivityLog, initServerDisplaySettings, getServerDisplaySettings, getAllServerDisplaySettings, setServerDisplaySettings } from '../db/init.js';
|
||||||
|
import { getEmptySince } from '../services/autoshutdown.js';
|
||||||
|
import { getDefaultMapGenSettings, getPresetNames, getPreset } from '../services/factorio.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize tables
|
||||||
|
initWhitelistCache();
|
||||||
|
initActivityLog();
|
||||||
|
initFactorioTemplates();
|
||||||
|
initFactorioWorldSettings();
|
||||||
|
initServerDisplaySettings();
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
function formatBytes(bytes, forceUnit = null) {
|
||||||
|
if (bytes === 0) return { value: 0, unit: forceUnit || "B" };
|
||||||
|
const gb = bytes / (1024 * 1024 * 1024);
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
if (forceUnit === "GB") return { value: gb, unit: "GB" };
|
||||||
|
if (forceUnit === "MB") return { value: mb, unit: "MB" };
|
||||||
|
if (gb >= 1) return { value: gb, unit: "GB" };
|
||||||
|
return { value: mb, unit: "MB" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ FACTORIO ROUTES ============
|
||||||
|
|
||||||
|
// Factorio: List saves
|
||||||
|
router.get("/factorio/saves", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.type === "factorio");
|
||||||
|
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||||
|
|
||||||
|
const saves = await listFactorioSaves(server);
|
||||||
|
res.json({ saves });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factorio: Get presets and default settings
|
||||||
|
router.get("/factorio/presets", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const presets = getPresetNames();
|
||||||
|
const defaultSettings = getDefaultMapGenSettings();
|
||||||
|
res.json({ presets, defaultSettings });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factorio: Get preset by name
|
||||||
|
router.get("/factorio/presets/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const preset = getPreset(req.params.name);
|
||||||
|
res.json({ settings: preset });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factorio: List templates
|
||||||
|
router.get("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const templates = getFactorioTemplates();
|
||||||
|
res.json({ templates: templates.map(t => ({ ...t, settings: JSON.parse(t.settings) })) });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factorio: Create template
|
||||||
|
router.post("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, settings } = req.body;
|
||||||
|
if (!name || !settings) {
|
||||||
|
return res.status(400).json({ error: "Name and settings required" });
|
||||||
|
}
|
||||||
|
const id = createFactorioTemplate(name, settings, req.user.id);
|
||||||
|
res.json({ id, message: "Template created" });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes("UNIQUE constraint")) {
|
||||||
|
return res.status(400).json({ error: "Template name already exists" });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factorio: Delete template
|
||||||
|
router.delete("/factorio/templates/:id", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = deleteFactorioTemplate(parseInt(req.params.id));
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: "Template not found" });
|
||||||
|
}
|
||||||
|
res.json({ message: "Template deleted" });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factorio: Create new world
|
||||||
|
router.post("/factorio/create-world", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { saveName, settings } = req.body;
|
||||||
|
if (!saveName) {
|
||||||
|
return res.status(400).json({ error: "Save name required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.type === "factorio");
|
||||||
|
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||||
|
|
||||||
|
const finalSettings = settings || getDefaultMapGenSettings();
|
||||||
|
await createFactorioWorld(server, saveName, finalSettings);
|
||||||
|
|
||||||
|
// Save settings to database for later reference
|
||||||
|
saveFactorioWorldSettings(saveName, finalSettings, req.user.id);
|
||||||
|
logActivity(req.user.id, req.user.username, 'factorio_world_create', 'factorio', saveName, req.user.discordId, req.user.avatar);
|
||||||
|
|
||||||
|
res.json({ message: "World created", saveName });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factorio: Delete save
|
||||||
|
router.delete("/factorio/saves/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.type === "factorio");
|
||||||
|
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||||
|
|
||||||
|
await deleteFactorioSave(server, req.params.name);
|
||||||
|
// Also delete stored settings if they exist
|
||||||
|
deleteFactorioWorldSettings(req.params.name);
|
||||||
|
logActivity(req.user.id, req.user.username, 'factorio_world_delete', 'factorio', req.params.name, req.user.discordId, req.user.avatar);
|
||||||
|
res.json({ message: "Save deleted" });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factorio: Get world settings
|
||||||
|
router.get("/factorio/saves/:name/settings", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = getFactorioWorldSettings(req.params.name);
|
||||||
|
if (!settings) {
|
||||||
|
return res.json({
|
||||||
|
legacy: true,
|
||||||
|
message: "This is a legacy world created before settings tracking was implemented"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
legacy: false,
|
||||||
|
settings: JSON.parse(settings.settings),
|
||||||
|
createdBy: settings.created_by,
|
||||||
|
createdAt: settings.created_at
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factorio: Get current/default save
|
||||||
|
router.get("/factorio/current-save", authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.type === "factorio");
|
||||||
|
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||||
|
|
||||||
|
const result = await getFactorioCurrentSave(server);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ ZOMBOID CONFIG ROUTES ============
|
||||||
|
|
||||||
|
// Zomboid: List config files
|
||||||
|
router.get("/zomboid/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.type === "zomboid");
|
||||||
|
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||||
|
|
||||||
|
const files = await listZomboidConfigs(server);
|
||||||
|
res.json({ files });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zomboid: Read config file
|
||||||
|
router.get("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.type === "zomboid");
|
||||||
|
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||||
|
|
||||||
|
const content = await readZomboidConfig(server, req.params.filename);
|
||||||
|
res.json({ filename: req.params.filename, content });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message === "File not allowed") {
|
||||||
|
return res.status(403).json({ error: err.message });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zomboid: Write config file
|
||||||
|
router.put("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.type === "zomboid");
|
||||||
|
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||||
|
|
||||||
|
const { content } = req.body;
|
||||||
|
if (content === undefined) {
|
||||||
|
return res.status(400).json({ error: "Content required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeZomboidConfig(server, req.params.filename, content);
|
||||||
|
logActivity(req.user.id, req.user.username, 'zomboid_config', 'zomboid', req.params.filename, req.user.discordId, req.user.avatar);
|
||||||
|
res.json({ message: "Config saved", filename: req.params.filename });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message === "File not allowed") {
|
||||||
|
return res.status(403).json({ error: err.message });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Palworld: List config files
|
||||||
|
router.get("/palworld/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.type === "palworld");
|
||||||
|
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||||
|
const files = await listPalworldConfigs(server);
|
||||||
|
res.json({ files });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Palworld: Read config file
|
||||||
|
router.get("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.type === "palworld");
|
||||||
|
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||||
|
const content = await readPalworldConfig(server, req.params.filename);
|
||||||
|
res.json({ filename: req.params.filename, content });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message === "File not allowed") {
|
||||||
|
return res.status(403).json({ error: err.message });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Palworld: Write config file
|
||||||
|
router.put("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.type === "palworld");
|
||||||
|
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||||
|
const { content } = req.body;
|
||||||
|
if (content === undefined) {
|
||||||
|
return res.status(400).json({ error: "Content required" });
|
||||||
|
}
|
||||||
|
await writePalworldConfig(server, req.params.filename, content);
|
||||||
|
logActivity(req.user.id, req.user.username, "palworld_config", "palworld", req.params.filename, req.user.discordId, req.user.avatar);
|
||||||
|
res.json({ message: "Config saved", filename: req.params.filename });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message === "File not allowed") {
|
||||||
|
return res.status(403).json({ error: err.message });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ GENERAL ROUTES ============
|
||||||
|
|
||||||
|
// Get all servers with status
|
||||||
|
router.get('/', optionalAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const servers = await Promise.all(config.servers.map(async (server) => {
|
||||||
|
// Quick check if host is unreachable - skip expensive operations
|
||||||
|
const hostUnreachable = isHostFailed(server.host, server.sshUser);
|
||||||
|
// If host is unreachable, return immediately with minimal data
|
||||||
|
if (hostUnreachable) {
|
||||||
|
const metrics = await getCurrentMetrics(server.id).catch(() => ({
|
||||||
|
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
|
||||||
|
}));
|
||||||
|
const memTotal = formatBytes(metrics.memoryTotal);
|
||||||
|
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||||
|
return {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
type: server.type,
|
||||||
|
status: "unreachable",
|
||||||
|
running: false,
|
||||||
|
metrics: {
|
||||||
|
cpu: metrics.cpu,
|
||||||
|
cpuCores: metrics.cpuCores,
|
||||||
|
memory: metrics.memory,
|
||||||
|
memoryUsed: memUsed.value,
|
||||||
|
memoryTotal: memTotal.value,
|
||||||
|
memoryUnit: memTotal.unit,
|
||||||
|
uptime: 0
|
||||||
|
},
|
||||||
|
players: { online: 0, max: null, list: [] },
|
||||||
|
hasRcon: !!server.rconPassword
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [status, metrics, players, playerList, processUptime] = await Promise.all([
|
||||||
|
getServerStatus(server),
|
||||||
|
getCurrentMetrics(server.id).catch(() => ({
|
||||||
|
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
|
||||||
|
})),
|
||||||
|
server.rconPassword ? getPlayers(server).catch(() => ({ online: 0, max: null })) : { online: 0, max: null },
|
||||||
|
server.rconPassword ? getPlayerList(server).catch(() => ({ players: [] })) : { players: [] },
|
||||||
|
getProcessUptime(server).catch(() => 0)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const memTotal = formatBytes(metrics.memoryTotal);
|
||||||
|
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||||
|
|
||||||
|
// Get auto-shutdown info
|
||||||
|
const shutdownSettings = getAutoShutdownSettings(server.id);
|
||||||
|
const emptySince = getEmptySince(server.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
type: server.type,
|
||||||
|
status,
|
||||||
|
running: status === 'online',
|
||||||
|
metrics: {
|
||||||
|
cpu: metrics.cpu,
|
||||||
|
cpuCores: metrics.cpuCores,
|
||||||
|
memory: metrics.memory,
|
||||||
|
memoryUsed: memUsed.value,
|
||||||
|
memoryTotal: memTotal.value,
|
||||||
|
memoryUnit: memTotal.unit,
|
||||||
|
uptime: processUptime
|
||||||
|
},
|
||||||
|
players: {
|
||||||
|
...players,
|
||||||
|
list: playerList.players
|
||||||
|
},
|
||||||
|
hasRcon: !!server.rconPassword,
|
||||||
|
autoShutdown: {
|
||||||
|
enabled: shutdownSettings?.enabled === 1 || false,
|
||||||
|
timeoutMinutes: shutdownSettings?.timeout_minutes || 15,
|
||||||
|
emptySinceMinutes: emptySince
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(servers);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Activity Log (superadmin only)
|
||||||
|
router.get('/activity-log', authenticateToken, requireRole('superadmin'), (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 100;
|
||||||
|
const logs = getActivityLog(limit);
|
||||||
|
res.json(logs);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all server display settings (for ServerCard)
|
||||||
|
router.get("/display-settings", optionalAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = getAllServerDisplaySettings();
|
||||||
|
const result = {};
|
||||||
|
settings.forEach(s => {
|
||||||
|
result[s.server_id] = { address: s.address, hint: s.hint };
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single server
|
||||||
|
router.get('/:id', optionalAuth, async (req, res) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [status, metrics, players, playerList, processUptime] = await Promise.all([
|
||||||
|
getServerStatus(server),
|
||||||
|
getCurrentMetrics(server.id),
|
||||||
|
server.rconPassword ? getPlayers(server) : { online: 0, max: null },
|
||||||
|
server.rconPassword ? getPlayerList(server) : { players: [] },
|
||||||
|
getProcessUptime(server).catch(() => 0)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const memTotal = formatBytes(metrics.memoryTotal);
|
||||||
|
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
type: server.type,
|
||||||
|
status,
|
||||||
|
running: status === 'online',
|
||||||
|
metrics: {
|
||||||
|
cpu: metrics.cpu,
|
||||||
|
cpuCores: metrics.cpuCores,
|
||||||
|
memory: metrics.memory,
|
||||||
|
memoryUsed: memUsed.value,
|
||||||
|
memoryTotal: memTotal.value,
|
||||||
|
memoryUnit: memTotal.unit,
|
||||||
|
uptime: processUptime
|
||||||
|
},
|
||||||
|
players: {
|
||||||
|
...players,
|
||||||
|
list: playerList.players
|
||||||
|
},
|
||||||
|
hasRcon: !!server.rconPassword
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get metrics history from Prometheus
|
||||||
|
router.get('/:id/metrics/history', optionalAuth, async (req, res) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
const range = req.query.range || '1h';
|
||||||
|
const validRanges = ['15m', '1h', '6h', '24h'];
|
||||||
|
if (!validRanges.includes(range)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid range. Valid: 15m, 1h, 6h, 24h' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const history = await getServerMetricsHistory(server.id, range);
|
||||||
|
res.json(history);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get player list
|
||||||
|
router.get('/:id/players', authenticateToken, async (req, res) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
if (!server.rconPassword) {
|
||||||
|
return res.status(400).json({ error: 'RCON not configured for this server' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [count, list] = await Promise.all([
|
||||||
|
getPlayers(server),
|
||||||
|
getPlayerList(server)
|
||||||
|
]);
|
||||||
|
res.json({ ...count, list: list.players });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get console logs (moderator+)
|
||||||
|
router.get('/:id/logs', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines = parseInt(req.query.lines) || 100;
|
||||||
|
const logs = await getConsoleLog(server, lines);
|
||||||
|
res.json({ logs });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Power actions (moderator+)
|
||||||
|
router.post('/:id/start', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { save } = req.body || {};
|
||||||
|
await startServer(server, { save });
|
||||||
|
logActivity(req.user.id, req.user.username, 'server_start', server.id, save ? 'Save: ' + save : null, req.user.discordId, req.user.avatar);
|
||||||
|
res.json({ message: 'Server starting' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/stop', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stopServer(server);
|
||||||
|
logActivity(req.user.id, req.user.username, 'server_stop', server.id, null, req.user.discordId, req.user.avatar);
|
||||||
|
res.json({ message: 'Server stopping' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/restart', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await restartServer(server);
|
||||||
|
logActivity(req.user.id, req.user.username, 'server_restart', server.id, null, req.user.discordId, req.user.avatar);
|
||||||
|
res.json({ message: 'Server restarting' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get whitelist (with server-side caching)
|
||||||
|
router.get('/:id/whitelist', optionalAuth, async (req, res) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
if (!server.rconPassword) {
|
||||||
|
return res.json({ players: [], cached: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendRconCommand(server, 'whitelist list');
|
||||||
|
const match = response.trim().match(/:\s*(.+)$/);
|
||||||
|
let players = [];
|
||||||
|
if (match && match[1]) {
|
||||||
|
players = match[1].split(',').map(p => p.trim()).filter(p => p.length > 0);
|
||||||
|
}
|
||||||
|
setCachedWhitelist(server.id, players);
|
||||||
|
res.json({ players, cached: false });
|
||||||
|
} catch (err) {
|
||||||
|
const players = getCachedWhitelist(server.id);
|
||||||
|
res.json({ players, cached: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// RCON command (moderator+)
|
||||||
|
router.post('/:id/rcon', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
if (!server.rconPassword) {
|
||||||
|
return res.status(400).json({ error: 'RCON not configured for this server' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { command } = req.body;
|
||||||
|
if (!command) {
|
||||||
|
return res.status(400).json({ error: 'Command required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendRconCommand(server, command);
|
||||||
|
logActivity(req.user.id, req.user.username, 'rcon_command', server.id, command, req.user.discordId, req.user.avatar);
|
||||||
|
if (command.startsWith("whitelist ")) {
|
||||||
|
try {
|
||||||
|
const listResponse = await sendRconCommand(server, "whitelist list");
|
||||||
|
const match = listResponse.trim().match(/:\s*(.+)$/);
|
||||||
|
let players = [];
|
||||||
|
if (match && match[1]) {
|
||||||
|
players = match[1].split(",").map(p => p.trim()).filter(p => p.length > 0);
|
||||||
|
}
|
||||||
|
setCachedWhitelist(server.id, players);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
res.json({ response });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Initialize auto-shutdown settings table
|
||||||
|
initAutoShutdownSettings();
|
||||||
|
|
||||||
|
// ============ AUTO-SHUTDOWN ROUTES ============
|
||||||
|
|
||||||
|
// Get auto-shutdown settings for a server
|
||||||
|
router.get('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
const settings = getAutoShutdownSettings(req.params.id);
|
||||||
|
const emptySince = getEmptySince(req.params.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
enabled: settings?.enabled === 1 || false,
|
||||||
|
timeoutMinutes: settings?.timeout_minutes || 15,
|
||||||
|
emptySinceMinutes: emptySince
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update auto-shutdown settings for a server
|
||||||
|
router.put('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const server = config.servers.find(s => s.id === req.params.id);
|
||||||
|
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
const { enabled, timeoutMinutes } = req.body;
|
||||||
|
const timeout = Math.max(1, Math.min(1440, timeoutMinutes || 15));
|
||||||
|
|
||||||
|
setAutoShutdownSettings(req.params.id, enabled, timeout);
|
||||||
|
logActivity(req.user.id, req.user.username, 'autoshutdown_config', req.params.id, 'Enabled: ' + enabled + ', Timeout: ' + timeout + ' min', req.user.discordId, req.user.avatar);
|
||||||
|
console.log('[AutoShutdown] Settings updated for ' + req.params.id + ': enabled=' + enabled + ', timeout=' + timeout + 'min');
|
||||||
|
|
||||||
|
res.json({ message: 'Auto-shutdown settings updated', enabled, timeoutMinutes: timeout });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Get display settings for a specific server
|
||||||
|
router.get("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = getServerDisplaySettings(req.params.id);
|
||||||
|
res.json(settings || { server_id: req.params.id, address: "", hint: "" });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update display settings for a server (superadmin only)
|
||||||
|
router.put("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
|
||||||
|
const { address, hint } = req.body;
|
||||||
|
try {
|
||||||
|
setServerDisplaySettings(req.params.id, address || "", hint || "");
|
||||||
|
res.json({ message: "Display settings updated", address, hint });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export default router;
|
||||||
159
gsm-backend/services/rcon.js
Normal file
159
gsm-backend/services/rcon.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Rcon } from 'rcon-client';
|
||||||
|
|
||||||
|
const rconConnections = new Map();
|
||||||
|
const playerCache = new Map();
|
||||||
|
const CACHE_TTL = 30000; // 30 seconds
|
||||||
|
|
||||||
|
async function getConnection(server) {
|
||||||
|
const key = `${server.host}:${server.rconPort}`;
|
||||||
|
|
||||||
|
if (rconConnections.has(key)) {
|
||||||
|
const conn = rconConnections.get(key);
|
||||||
|
if (conn.authenticated) {
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
rconConnections.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rcon = await Rcon.connect({
|
||||||
|
host: server.host,
|
||||||
|
port: server.rconPort,
|
||||||
|
password: server.rconPassword,
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
rcon.on('error', (err) => {
|
||||||
|
console.error(`RCON error for ${key}:`, err.message);
|
||||||
|
rconConnections.delete(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
rcon.on('end', () => {
|
||||||
|
rconConnections.delete(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle socket errors to prevent crash
|
||||||
|
if (rcon.socket) {
|
||||||
|
rcon.socket.on('error', (err) => {
|
||||||
|
console.error(`RCON socket error for ${key}:`, err.message);
|
||||||
|
rconConnections.delete(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rconConnections.set(key, rcon);
|
||||||
|
return rcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendRconCommand(server, command) {
|
||||||
|
if (!server.rconPassword) {
|
||||||
|
throw new Error('RCON password not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rcon = await getConnection(server);
|
||||||
|
return await rcon.send(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlayers(server) {
|
||||||
|
const cacheKey = `${server.id}-count`;
|
||||||
|
const cached = playerCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && Date.now() - cached.time < CACHE_TTL) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result = { online: 0, max: null };
|
||||||
|
|
||||||
|
if (server.type === 'minecraft') {
|
||||||
|
const response = await sendRconCommand(server, 'list');
|
||||||
|
const match = response.match(/There are (\d+) of a max of (\d+) players online/);
|
||||||
|
if (match) {
|
||||||
|
result = { online: parseInt(match[1]), max: parseInt(match[2]) };
|
||||||
|
}
|
||||||
|
} else if (server.type === 'factorio') {
|
||||||
|
const response = await sendRconCommand(server, '/players online count');
|
||||||
|
const match = response.match(/(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
result = { online: parseInt(match[1]), max: null };
|
||||||
|
}
|
||||||
|
} else if (server.type === 'zomboid') {
|
||||||
|
const response = await sendRconCommand(server, 'players');
|
||||||
|
// Format: "Players connected (X):" or "Players connected (X): Player1, Player2"
|
||||||
|
const match = response.match(/Players connected \((\d+)\)/);
|
||||||
|
if (match) {
|
||||||
|
result = { online: parseInt(match[1]), max: null };
|
||||||
|
}
|
||||||
|
} else if (server.type === 'vrising') {
|
||||||
|
const response = await sendRconCommand(server, 'listusers');
|
||||||
|
// Count lines that contain player info
|
||||||
|
const lines = response.split('\n').filter(l => l.trim() && !l.includes('listusers'));
|
||||||
|
result = { online: lines.length, max: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
playerCache.set(cacheKey, { data: result, time: Date.now() });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to get players for ${server.id}:`, err.message);
|
||||||
|
// Clear cache on error - server might be offline
|
||||||
|
playerCache.delete(cacheKey);
|
||||||
|
return { online: 0, max: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlayerList(server) {
|
||||||
|
const cacheKey = `${server.id}-list`;
|
||||||
|
const cached = playerCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && Date.now() - cached.time < CACHE_TTL) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let players = [];
|
||||||
|
|
||||||
|
if (server.type === 'minecraft') {
|
||||||
|
const response = await sendRconCommand(server, 'list');
|
||||||
|
// Format: "There are X of a max of Y players online: Player1, Player2, Player3"
|
||||||
|
const colonIndex = response.indexOf(':');
|
||||||
|
if (colonIndex !== -1) {
|
||||||
|
const playerPart = response.substring(colonIndex + 1).trim();
|
||||||
|
if (playerPart) {
|
||||||
|
players = playerPart.split(',').map(p => p.trim()).filter(p => p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (server.type === 'factorio') {
|
||||||
|
const response = await sendRconCommand(server, '/players online');
|
||||||
|
// Format: "Online players (X):\n player1 (online)\n player2 (online)"
|
||||||
|
const lines = response.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^\s*(\S+)\s*\(online\)/);
|
||||||
|
if (match) {
|
||||||
|
players.push(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (server.type === 'zomboid') {
|
||||||
|
const response = await sendRconCommand(server, 'players');
|
||||||
|
// Format: "Players connected (X): \n-Player1\n-Player2\n-Player3"
|
||||||
|
const lines = response.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith('-')) {
|
||||||
|
players.push(trimmed.substring(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (server.type === 'vrising') {
|
||||||
|
const response = await sendRconCommand(server, 'listusers');
|
||||||
|
// Parse player names from listusers output
|
||||||
|
const lines = response.split('\n').filter(l => l.trim() && !l.includes('listusers'));
|
||||||
|
players = lines.map(l => l.trim()).filter(p => p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { players };
|
||||||
|
playerCache.set(cacheKey, { data: result, time: Date.now() });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to get player list for ${server.id}:`, err.message);
|
||||||
|
// Clear cache on error - server might be offline
|
||||||
|
playerCache.delete(cacheKey);
|
||||||
|
return { players: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
493
gsm-backend/services/ssh.js
Normal file
493
gsm-backend/services/ssh.js
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
import { NodeSSH } from "node-ssh";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sshConnections = new Map();
|
||||||
|
const failedHosts = new Map(); // Cache failed connections
|
||||||
|
const FAILED_HOST_TTL = 60000; // 60 seconds before retry
|
||||||
|
const SSH_TIMEOUT = 5000;
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if host is marked as failed (non-blocking)
|
||||||
|
export function isHostFailed(host, username = "root") {
|
||||||
|
const key = username + "@" + host;
|
||||||
|
const failedAt = failedHosts.get(key);
|
||||||
|
return failedAt && Date.now() - failedAt < FAILED_HOST_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark host as failed
|
||||||
|
export function markHostFailed(host, username = "root") {
|
||||||
|
const key = username + "@" + host;
|
||||||
|
failedHosts.set(key, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear failed status
|
||||||
|
export function clearHostFailed(host, username = "root") {
|
||||||
|
const key = username + "@" + host;
|
||||||
|
failedHosts.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConnection(host, username = "root") {
|
||||||
|
const key = username + "@" + host;
|
||||||
|
|
||||||
|
// Check if host recently failed - throw immediately
|
||||||
|
if (isHostFailed(host, username)) {
|
||||||
|
throw new Error("Host recently unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sshConnections.has(key)) {
|
||||||
|
const conn = sshConnections.get(key);
|
||||||
|
if (conn.isConnected()) return conn;
|
||||||
|
sshConnections.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssh = new NodeSSH();
|
||||||
|
try {
|
||||||
|
await ssh.connect({
|
||||||
|
host,
|
||||||
|
username,
|
||||||
|
privateKeyPath: "/root/.ssh/id_ed25519",
|
||||||
|
readyTimeout: SSH_TIMEOUT
|
||||||
|
});
|
||||||
|
clearHostFailed(host, username);
|
||||||
|
sshConnections.set(key, ssh);
|
||||||
|
return ssh;
|
||||||
|
} catch (err) {
|
||||||
|
markHostFailed(host, username);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns: "online", "starting", "stopping", "offline"
|
||||||
|
export async function getServerStatus(server) {
|
||||||
|
try {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
|
||||||
|
if (server.runtime === 'docker') {
|
||||||
|
const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`);
|
||||||
|
const status = result.stdout.trim();
|
||||||
|
if (status === 'running') return 'online';
|
||||||
|
if (status === 'restarting' || status === 'created') return 'starting';
|
||||||
|
if (status === 'removing' || status === 'paused') return 'stopping';
|
||||||
|
return 'offline';
|
||||||
|
} else if (server.runtime === 'systemd') {
|
||||||
|
const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`);
|
||||||
|
const status = result.stdout.trim();
|
||||||
|
if (status === 'active') return 'online';
|
||||||
|
if (status === 'activating' || status === 'reloading') return 'starting';
|
||||||
|
if (status === 'deactivating') return 'stopping';
|
||||||
|
return 'offline';
|
||||||
|
} else {
|
||||||
|
const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||||
|
if (result.code === 0) {
|
||||||
|
const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`);
|
||||||
|
const uptime = parseInt(uptimeResult.stdout.trim()) || 999;
|
||||||
|
if (uptime < 60) return 'starting';
|
||||||
|
return 'online';
|
||||||
|
}
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to get status for ${server.id}:`, err.message);
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startServer(server, options = {}) {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
|
||||||
|
if (server.runtime === 'docker') {
|
||||||
|
// Factorio with specific save
|
||||||
|
if (server.type === 'factorio' && options.save) {
|
||||||
|
const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save;
|
||||||
|
// Stop and remove existing container
|
||||||
|
await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`);
|
||||||
|
await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`);
|
||||||
|
// Start with specific save
|
||||||
|
await ssh.execCommand(`
|
||||||
|
docker run -d \
|
||||||
|
--name ${server.containerName} \
|
||||||
|
-p 34197:34197/udp \
|
||||||
|
-p 27015:27015/tcp \
|
||||||
|
-v /srv/docker/factorio/data:/factorio \
|
||||||
|
-e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \
|
||||||
|
-e TZ=Europe/Berlin \
|
||||||
|
-e PUID=845 \
|
||||||
|
-e PGID=845 \
|
||||||
|
--restart=unless-stopped \
|
||||||
|
factoriotools/factorio
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
await ssh.execCommand(`docker start ${server.containerName}`);
|
||||||
|
}
|
||||||
|
} else if (server.runtime === 'systemd') {
|
||||||
|
await ssh.execCommand(`systemctl start ${server.serviceName}`);
|
||||||
|
} else {
|
||||||
|
await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopServer(server) {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
|
||||||
|
if (server.runtime === 'docker') {
|
||||||
|
await ssh.execCommand(`docker stop ${server.containerName}`);
|
||||||
|
} else if (server.runtime === 'systemd') {
|
||||||
|
await ssh.execCommand(`systemctl stop ${server.serviceName}`);
|
||||||
|
} else {
|
||||||
|
// Different stop commands per server type
|
||||||
|
const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop';
|
||||||
|
await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`);
|
||||||
|
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||||
|
if (check.code !== 0) {
|
||||||
|
console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Force killing ${server.id} after timeout`);
|
||||||
|
await ssh.execCommand(`screen -S ${server.screenName} -X quit`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartServer(server) {
|
||||||
|
await stopServer(server);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
await startServer(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConsoleLog(server, lines = 50) {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
|
||||||
|
if (server.runtime === 'docker') {
|
||||||
|
const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`);
|
||||||
|
return result.stdout || result.stderr;
|
||||||
|
} else if (server.runtime === 'systemd') {
|
||||||
|
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`);
|
||||||
|
return result.stdout || result.stderr;
|
||||||
|
} else if (server.logFile) {
|
||||||
|
const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`);
|
||||||
|
return result.stdout;
|
||||||
|
} else {
|
||||||
|
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`);
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProcessUptime(server) {
|
||||||
|
try {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
|
||||||
|
if (server.runtime === "docker") {
|
||||||
|
const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`);
|
||||||
|
if (result.stdout.trim()) {
|
||||||
|
const startTime = new Date(result.stdout.trim());
|
||||||
|
if (!isNaN(startTime.getTime())) {
|
||||||
|
return Math.floor((Date.now() - startTime.getTime()) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (server.runtime === "systemd") {
|
||||||
|
const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`);
|
||||||
|
const startEpoch = parseInt(result.stdout.trim());
|
||||||
|
if (!isNaN(startEpoch)) {
|
||||||
|
return Math.floor(Date.now() / 1000) - startEpoch;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`);
|
||||||
|
const uptime = parseInt(result.stdout.trim());
|
||||||
|
if (!isNaN(uptime)) return uptime;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to get uptime for ${server.id}:`, err.message);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ FACTORIO-SPECIFIC FUNCTIONS ============
|
||||||
|
|
||||||
|
export async function listFactorioSaves(server) {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null');
|
||||||
|
|
||||||
|
if (result.code !== 0 || !result.stdout.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const saves = [];
|
||||||
|
const lines = result.stdout.trim().split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
const filename = match[7].split('/').pop();
|
||||||
|
// Skip autosaves
|
||||||
|
if (filename.startsWith('_autosave')) continue;
|
||||||
|
|
||||||
|
const size = match[5];
|
||||||
|
const modified = match[6];
|
||||||
|
|
||||||
|
saves.push({
|
||||||
|
name: filename.replace('.zip', ''),
|
||||||
|
filename,
|
||||||
|
size,
|
||||||
|
modified
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return saves;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFactorioSave(server, saveName) {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`;
|
||||||
|
|
||||||
|
// Security check - prevent path traversal
|
||||||
|
if (filename.includes('/') || filename.includes('..')) {
|
||||||
|
throw new Error('Invalid save name');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`);
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(result.stderr || 'Failed to delete save');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFactorioWorld(server, saveName, settings) {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
|
||||||
|
// Security check
|
||||||
|
if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) {
|
||||||
|
throw new Error("Invalid save name - no spaces or special characters allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write map-gen-settings.json
|
||||||
|
const settingsJson = JSON.stringify(settings, null, 2);
|
||||||
|
await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF'
|
||||||
|
${settingsJson}
|
||||||
|
SETTINGSEOF`);
|
||||||
|
|
||||||
|
// Create new world using --create flag (correct method)
|
||||||
|
console.log(`Creating new Factorio world: ${saveName}`);
|
||||||
|
const createResult = await ssh.execCommand(`
|
||||||
|
docker run --rm \
|
||||||
|
-v /srv/docker/factorio/data:/factorio \
|
||||||
|
factoriotools/factorio \
|
||||||
|
/opt/factorio/bin/x64/factorio \
|
||||||
|
--create /factorio/saves/${saveName}.zip \
|
||||||
|
--map-gen-settings /factorio/config/map-gen-settings.json
|
||||||
|
`, { execOptions: { timeout: 120000 } });
|
||||||
|
|
||||||
|
console.log("World creation output:", createResult.stdout, createResult.stderr);
|
||||||
|
|
||||||
|
// Verify save was created
|
||||||
|
const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`);
|
||||||
|
if (checkResult.code !== 0) {
|
||||||
|
throw new Error("World creation failed - save file not found. Output: " + createResult.stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFactorioCurrentSave(server) {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
|
||||||
|
// Check if container has SAVE_NAME env set
|
||||||
|
const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`);
|
||||||
|
if (envResult.stdout.trim()) {
|
||||||
|
const saveName = envResult.stdout.trim().replace("SAVE_NAME=", "");
|
||||||
|
return { save: saveName, source: "configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise find newest save file
|
||||||
|
const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1");
|
||||||
|
if (result.stdout.trim()) {
|
||||||
|
const filename = result.stdout.trim().split("/").pop().replace(".zip", "");
|
||||||
|
return { save: filename, source: "newest" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { save: null, source: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ============ ZOMBOID CONFIG FUNCTIONS ============
|
||||||
|
|
||||||
|
const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server";
|
||||||
|
const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"];
|
||||||
|
|
||||||
|
export async function listZomboidConfigs(server) {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`;
|
||||||
|
const result = await ssh.execCommand(cmd);
|
||||||
|
|
||||||
|
if (result.code !== 0 || !result.stdout.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
const lines = result.stdout.trim().split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
const fullPath = match[7];
|
||||||
|
const filename = fullPath.split("/").pop();
|
||||||
|
|
||||||
|
if (!ALLOWED_CONFIG_FILES.includes(filename)) continue;
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
filename,
|
||||||
|
size: parseInt(match[5]),
|
||||||
|
modified: match[6]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readZomboidConfig(server, filename) {
|
||||||
|
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||||
|
throw new Error("File not allowed");
|
||||||
|
}
|
||||||
|
if (filename.includes("/") || filename.includes("..")) {
|
||||||
|
throw new Error("Invalid filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`);
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(result.stderr || "Failed to read config file");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeZomboidConfig(server, filename, content) {
|
||||||
|
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||||
|
throw new Error("File not allowed");
|
||||||
|
}
|
||||||
|
if (filename.includes("/") || filename.includes("..")) {
|
||||||
|
throw new Error("Invalid filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
|
||||||
|
// Create backup
|
||||||
|
const backupName = `${filename}.backup.${Date.now()}`;
|
||||||
|
await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||||
|
|
||||||
|
// Write file using sftp
|
||||||
|
const sftp = await ssh.requestSFTP();
|
||||||
|
const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`;
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
sftp.writeFile(filePath, content, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up old backups (keep last 5)
|
||||||
|
await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ PALWORLD CONFIG ============
|
||||||
|
const PALWORLD_CONFIG_PATH = "/opt/palworld/Pal/Saved/Config/LinuxServer";
|
||||||
|
const PALWORLD_ALLOWED_FILES = ["PalWorldSettings.ini", "Engine.ini", "GameUserSettings.ini"];
|
||||||
|
|
||||||
|
export async function listPalworldConfigs(server) {
|
||||||
|
const ssh = await getConnection(server.host);
|
||||||
|
const cmd = `ls -la ${PALWORLD_CONFIG_PATH}/*.ini 2>/dev/null`;
|
||||||
|
const result = await ssh.execCommand(cmd);
|
||||||
|
|
||||||
|
if (result.code !== 0 || !result.stdout.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
const lines = result.stdout.trim().split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
const fullPath = match[7];
|
||||||
|
const filename = fullPath.split("/").pop();
|
||||||
|
|
||||||
|
if (!PALWORLD_ALLOWED_FILES.includes(filename)) continue;
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
filename,
|
||||||
|
size: parseInt(match[5]),
|
||||||
|
modified: match[6]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readPalworldConfig(server, filename) {
|
||||||
|
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||||
|
throw new Error("File not allowed");
|
||||||
|
}
|
||||||
|
if (filename.includes("/") || filename.includes("..")) {
|
||||||
|
throw new Error("Invalid filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssh = await getConnection(server.host);
|
||||||
|
const result = await ssh.execCommand(`cat ${PALWORLD_CONFIG_PATH}/${filename}`);
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(result.stderr || "Failed to read config file");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writePalworldConfig(server, filename, content) {
|
||||||
|
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||||
|
throw new Error("File not allowed");
|
||||||
|
}
|
||||||
|
if (filename.includes("/") || filename.includes("..")) {
|
||||||
|
throw new Error("Invalid filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssh = await getConnection(server.host);
|
||||||
|
|
||||||
|
// Create backup
|
||||||
|
const backupName = `${filename}.backup.${Date.now()}`;
|
||||||
|
await ssh.execCommand(`cp ${PALWORLD_CONFIG_PATH}/${filename} ${PALWORLD_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||||
|
|
||||||
|
// Write file using sftp
|
||||||
|
const sftp = await ssh.requestSFTP();
|
||||||
|
const filePath = `${PALWORLD_CONFIG_PATH}/${filename}`;
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
sftp.writeFile(filePath, content, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up old backups (keep last 5)
|
||||||
|
await ssh.execCommand(`ls -t ${PALWORLD_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
BIN
gsm-frontend/public/palworld.png
Normal file
BIN
gsm-frontend/public/palworld.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
BIN
gsm-frontend/public/zomboid.png
Normal file
BIN
gsm-frontend/public/zomboid.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -1,4 +1,4 @@
|
|||||||
const API_URL = import.meta.env.VITE_API_URL || 'https://monitor.dimension47.de/api'
|
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||||
|
|
||||||
async function fetchAPI(endpoint, options = {}) {
|
async function fetchAPI(endpoint, options = {}) {
|
||||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||||
@@ -185,3 +185,67 @@ export async function getFactorioWorldSettings(token, saveName) {
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-Shutdown Settings
|
||||||
|
export async function getAutoShutdownSettings(token, serverId) {
|
||||||
|
return fetchAPI(`/servers/${serverId}/autoshutdown`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAutoShutdownSettings(token, serverId, enabled, timeoutMinutes) {
|
||||||
|
return fetchAPI(`/servers/${serverId}/autoshutdown`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ enabled, timeoutMinutes }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zomboid Config Management
|
||||||
|
export async function getZomboidConfigs(token) {
|
||||||
|
return fetchAPI('/servers/zomboid/config', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getZomboidConfig(token, filename) {
|
||||||
|
return fetchAPI(`/servers/zomboid/config/${encodeURIComponent(filename)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveZomboidConfig(token, filename, content) {
|
||||||
|
return fetchAPI(`/servers/zomboid/config/${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Palworld Config Management
|
||||||
|
export async function getPalworldConfigs(token) {
|
||||||
|
return fetchAPI('/servers/palworld/config', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPalworldConfig(token, filename) {
|
||||||
|
return fetchAPI(`/servers/palworld/config/${encodeURIComponent(filename)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePalworldConfig(token, filename, content) {
|
||||||
|
return fetchAPI(`/servers/palworld/config/${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity Log
|
||||||
|
export async function getActivityLog(token, limit = 100) {
|
||||||
|
return fetchAPI(`/servers/activity-log?limit=${limit}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
175
gsm-frontend/src/components/ActivityLog.jsx
Normal file
175
gsm-frontend/src/components/ActivityLog.jsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useUser } from '../context/UserContext'
|
||||||
|
import { getActivityLog } from '../api'
|
||||||
|
|
||||||
|
const actionLabels = {
|
||||||
|
server_start: 'Server gestartet',
|
||||||
|
server_stop: 'Server gestoppt',
|
||||||
|
server_restart: 'Server neugestartet',
|
||||||
|
rcon_command: 'RCON Befehl',
|
||||||
|
autoshutdown_config: 'Auto-Shutdown geändert',
|
||||||
|
zomboid_config: 'Config geändert',
|
||||||
|
palworld_config: 'Config geändert',
|
||||||
|
factorio_world_create: 'Welt erstellt',
|
||||||
|
factorio_world_delete: 'Welt gelöscht'
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionIcons = {
|
||||||
|
server_start: '▶️',
|
||||||
|
server_stop: '⏹️',
|
||||||
|
server_restart: '🔄',
|
||||||
|
rcon_command: '💻',
|
||||||
|
autoshutdown_config: '⏱️',
|
||||||
|
zomboid_config: '📝',
|
||||||
|
palworld_config: '📝',
|
||||||
|
factorio_world_create: '🌍',
|
||||||
|
factorio_world_delete: '🗑️'
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverLabels = {
|
||||||
|
minecraft: 'Minecraft',
|
||||||
|
factorio: 'Factorio',
|
||||||
|
zomboid: 'Project Zomboid',
|
||||||
|
vrising: 'V Rising',
|
||||||
|
palworld: 'Palworld'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvatarUrl(discordId, avatar) {
|
||||||
|
if (!discordId || !avatar) return null
|
||||||
|
return `https://cdn.discordapp.com/avatars/${discordId}/${avatar}.png?size=32`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiscordProfileUrl(discordId) {
|
||||||
|
return `https://discord.com/users/${discordId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityLog({ onClose }) {
|
||||||
|
const { token } = useUser()
|
||||||
|
const [logs, setLogs] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getActivityLog(token, 100)
|
||||||
|
setLogs(data)
|
||||||
|
setError('')
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden des Activity Logs')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchLogs()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
const date = new Date(dateStr + 'Z')
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - date
|
||||||
|
|
||||||
|
if (diff < 60000) return 'Gerade eben'
|
||||||
|
if (diff < 3600000) return Math.floor(diff / 60000) + ' Min'
|
||||||
|
if (diff < 86400000) return Math.floor(diff / 3600000) + ' Std'
|
||||||
|
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop fade-in" onClick={onClose}>
|
||||||
|
<div className="modal fade-in-scale" style={{ maxWidth: '42rem' }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">Activity Log</h2>
|
||||||
|
<button onClick={onClose} className="btn btn-ghost">
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-error mb-4">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-4 text-neutral-400">Laden...</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-neutral-500">Noch keine Aktivitäten</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{logs.map((log) => {
|
||||||
|
const avatarUrl = getAvatarUrl(log.discord_id, log.avatar)
|
||||||
|
const profileUrl = log.discord_id ? getDiscordProfileUrl(log.discord_id) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={log.id} className="card p-3 flex items-start gap-3">
|
||||||
|
{/* Avatar or Action Icon */}
|
||||||
|
{avatarUrl ? (
|
||||||
|
<a
|
||||||
|
href={profileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-8 h-8 rounded-full hover:ring-2 hover:ring-blue-500 transition-all"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-neutral-700 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-neutral-400 text-xs">
|
||||||
|
{log.username?.charAt(0)?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{profileUrl ? (
|
||||||
|
<a
|
||||||
|
href={profileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-white font-medium hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
{log.username}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-white font-medium">{log.username}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-neutral-500">
|
||||||
|
{actionIcons[log.action] || '📋'} {actionLabels[log.action] || log.action}
|
||||||
|
</span>
|
||||||
|
{log.target && (
|
||||||
|
<span className="text-blue-400">
|
||||||
|
{serverLabels[log.target] || log.target}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{log.details && (
|
||||||
|
<div className="text-sm text-neutral-500 mt-1 truncate font-mono">
|
||||||
|
{log.details}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-neutral-500 whitespace-nowrap">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
336
gsm-frontend/src/components/PalworldConfigEditor.jsx
Normal file
336
gsm-frontend/src/components/PalworldConfigEditor.jsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { FileText, Save, RefreshCw, AlertTriangle, Check, X, ChevronDown } from 'lucide-react'
|
||||||
|
import { getPalworldConfigs, getPalworldConfig, savePalworldConfig } from '../api'
|
||||||
|
|
||||||
|
export default function PalworldConfigEditor({ token }) {
|
||||||
|
const [files, setFiles] = useState([])
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null)
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [originalContent, setOriginalContent] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [success, setSuccess] = useState(null)
|
||||||
|
const [hasChanges, setHasChanges] = useState(false)
|
||||||
|
const textareaRef = useRef(null)
|
||||||
|
const highlightRef = useRef(null)
|
||||||
|
|
||||||
|
// Load file list
|
||||||
|
useEffect(() => {
|
||||||
|
loadFiles()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
// Track changes
|
||||||
|
useEffect(() => {
|
||||||
|
setHasChanges(content !== originalContent)
|
||||||
|
}, [content, originalContent])
|
||||||
|
|
||||||
|
// Sync scroll between textarea and highlight div
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (highlightRef.current && textareaRef.current) {
|
||||||
|
highlightRef.current.scrollTop = textareaRef.current.scrollTop
|
||||||
|
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await getPalworldConfigs(token)
|
||||||
|
setFiles(data.files || [])
|
||||||
|
if (data.files?.length > 0 && !selectedFile) {
|
||||||
|
loadFile(data.files[0].filename)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden der Config-Dateien: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFile(filename) {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
const data = await getPalworldConfig(token, filename)
|
||||||
|
setSelectedFile(filename)
|
||||||
|
// Format the config for better readability
|
||||||
|
let formattedContent = data.content
|
||||||
|
if (filename === 'PalWorldSettings.ini') {
|
||||||
|
formattedContent = formatPalworldConfig(data.content)
|
||||||
|
}
|
||||||
|
setContent(formattedContent)
|
||||||
|
setOriginalContent(formattedContent)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format Palworld config for better readability
|
||||||
|
function formatPalworldConfig(content) {
|
||||||
|
// The config is one long line with OptionSettings=(...)
|
||||||
|
// We'll split key=value pairs for better readability
|
||||||
|
return content
|
||||||
|
.replace(/,(?=[A-Z])/g, ',\n') // Add newline before each key
|
||||||
|
.replace(/\((?=[A-Z])/g, '(\n') // Newline after opening paren
|
||||||
|
.replace(/\)$/gm, '\n)') // Newline before closing paren
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact config back to single line for saving
|
||||||
|
function compactPalworldConfig(content) {
|
||||||
|
if (!selectedFile?.includes('PalWorldSettings.ini')) return content
|
||||||
|
return content
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.join('')
|
||||||
|
.replace(/,\s+/g, ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!selectedFile || !hasChanges) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
const saveContent = selectedFile === 'PalWorldSettings.ini'
|
||||||
|
? compactPalworldConfig(content)
|
||||||
|
: content
|
||||||
|
await savePalworldConfig(token, selectedFile, saveContent)
|
||||||
|
setOriginalContent(content)
|
||||||
|
setSuccess('Config gespeichert! Server-Neustart erforderlich.')
|
||||||
|
setTimeout(() => setSuccess(null), 5000)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Speichern: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
setContent(originalContent)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileDescription(filename) {
|
||||||
|
const descriptions = {
|
||||||
|
'PalWorldSettings.ini': 'Server-Einstellungen (Spieler, Raten, RCON)',
|
||||||
|
'Engine.ini': 'Engine-Konfiguration (Performance)',
|
||||||
|
'GameUserSettings.ini': 'Grafik und Audio Einstellungen'
|
||||||
|
}
|
||||||
|
return descriptions[filename] || filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight syntax for INI files
|
||||||
|
function highlightSyntax(text) {
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const lines = text.split('\n')
|
||||||
|
|
||||||
|
return lines.map((line) => {
|
||||||
|
let highlighted = line
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
// Section headers [Section]
|
||||||
|
if (line.trim().startsWith('[') && line.trim().endsWith(']')) {
|
||||||
|
highlighted = `<span class="text-purple-400">${highlighted}</span>`
|
||||||
|
}
|
||||||
|
// Comments (;)
|
||||||
|
else if (line.trim().startsWith(';')) {
|
||||||
|
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||||
|
}
|
||||||
|
// Highlight key=value
|
||||||
|
else if (line.includes('=')) {
|
||||||
|
const idx = line.indexOf('=')
|
||||||
|
const key = highlighted.substring(0, idx)
|
||||||
|
const value = highlighted.substring(idx + 1)
|
||||||
|
|
||||||
|
// Color boolean values
|
||||||
|
let coloredValue = value
|
||||||
|
.replace(/\b(True|False)\b/gi, '<span class="text-orange-400">$1</span>')
|
||||||
|
.replace(/\b(\d+\.?\d*)\b/g, '<span class="text-cyan-400">$1</span>')
|
||||||
|
|
||||||
|
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${coloredValue}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlighted
|
||||||
|
}).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && files.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||||
|
<span className="ml-2 text-gray-400">Lade Config-Dateien...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* File selector */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<select
|
||||||
|
value={selectedFile || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (hasChanges) {
|
||||||
|
if (!confirm('Ungespeicherte Änderungen verwerfen?')) return
|
||||||
|
}
|
||||||
|
loadFile(e.target.value)
|
||||||
|
}}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 pr-10 text-white appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{files.map(file => (
|
||||||
|
<option key={file.filename} value={file.filename}>
|
||||||
|
{file.filename}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => loadFile(selectedFile)}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Neu laden"
|
||||||
|
>
|
||||||
|
<RefreshCw className={"w-5 h-5 " + (loading ? 'animate-spin' : '')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File description */}
|
||||||
|
{selectedFile && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>{getFileDescription(selectedFile)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error/Success messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
|
||||||
|
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400">
|
||||||
|
<Check className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editor with syntax highlighting */}
|
||||||
|
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
|
||||||
|
{/* Highlighted background layer */}
|
||||||
|
<pre
|
||||||
|
ref={highlightRef}
|
||||||
|
className="absolute inset-0 p-4 m-0 text-sm font-mono bg-gray-900 text-gray-100 overflow-auto pointer-events-none whitespace-pre-wrap break-words"
|
||||||
|
style={{ wordBreak: 'break-word' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlightSyntax(content) + '\n' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Transparent textarea for editing */}
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="absolute inset-0 w-full h-full p-4 text-sm font-mono bg-transparent text-transparent caret-white resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ caretColor: 'white' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Change indicator */}
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded z-10">
|
||||||
|
Ungespeichert
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{selectedFile && files.find(f => f.filename === selectedFile)?.modified && (
|
||||||
|
<span>Zuletzt geändert: {files.find(f => f.filename === selectedFile).modified}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{hasChanges && (
|
||||||
|
<button
|
||||||
|
onClick={handleDiscard}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Verwerfen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
||||||
|
(hasChanges && !saving
|
||||||
|
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
Speichern...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Speichern
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||||
|
<h4 className="text-sm font-medium text-gray-300 mb-2">Legende</h4>
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-purple-400"></span>
|
||||||
|
<span className="text-gray-400">Sektion</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-blue-400"></span>
|
||||||
|
<span className="text-gray-400">Einstellung</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-amber-300"></span>
|
||||||
|
<span className="text-gray-400">Wert</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-orange-400"></span>
|
||||||
|
<span className="text-gray-400">Boolean</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-cyan-400"></span>
|
||||||
|
<span className="text-gray-400">Zahlen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-3">Änderungen werden erst nach Server-Neustart aktiv. Ein Backup wird automatisch erstellt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const serverInfo = {
|
const serverInfo = {
|
||||||
minecraft: {
|
minecraft: {
|
||||||
address: 'minecraft.dimension47.de',
|
address: 'minecraft.zeasy.dev',
|
||||||
logo: '/minecraft.png',
|
logo: '/minecraft.png',
|
||||||
links: [
|
links: [
|
||||||
{ label: 'ATM10 Modpack', url: 'https://www.curseforge.com/minecraft/modpacks/all-the-mods-10' }
|
{ label: 'ATM10 Modpack', url: 'https://www.curseforge.com/minecraft/modpacks/all-the-mods-10' }
|
||||||
@@ -8,7 +8,7 @@ const serverInfo = {
|
|||||||
},
|
},
|
||||||
factorio: {
|
factorio: {
|
||||||
hint: 'Serverpasswort: affe',
|
hint: 'Serverpasswort: affe',
|
||||||
address: 'factorio.dimension47.de',
|
address: 'factorio.zeasy.dev',
|
||||||
logo: '/factorio.png',
|
logo: '/factorio.png',
|
||||||
links: [
|
links: [
|
||||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/427520/Factorio/' }
|
{ label: 'Steam', url: 'https://store.steampowered.com/app/427520/Factorio/' }
|
||||||
@@ -20,6 +20,21 @@ const serverInfo = {
|
|||||||
links: [
|
links: [
|
||||||
{ label: 'Steam', url: 'https://store.steampowered.com/app/1604030/V_Rising/' }
|
{ label: 'Steam', url: 'https://store.steampowered.com/app/1604030/V_Rising/' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
zomboid: {
|
||||||
|
hint: 'Version 42.13.1',
|
||||||
|
address: 'pz.zeasy.dev:16261',
|
||||||
|
logo: '/zomboid.png',
|
||||||
|
links: [
|
||||||
|
{ label: 'Steam', url: 'https://store.steampowered.com/app/108600/Project_Zomboid/' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
palworld: {
|
||||||
|
address: 'palworld.zeasy.dev:8211',
|
||||||
|
logo: '/palworld.png',
|
||||||
|
links: [
|
||||||
|
{ label: 'Steam', url: 'https://store.steampowered.com/app/1623730/Palworld/' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,11 +43,25 @@ const getServerInfo = (serverName) => {
|
|||||||
if (name.includes('minecraft') || name.includes('all the mods')) return serverInfo.minecraft
|
if (name.includes('minecraft') || name.includes('all the mods')) return serverInfo.minecraft
|
||||||
if (name.includes('factorio')) return serverInfo.factorio
|
if (name.includes('factorio')) return serverInfo.factorio
|
||||||
if (name.includes('vrising') || name.includes('v rising')) return serverInfo.vrising
|
if (name.includes('vrising') || name.includes('v rising')) return serverInfo.vrising
|
||||||
|
if (name.includes('zomboid')) return serverInfo.zomboid
|
||||||
|
if (name.includes('palworld')) return serverInfo.palworld
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ServerCard({ server, onClick, isAuthenticated }) {
|
export default function ServerCard({ server, onClick, isAuthenticated, displaySettings }) {
|
||||||
const info = getServerInfo(server.name)
|
const defaultInfo = getServerInfo(server.name)
|
||||||
|
|
||||||
|
// Merge default info with database display settings (database takes priority)
|
||||||
|
const info = defaultInfo ? {
|
||||||
|
...defaultInfo,
|
||||||
|
address: displaySettings?.address || defaultInfo.address,
|
||||||
|
hint: displaySettings?.hint || defaultInfo.hint
|
||||||
|
} : (displaySettings ? {
|
||||||
|
address: displaySettings.address,
|
||||||
|
hint: displaySettings.hint,
|
||||||
|
logo: null,
|
||||||
|
links: []
|
||||||
|
} : null)
|
||||||
|
|
||||||
const formatUptime = (seconds) => {
|
const formatUptime = (seconds) => {
|
||||||
const hours = Math.floor(seconds / 3600)
|
const hours = Math.floor(seconds / 3600)
|
||||||
@@ -106,27 +135,20 @@ export default function ServerCard({ server, onClick, isAuthenticated }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Whitelist notice for Minecraft - only for authenticated users */}
|
{/* Server hint - only for authenticated users */}
|
||||||
{isAuthenticated && server.type === 'minecraft' && (
|
{isAuthenticated && info?.hint && (
|
||||||
|
<div className="mb-4 text-xs text-neutral-500">
|
||||||
|
{info.hint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Whitelist notice for Minecraft - only if no custom hint is set */}
|
||||||
|
{isAuthenticated && server.type === 'minecraft' && !displaySettings?.hint && (
|
||||||
<div className="mb-4 text-xs text-neutral-500">
|
<div className="mb-4 text-xs text-neutral-500">
|
||||||
Whitelist erforderlich - im Whitelist-Tab freischalten
|
Whitelist erforderlich - im Whitelist-Tab freischalten
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Factorio notice - only for authenticated users */}
|
|
||||||
{isAuthenticated && server.type === 'factorio' && (
|
|
||||||
<div className="mb-4 text-xs text-neutral-500">
|
|
||||||
Serverpasswort: affe
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* V Rising notice - only for authenticated users */}
|
|
||||||
{isAuthenticated && server.type === 'vrising' && (
|
|
||||||
<div className="mb-4 text-xs text-neutral-500">
|
|
||||||
In der Serverliste suchen - Passwort: affe
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Metrics */}
|
{/* Metrics */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* CPU */}
|
{/* CPU */}
|
||||||
@@ -162,13 +184,25 @@ export default function ServerCard({ server, onClick, isAuthenticated }) {
|
|||||||
|
|
||||||
{/* Footer Stats */}
|
{/* Footer Stats */}
|
||||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-neutral-800 text-sm">
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-neutral-800 text-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-neutral-400">
|
<div className="text-neutral-400">
|
||||||
<span className="text-white font-medium">{server.players.online}</span>
|
<span className="text-white font-medium">{server.players.online}</span>
|
||||||
{server.players.max ? ' / ' + server.players.max : ''} players
|
{server.players.max ? ' / ' + server.players.max : ''} Spieler
|
||||||
|
</div>
|
||||||
|
{server.running && server.autoShutdown?.enabled && server.autoShutdown?.emptySinceMinutes !== null && (
|
||||||
|
<div className="flex items-center gap-1.5 text-yellow-500">
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs">
|
||||||
|
Shutdown in {server.autoShutdown.timeoutMinutes - server.autoShutdown.emptySinceMinutes}m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{server.running && (
|
{server.running && (
|
||||||
<div className="text-neutral-400">
|
<div className="text-neutral-400">
|
||||||
Uptime: <span className="text-white">{formatUptime(server.metrics.uptime)}</span>
|
Laufzeit: <span className="text-white">{formatUptime(server.metrics.uptime)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
326
gsm-frontend/src/components/ZomboidConfigEditor.jsx
Normal file
326
gsm-frontend/src/components/ZomboidConfigEditor.jsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { FileText, Save, RefreshCw, AlertTriangle, Check, X, ChevronDown } from 'lucide-react'
|
||||||
|
import { getZomboidConfigs, getZomboidConfig, saveZomboidConfig } from '../api'
|
||||||
|
|
||||||
|
export default function ZomboidConfigEditor({ token }) {
|
||||||
|
const [files, setFiles] = useState([])
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null)
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [originalContent, setOriginalContent] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [success, setSuccess] = useState(null)
|
||||||
|
const [hasChanges, setHasChanges] = useState(false)
|
||||||
|
const textareaRef = useRef(null)
|
||||||
|
const highlightRef = useRef(null)
|
||||||
|
|
||||||
|
// Load file list
|
||||||
|
useEffect(() => {
|
||||||
|
loadFiles()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
// Track changes
|
||||||
|
useEffect(() => {
|
||||||
|
setHasChanges(content !== originalContent)
|
||||||
|
}, [content, originalContent])
|
||||||
|
|
||||||
|
// Sync scroll between textarea and highlight div
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (highlightRef.current && textareaRef.current) {
|
||||||
|
highlightRef.current.scrollTop = textareaRef.current.scrollTop
|
||||||
|
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await getZomboidConfigs(token)
|
||||||
|
setFiles(data.files || [])
|
||||||
|
if (data.files?.length > 0 && !selectedFile) {
|
||||||
|
loadFile(data.files[0].filename)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden der Config-Dateien: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFile(filename) {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
const data = await getZomboidConfig(token, filename)
|
||||||
|
setSelectedFile(filename)
|
||||||
|
setContent(data.content)
|
||||||
|
setOriginalContent(data.content)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!selectedFile || !hasChanges) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
await saveZomboidConfig(token, selectedFile, content)
|
||||||
|
setOriginalContent(content)
|
||||||
|
setSuccess('Config gespeichert! Server-Neustart erforderlich für Änderungen.')
|
||||||
|
setTimeout(() => setSuccess(null), 5000)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Speichern: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
setContent(originalContent)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileDescription(filename) {
|
||||||
|
const descriptions = {
|
||||||
|
'Project.ini': 'Server-Einstellungen (PVP, Spieler, Netzwerk)',
|
||||||
|
'Project_SandboxVars.lua': 'Gameplay-Einstellungen (Zombies, Loot, Schwierigkeit)',
|
||||||
|
'Project_spawnpoints.lua': 'Spawn-Punkte für neue Spieler',
|
||||||
|
'Project_spawnregions.lua': 'Spawn-Regionen Konfiguration'
|
||||||
|
}
|
||||||
|
return descriptions[filename] || filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight syntax based on file type
|
||||||
|
function highlightSyntax(text, filename) {
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const isLua = filename?.endsWith('.lua')
|
||||||
|
const lines = text.split('\n')
|
||||||
|
|
||||||
|
return lines.map((line, i) => {
|
||||||
|
let highlighted = line
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
if (isLua) {
|
||||||
|
// Lua: -- comments
|
||||||
|
if (line.trim().startsWith('--')) {
|
||||||
|
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||||
|
} else if (line.includes('--')) {
|
||||||
|
const idx = line.indexOf('--')
|
||||||
|
const code = highlighted.substring(0, idx)
|
||||||
|
const comment = highlighted.substring(idx)
|
||||||
|
highlighted = `${code}<span class="text-emerald-500">${comment}</span>`
|
||||||
|
}
|
||||||
|
// Highlight true/false/nil
|
||||||
|
highlighted = highlighted
|
||||||
|
.replace(/\b(true|false|nil)\b/g, '<span class="text-orange-400">$1</span>')
|
||||||
|
// Highlight numbers
|
||||||
|
highlighted = highlighted
|
||||||
|
.replace(/\b(\d+\.?\d*)\b/g, '<span class="text-cyan-400">$1</span>')
|
||||||
|
} else {
|
||||||
|
// INI: # comments
|
||||||
|
if (line.trim().startsWith('#')) {
|
||||||
|
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
|
||||||
|
}
|
||||||
|
// Highlight key=value
|
||||||
|
else if (line.includes('=')) {
|
||||||
|
const idx = line.indexOf('=')
|
||||||
|
const key = highlighted.substring(0, idx)
|
||||||
|
const value = highlighted.substring(idx + 1)
|
||||||
|
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${value}</span>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlighted
|
||||||
|
}).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && files.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||||
|
<span className="ml-2 text-gray-400">Lade Config-Dateien...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* File selector */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<select
|
||||||
|
value={selectedFile || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (hasChanges) {
|
||||||
|
if (!confirm('Ungespeicherte Änderungen verwerfen?')) return
|
||||||
|
}
|
||||||
|
loadFile(e.target.value)
|
||||||
|
}}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 pr-10 text-white appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{files.map(file => (
|
||||||
|
<option key={file.filename} value={file.filename}>
|
||||||
|
{file.filename}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => loadFile(selectedFile)}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Neu laden"
|
||||||
|
>
|
||||||
|
<RefreshCw className={"w-5 h-5 " + (loading ? 'animate-spin' : '')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File description */}
|
||||||
|
{selectedFile && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>{getFileDescription(selectedFile)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error/Success messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
|
||||||
|
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400">
|
||||||
|
<Check className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editor with syntax highlighting */}
|
||||||
|
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
|
||||||
|
{/* Highlighted background layer */}
|
||||||
|
<pre
|
||||||
|
ref={highlightRef}
|
||||||
|
className="absolute inset-0 p-4 m-0 text-sm font-mono bg-gray-900 text-gray-100 overflow-auto pointer-events-none whitespace-pre-wrap break-words"
|
||||||
|
style={{ wordBreak: 'break-word' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlightSyntax(content, selectedFile) + '\n' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Transparent textarea for editing */}
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="absolute inset-0 w-full h-full p-4 text-sm font-mono bg-transparent text-transparent caret-white resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ caretColor: 'white' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Change indicator */}
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded z-10">
|
||||||
|
Ungespeichert
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{selectedFile && files.find(f => f.filename === selectedFile)?.modified && (
|
||||||
|
<span>Zuletzt geändert: {files.find(f => f.filename === selectedFile).modified}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{hasChanges && (
|
||||||
|
<button
|
||||||
|
onClick={handleDiscard}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Verwerfen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
|
||||||
|
(hasChanges && !saving
|
||||||
|
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
Speichern...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Speichern
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||||
|
<h4 className="text-sm font-medium text-gray-300 mb-2">Legende</h4>
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-emerald-500"></span>
|
||||||
|
<span className="text-gray-400">Kommentare</span>
|
||||||
|
</div>
|
||||||
|
{selectedFile?.endsWith('.ini') && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-blue-400"></span>
|
||||||
|
<span className="text-gray-400">Einstellung</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-amber-300"></span>
|
||||||
|
<span className="text-gray-400">Wert</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedFile?.endsWith('.lua') && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-orange-400"></span>
|
||||||
|
<span className="text-gray-400">Boolean/Nil</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded bg-cyan-400"></span>
|
||||||
|
<span className="text-gray-400">Zahlen</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-3">Änderungen werden erst nach Server-Neustart aktiv. Ein Backup wird automatisch erstellt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -366,6 +366,21 @@ button {
|
|||||||
animation: slideInLeft 0.3s ease-out;
|
animation: slideInLeft 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slideDown {
|
||||||
|
animation: slideDown 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
/* Page transitions */
|
/* Page transitions */
|
||||||
.page-enter {
|
.page-enter {
|
||||||
animation: fadeInUp 0.3s ease-out;
|
animation: fadeInUp 0.3s ease-out;
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { getServers } from '../api'
|
import { getServers } from '../api'
|
||||||
import { useUser } from '../context/UserContext'
|
import { useUser } from '../context/UserContext'
|
||||||
import ServerCard from '../components/ServerCard'
|
import ServerCard from '../components/ServerCard'
|
||||||
import SettingsModal from '../components/SettingsModal'
|
|
||||||
import UserManagement from '../components/UserManagement'
|
import UserManagement from '../components/UserManagement'
|
||||||
import LoginModal from '../components/LoginModal'
|
import LoginModal from '../components/LoginModal'
|
||||||
|
import ActivityLog from '../components/ActivityLog'
|
||||||
|
|
||||||
export default function Dashboard({ onLogin, onLogout }) {
|
export default function Dashboard({ onLogin, onLogout }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -13,9 +13,10 @@ export default function Dashboard({ onLogin, onLogout }) {
|
|||||||
const [servers, setServers] = useState([])
|
const [servers, setServers] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
|
||||||
const [showUserMgmt, setShowUserMgmt] = useState(false)
|
const [showUserMgmt, setShowUserMgmt] = useState(false)
|
||||||
const [showLogin, setShowLogin] = useState(false)
|
const [showLogin, setShowLogin] = useState(false)
|
||||||
|
const [showActivityLog, setShowActivityLog] = useState(false)
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
|
||||||
const isAuthenticated = !!token
|
const isAuthenticated = !!token
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ export default function Dashboard({ onLogin, onLogout }) {
|
|||||||
onLogout()
|
onLogout()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError('Failed to connect to server')
|
setError('Verbindung zum Server fehlgeschlagen')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -46,15 +47,15 @@ export default function Dashboard({ onLogin, onLogout }) {
|
|||||||
}, [token, userLoading])
|
}, [token, userLoading])
|
||||||
|
|
||||||
const roleLabels = {
|
const roleLabels = {
|
||||||
user: 'Viewer',
|
user: 'Zuschauer',
|
||||||
moderator: 'Operator',
|
moderator: 'Moderator',
|
||||||
superadmin: 'Admin'
|
superadmin: 'Admin'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userLoading) {
|
if (userLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-neutral-400">Loading...</div>
|
<div className="text-neutral-400">Laden...</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,7 @@ export default function Dashboard({ onLogin, onLogout }) {
|
|||||||
<div className="container-main py-4">
|
<div className="container-main py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<a href="https://zeasy.software" target="_blank" rel="noopener noreferrer" className="relative block group"><img src="/navbarlogograuer.png" alt="Logo" className="h-8 transition-opacity duration-300 group-hover:opacity-0" /><img src="/navbarlogoweiß.png" alt="Logo" className="h-8 absolute top-0 left-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" /></a>
|
<a href="https://zeasy.software" target="_blank" rel="noopener noreferrer" className="relative block group"><img src="/navbarlogoweiß.png" alt="Logo" className="h-8 transition-opacity duration-300 group-hover:opacity-0" /><img src="/navbarlogograuer.png" alt="Logo" className="h-8 absolute top-0 left-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" /></a>
|
||||||
<span className="text-xl font-semibold text-white hidden sm:inline">Gameserver Management</span>
|
<span className="text-xl font-semibold text-white hidden sm:inline">Gameserver Management</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:flex items-center gap-4 text-sm text-neutral-400">
|
<div className="hidden md:flex items-center gap-4 text-sm text-neutral-400">
|
||||||
@@ -79,47 +80,125 @@ export default function Dashboard({ onLogin, onLogout }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-neutral-600">|</span>
|
<span className="text-neutral-600">|</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="text-white font-medium">{totalPlayers}</span> players
|
<span className="text-white font-medium">{totalPlayers}</span> Spieler
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
<div className="hidden sm:block text-right mr-2">
|
{/* Desktop Navigation */}
|
||||||
|
<div className="hidden md:flex items-center gap-6">
|
||||||
|
{user?.avatar && user?.discordId && (
|
||||||
|
<img
|
||||||
|
src={`https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="text-right mr-2">
|
||||||
<div className="text-sm text-white">{user?.username}</div>
|
<div className="text-sm text-white">{user?.username}</div>
|
||||||
<div className="text-xs text-neutral-500">{roleLabels[role]}</div>
|
<div className="text-xs text-neutral-500">{roleLabels[role]}</div>
|
||||||
</div>
|
</div>
|
||||||
{isSuperadmin && (
|
{isSuperadmin && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUserMgmt(true)}
|
onClick={() => setShowUserMgmt(true)}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
>
|
>
|
||||||
Users
|
Benutzer
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSettings(true)}
|
onClick={() => setShowActivityLog(true)}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
>
|
>
|
||||||
Settings
|
Logs
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
className="btn btn-outline"
|
className="btn btn-outline"
|
||||||
>
|
>
|
||||||
Sign out
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Burger Button */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
className="btn btn-ghost p-2"
|
||||||
|
aria-label="Menu"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowLogin(true)}
|
onClick={() => setShowLogin(true)}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
>
|
>
|
||||||
Sign in
|
Anmelden
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Dropdown Menu */}
|
||||||
|
{mobileMenuOpen && isAuthenticated && (
|
||||||
|
<div className="md:hidden border-t border-neutral-800 py-4 mt-4 animate-slideDown">
|
||||||
|
<div className="flex items-center justify-between mb-3 px-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{user?.avatar && user?.discordId && (
|
||||||
|
<img
|
||||||
|
src={`https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-white">{user?.username}</div>
|
||||||
|
<div className="text-xs text-neutral-500">{roleLabels[role]}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-400 text-right">
|
||||||
|
<div><span className="text-white font-medium">{onlineCount}</span>/{servers.length} online</div>
|
||||||
|
<div><span className="text-white font-medium">{totalPlayers}</span> Spieler</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{isSuperadmin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowUserMgmt(true); setMobileMenuOpen(false); }}
|
||||||
|
className="btn btn-ghost justify-start"
|
||||||
|
>
|
||||||
|
Benutzer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowActivityLog(true); setMobileMenuOpen(false); }}
|
||||||
|
className="btn btn-ghost justify-start"
|
||||||
|
>
|
||||||
|
Logs
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { onLogout(); setMobileMenuOpen(false); }}
|
||||||
|
className="btn btn-outline justify-start"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -132,7 +211,7 @@ export default function Dashboard({ onLogin, onLogout }) {
|
|||||||
)}
|
)}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-neutral-400">Loading servers...</div>
|
<div className="text-neutral-400">Lade Server...</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -154,12 +233,12 @@ export default function Dashboard({ onLogin, onLogout }) {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
{showSettings && (
|
|
||||||
<SettingsModal onClose={() => setShowSettings(false)} />
|
|
||||||
)}
|
|
||||||
{showUserMgmt && (
|
{showUserMgmt && (
|
||||||
<UserManagement onClose={() => setShowUserMgmt(false)} />
|
<UserManagement onClose={() => setShowUserMgmt(false)} />
|
||||||
)}
|
)}
|
||||||
|
{showActivityLog && (
|
||||||
|
<ActivityLog onClose={() => setShowActivityLog(false)} />
|
||||||
|
)}
|
||||||
{showLogin && (
|
{showLogin && (
|
||||||
<LoginModal onLogin={onLogin} onClose={() => setShowLogin(false)} />
|
<LoginModal onLogin={onLogin} onClose={() => setShowLogin(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { getServers, serverAction, sendRcon, getServerLogs, getWhitelist, getFactorioCurrentSave } from '../api'
|
import { getServers, serverAction, sendRcon, getServerLogs, getWhitelist, getFactorioCurrentSave, getAutoShutdownSettings, setAutoShutdownSettings, getDisplaySettings, setDisplaySettings } from '../api'
|
||||||
import { useUser } from '../context/UserContext'
|
import { useUser } from '../context/UserContext'
|
||||||
import MetricsChart from '../components/MetricsChart'
|
import MetricsChart from '../components/MetricsChart'
|
||||||
import FactorioWorldManager from '../components/FactorioWorldManager'
|
import FactorioWorldManager from '../components/FactorioWorldManager'
|
||||||
|
import PalworldConfigEditor from '../components/PalworldConfigEditor'
|
||||||
|
import ZomboidConfigEditor from '../components/ZomboidConfigEditor'
|
||||||
|
|
||||||
const getServerLogo = (serverName) => {
|
const getServerLogo = (serverName) => {
|
||||||
const name = serverName.toLowerCase()
|
const name = serverName.toLowerCase()
|
||||||
if (name.includes("minecraft") || name.includes("all the mods")) return "/minecraft.png"
|
if (name.includes("minecraft") || name.includes("all the mods")) return "/minecraft.png"
|
||||||
if (name.includes("factorio")) return "/factorio.png"
|
if (name.includes("factorio")) return "/factorio.png"
|
||||||
if (name.includes("vrising") || name.includes("v rising")) return "/vrising.png"
|
if (name.includes("vrising") || name.includes("v rising")) return "/vrising.png"
|
||||||
|
if (name.includes("zomboid")) return "/zomboid.png"
|
||||||
|
if (name.includes("palworld")) return "/palworld.png"
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
export default function ServerDetail() {
|
export default function ServerDetail() {
|
||||||
const { serverId } = useParams()
|
const { serverId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { token, isModerator } = useUser()
|
const { token, isModerator, isSuperadmin } = useUser()
|
||||||
const [server, setServer] = useState(null)
|
const [server, setServer] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -28,9 +32,19 @@ export default function ServerDetail() {
|
|||||||
const [whitelistInput, setWhitelistInput] = useState('')
|
const [whitelistInput, setWhitelistInput] = useState('')
|
||||||
const [whitelistLoading, setWhitelistLoading] = useState(false)
|
const [whitelistLoading, setWhitelistLoading] = useState(false)
|
||||||
const [currentSave, setCurrentSave] = useState(null)
|
const [currentSave, setCurrentSave] = useState(null)
|
||||||
|
const [logsUpdated, setLogsUpdated] = useState(null)
|
||||||
const logsRef = useRef(null)
|
const logsRef = useRef(null)
|
||||||
const rconRef = useRef(null)
|
const rconRef = useRef(null)
|
||||||
|
|
||||||
|
// Auto-shutdown state
|
||||||
|
const [autoShutdown, setAutoShutdown] = useState({ enabled: false, timeoutMinutes: 15, emptySinceMinutes: null })
|
||||||
|
const [autoShutdownLoading, setAutoShutdownLoading] = useState(false)
|
||||||
|
|
||||||
|
// Display settings state (superadmin only)
|
||||||
|
const [displaySettings, setDisplaySettingsState] = useState({ address: '', hint: '' })
|
||||||
|
const [displaySettingsLoading, setDisplaySettingsLoading] = useState(false)
|
||||||
|
const [displaySettingsSaved, setDisplaySettingsSaved] = useState(false)
|
||||||
|
|
||||||
const fetchCurrentSave = async () => {
|
const fetchCurrentSave = async () => {
|
||||||
if (token && serverId === 'factorio') {
|
if (token && serverId === 'factorio') {
|
||||||
try {
|
try {
|
||||||
@@ -59,10 +73,49 @@ export default function ServerDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchAutoShutdownSettings = async () => {
|
||||||
|
if (token && serverId) {
|
||||||
|
try {
|
||||||
|
const data = await getAutoShutdownSettings(token, serverId)
|
||||||
|
setAutoShutdown(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load auto-shutdown settings:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAutoShutdownToggle = async () => {
|
||||||
|
setAutoShutdownLoading(true)
|
||||||
|
try {
|
||||||
|
await setAutoShutdownSettings(token, serverId, !autoShutdown.enabled, autoShutdown.timeoutMinutes)
|
||||||
|
setAutoShutdown(prev => ({ ...prev, enabled: !prev.enabled }))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
setAutoShutdownLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAutoShutdownTimeoutChange = async (newTimeout) => {
|
||||||
|
const timeout = Math.max(1, Math.min(1440, parseInt(newTimeout) || 15))
|
||||||
|
setAutoShutdown(prev => ({ ...prev, timeoutMinutes: timeout }))
|
||||||
|
|
||||||
|
clearTimeout(window.autoShutdownSaveTimeout)
|
||||||
|
window.autoShutdownSaveTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await setAutoShutdownSettings(token, serverId, autoShutdown.enabled, timeout)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServer()
|
fetchServer()
|
||||||
fetchCurrentSave()
|
fetchCurrentSave()
|
||||||
const interval = setInterval(fetchServer, 10000)
|
const interval = setInterval(() => {
|
||||||
|
fetchServer()
|
||||||
|
fetchCurrentSave()
|
||||||
|
}, 10000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [token, serverId])
|
}, [token, serverId])
|
||||||
|
|
||||||
@@ -97,8 +150,9 @@ export default function ServerDetail() {
|
|||||||
|
|
||||||
const fetchLogs = async () => {
|
const fetchLogs = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getServerLogs(token, server.id, 20)
|
const data = await getServerLogs(token, server.id, 50)
|
||||||
setLogs(data.logs || '')
|
setLogs(data.logs || '')
|
||||||
|
setLogsUpdated(new Date())
|
||||||
if (logsRef.current) {
|
if (logsRef.current) {
|
||||||
logsRef.current.scrollTop = logsRef.current.scrollHeight
|
logsRef.current.scrollTop = logsRef.current.scrollHeight
|
||||||
}
|
}
|
||||||
@@ -127,6 +181,42 @@ export default function ServerDetail() {
|
|||||||
}
|
}
|
||||||
}, [rconHistory])
|
}, [rconHistory])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'settings' && isModerator) {
|
||||||
|
fetchAutoShutdownSettings()
|
||||||
|
const interval = setInterval(fetchAutoShutdownSettings, 10000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [activeTab, isModerator, serverId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'display' && isSuperadmin) {
|
||||||
|
fetchDisplaySettings()
|
||||||
|
}
|
||||||
|
}, [activeTab, isSuperadmin, serverId])
|
||||||
|
|
||||||
|
const fetchDisplaySettings = async () => {
|
||||||
|
if (token && serverId) {
|
||||||
|
try {
|
||||||
|
const data = await getDisplaySettings(token, serverId)
|
||||||
|
setDisplaySettingsState({ address: data.address || '', hint: data.hint || '' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load display settings:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveDisplaySettings = async () => {
|
||||||
|
setDisplaySettingsLoading(true)
|
||||||
|
try {
|
||||||
|
await setDisplaySettings(token, serverId, displaySettings.address, displaySettings.hint)
|
||||||
|
setDisplaySettingsSaved(true)
|
||||||
|
setTimeout(() => setDisplaySettingsSaved(false), 3000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save display settings:', err)
|
||||||
|
}
|
||||||
|
setDisplaySettingsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
const fetchWhitelist = async () => {
|
const fetchWhitelist = async () => {
|
||||||
if (!server?.hasRcon) return
|
if (!server?.hasRcon) return
|
||||||
@@ -174,23 +264,35 @@ const formatUptime = (seconds) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'overview', label: 'Overview' },
|
{ id: 'overview', label: 'Übersicht' },
|
||||||
{ id: 'metrics', label: 'Metrics' },
|
{ id: 'metrics', label: 'Metriken' },
|
||||||
...(isModerator ? [
|
...(isModerator ? [
|
||||||
{ id: 'console', label: 'Console' },
|
{ id: 'console', label: 'Konsole' },
|
||||||
] : []),
|
] : []),
|
||||||
...(isModerator && server?.type === 'minecraft' ? [
|
...(isModerator && server?.type === 'minecraft' ? [
|
||||||
{ id: 'whitelist', label: 'Whitelist' },
|
{ id: 'whitelist', label: 'Whitelist' },
|
||||||
] : []),
|
] : []),
|
||||||
...(isModerator && server?.type === 'factorio' ? [
|
...(isModerator && server?.type === 'factorio' ? [
|
||||||
{ id: 'worlds', label: 'Worlds' },
|
{ id: 'worlds', label: 'Welten' },
|
||||||
|
] : []),
|
||||||
|
...(isModerator && server?.type === 'palworld' ? [
|
||||||
|
{ id: 'config', label: 'Config' },
|
||||||
|
] : []),
|
||||||
|
...(isModerator && server?.type === 'zomboid' ? [
|
||||||
|
{ id: 'zomboid-config', label: 'Config' },
|
||||||
|
] : []),
|
||||||
|
...(isModerator ? [
|
||||||
|
{ id: 'settings', label: 'Einstellungen' },
|
||||||
|
] : []),
|
||||||
|
...(isSuperadmin ? [
|
||||||
|
{ id: 'display', label: 'Anzeige' },
|
||||||
] : []),
|
] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-neutral-400">Loading...</div>
|
<div className="text-neutral-400">Laden...</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -198,7 +300,7 @@ const formatUptime = (seconds) => {
|
|||||||
if (!server) {
|
if (!server) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-neutral-400">Server not found</div>
|
<div className="text-neutral-400">Server nicht gefunden</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -212,9 +314,9 @@ const formatUptime = (seconds) => {
|
|||||||
case 'online':
|
case 'online':
|
||||||
return { class: 'badge badge-success', text: 'Online' }
|
return { class: 'badge badge-success', text: 'Online' }
|
||||||
case 'starting':
|
case 'starting':
|
||||||
return { class: 'badge badge-warning', text: 'Starting...' }
|
return { class: 'badge badge-warning', text: 'Startet...' }
|
||||||
case 'stopping':
|
case 'stopping':
|
||||||
return { class: 'badge badge-warning', text: 'Stopping...' }
|
return { class: 'badge badge-warning', text: 'Stoppt...' }
|
||||||
default:
|
default:
|
||||||
return { class: 'badge badge-destructive', text: 'Offline' }
|
return { class: 'badge badge-destructive', text: 'Offline' }
|
||||||
}
|
}
|
||||||
@@ -232,7 +334,7 @@ const formatUptime = (seconds) => {
|
|||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
>
|
>
|
||||||
Back
|
Zurück
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -244,7 +346,7 @@ const formatUptime = (seconds) => {
|
|||||||
</div>
|
</div>
|
||||||
{server.running && (
|
{server.running && (
|
||||||
<p className="text-sm text-neutral-400 mt-1">
|
<p className="text-sm text-neutral-400 mt-1">
|
||||||
Uptime: {formatUptime(server.metrics.uptime)}
|
Laufzeit: {formatUptime(server.metrics.uptime)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -273,38 +375,47 @@ const formatUptime = (seconds) => {
|
|||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="text-sm text-neutral-400">CPU Usage</div>
|
<div className="text-sm text-neutral-400">CPU Auslastung</div>
|
||||||
<div className="text-2xl font-semibold text-white mt-1">{server.metrics.cpu.toFixed(1)}%</div>
|
<div className="text-2xl font-semibold text-white mt-1">{server.metrics.cpu.toFixed(1)}%</div>
|
||||||
<div className="progress mt-2">
|
<div className="progress mt-2">
|
||||||
<div className="progress-bar" style={{ width: cpuPercent + '%' }} />
|
<div className="progress-bar" style={{ width: cpuPercent + '%' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="text-sm text-neutral-400">Memory</div>
|
<div className="text-sm text-neutral-400">Arbeitsspeicher</div>
|
||||||
<div className="text-2xl font-semibold text-white mt-1">
|
<div className="text-2xl font-semibold text-white mt-1">
|
||||||
{server.metrics.memoryUsed?.toFixed(1)} {server.metrics.memoryUnit}
|
{server.metrics.memoryUsed?.toFixed(1)} {server.metrics.memoryUnit}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500 mt-1">
|
<div className="text-xs text-neutral-500 mt-1">
|
||||||
of {server.metrics.memoryTotal?.toFixed(1)} {server.metrics.memoryUnit}
|
von {server.metrics.memoryTotal?.toFixed(1)} {server.metrics.memoryUnit}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="text-sm text-neutral-400">Players</div>
|
<div className="text-sm text-neutral-400">Spieler</div>
|
||||||
<div className="text-2xl font-semibold text-white mt-1">{server.players.online}</div>
|
<div className="text-2xl font-semibold text-white mt-1">{server.players.online}</div>
|
||||||
<div className="text-xs text-neutral-500 mt-1">
|
<div className="text-xs text-neutral-500 mt-1">
|
||||||
{server.players.max ? 'of ' + server.players.max + ' max' : 'No limit'}
|
{server.players.max ? 'von ' + server.players.max + ' max' : 'Kein Limit'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="text-sm text-neutral-400">CPU Cores</div>
|
<div className="text-sm text-neutral-400">CPU Kerne</div>
|
||||||
<div className="text-2xl font-semibold text-white mt-1">{server.metrics.cpuCores}</div>
|
<div className="text-2xl font-semibold text-white mt-1">{server.metrics.cpuCores}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{server.type === 'factorio' && currentSave?.save && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-sm text-neutral-400">{server.running ? 'Aktuelle Welt' : 'Nächste Welt'}</div>
|
||||||
|
<div className="text-lg font-semibold text-white mt-1 truncate">{currentSave.save}</div>
|
||||||
|
{!server.running && currentSave.source === 'newest' && (
|
||||||
|
<div className="text-xs text-neutral-500 mt-1">neuester Spielstand</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Players List */}
|
{/* Players List */}
|
||||||
{server.players?.list?.length > 0 && (
|
{server.players?.list?.length > 0 && (
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">Online Players</h3>
|
<h3 className="text-sm font-medium text-neutral-300 mb-3">Online Spieler</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{server.players.list.map((player, i) => (
|
{server.players.list.map((player, i) => (
|
||||||
<span key={i} className="badge badge-secondary">{player}</span>
|
<span key={i} className="badge badge-secondary">{player}</span>
|
||||||
@@ -316,15 +427,7 @@ const formatUptime = (seconds) => {
|
|||||||
{/* Power Controls */}
|
{/* Power Controls */}
|
||||||
{isModerator && (
|
{isModerator && (
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">Server Controls</h3>
|
<h3 className="text-sm font-medium text-neutral-300 mb-3">Server Steuerung</h3>
|
||||||
|
|
||||||
{/* Factorio: Show which save will be loaded */}
|
|
||||||
{server.type === 'factorio' && !server.running && currentSave?.save && (
|
|
||||||
<div className="text-sm text-neutral-400 mb-3">
|
|
||||||
Will load: <span className="text-white font-medium">{currentSave.save}</span>
|
|
||||||
{currentSave.source === 'newest' && <span className="text-neutral-500 ml-1">(newest save)</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{(server.status === 'online' || (server.running && server.status !== 'starting' && server.status !== 'stopping')) ? (
|
{(server.status === 'online' || (server.running && server.status !== 'starting' && server.status !== 'stopping')) ? (
|
||||||
@@ -334,14 +437,14 @@ const formatUptime = (seconds) => {
|
|||||||
disabled={server.status === 'stopping' || server.status === 'starting'}
|
disabled={server.status === 'stopping' || server.status === 'starting'}
|
||||||
className="btn btn-destructive"
|
className="btn btn-destructive"
|
||||||
>
|
>
|
||||||
{server.status === 'stopping' ? 'Stopping...' : 'Stop Server'}
|
{server.status === 'stopping' ? 'Stoppt...' : 'Server stoppen'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('restart')}
|
onClick={() => handleAction('restart')}
|
||||||
disabled={server.status === 'stopping' || server.status === 'starting'}
|
disabled={server.status === 'stopping' || server.status === 'starting'}
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
>
|
>
|
||||||
{server.status === 'starting' ? 'Starting...' : 'Restart Server'}
|
{server.status === 'starting' ? 'Startet...' : 'Server neustarten'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -350,7 +453,7 @@ const formatUptime = (seconds) => {
|
|||||||
disabled={server.status === 'stopping' || server.status === 'starting'}
|
disabled={server.status === 'stopping' || server.status === 'starting'}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
>
|
>
|
||||||
{server.status === 'starting' ? 'Starting...' : 'Start Server'}
|
{server.status === 'starting' ? 'Startet...' : 'Server starten'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -369,22 +472,29 @@ const formatUptime = (seconds) => {
|
|||||||
<div className="space-y-4 tab-content">
|
<div className="space-y-4 tab-content">
|
||||||
{/* Logs */}
|
{/* Logs */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-neutral-400">Server Logs (last 20 lines)</span>
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-neutral-400">Server Logs (letzte 50 Zeilen)</span>
|
||||||
|
{logsUpdated && (
|
||||||
|
<span className="text-xs text-neutral-600">
|
||||||
|
Aktualisiert: {logsUpdated.toLocaleTimeString('de-DE')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button onClick={fetchLogs} className="btn btn-secondary">
|
<button onClick={fetchLogs} className="btn btn-secondary">
|
||||||
Refresh
|
Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={logsRef}
|
ref={logsRef}
|
||||||
className="terminal p-4 logs-container text-xs text-neutral-300 whitespace-pre-wrap"
|
className="terminal p-4 logs-container text-xs text-neutral-300 whitespace-pre-wrap"
|
||||||
>
|
>
|
||||||
{logs || 'Loading...'}
|
{logs || 'Laden...'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RCON History */}
|
{/* RCON History */}
|
||||||
{rconHistory.length > 0 && (
|
{rconHistory.length > 0 && (
|
||||||
<div ref={rconRef} className="terminal p-4 max-h-40 overflow-y-auto">
|
<div ref={rconRef} className="terminal p-4 max-h-40 overflow-y-auto">
|
||||||
<div className="text-neutral-500 text-xs mb-2">RCON History</div>
|
<div className="text-neutral-500 text-xs mb-2">RCON Verlauf</div>
|
||||||
{rconHistory.map((entry, i) => (
|
{rconHistory.map((entry, i) => (
|
||||||
<div key={i} className="mb-2 text-sm">
|
<div key={i} className="mb-2 text-sm">
|
||||||
<div className="text-neutral-400">
|
<div className="text-neutral-400">
|
||||||
@@ -405,11 +515,11 @@ const formatUptime = (seconds) => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={rconCommand}
|
value={rconCommand}
|
||||||
onChange={(e) => setRconCommand(e.target.value)}
|
onChange={(e) => setRconCommand(e.target.value)}
|
||||||
placeholder="RCON command..."
|
placeholder="RCON Befehl..."
|
||||||
className="input flex-1"
|
className="input flex-1"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Send
|
Senden
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -420,27 +530,27 @@ const formatUptime = (seconds) => {
|
|||||||
{activeTab === 'whitelist' && isModerator && server.type === 'minecraft' && (
|
{activeTab === 'whitelist' && isModerator && server.type === 'minecraft' && (
|
||||||
<div className="space-y-4 tab-content">
|
<div className="space-y-4 tab-content">
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">Add to Whitelist</h3>
|
<h3 className="text-sm font-medium text-neutral-300 mb-3">Zur Whitelist hinzufügen</h3>
|
||||||
<form onSubmit={addToWhitelist} className="flex gap-2">
|
<form onSubmit={addToWhitelist} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={whitelistInput}
|
value={whitelistInput}
|
||||||
onChange={(e) => setWhitelistInput(e.target.value)}
|
onChange={(e) => setWhitelistInput(e.target.value)}
|
||||||
placeholder="Minecraft username..."
|
placeholder="Minecraft Benutzername..."
|
||||||
className="input flex-1"
|
className="input flex-1"
|
||||||
disabled={whitelistLoading || !server.running}
|
disabled={whitelistLoading || !server.running}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary" disabled={whitelistLoading || !server.running}>
|
<button type="submit" className="btn btn-primary" disabled={whitelistLoading || !server.running}>
|
||||||
{whitelistLoading ? 'Adding...' : 'Add'}
|
{whitelistLoading ? 'Hinzufügen...' : 'Hinzufügen'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<h3 className="text-sm font-medium text-neutral-300">Whitelisted Players ({whitelistPlayers.length})</h3>
|
<h3 className="text-sm font-medium text-neutral-300">Whitelist Spieler ({whitelistPlayers.length})</h3>
|
||||||
<button onClick={fetchWhitelist} className="btn btn-ghost text-sm">
|
<button onClick={fetchWhitelist} className="btn btn-ghost text-sm">
|
||||||
Refresh
|
Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{whitelistPlayers.length > 0 ? (
|
{whitelistPlayers.length > 0 ? (
|
||||||
@@ -459,7 +569,7 @@ const formatUptime = (seconds) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-neutral-500 text-sm">No players whitelisted</div>
|
<div className="text-neutral-500 text-sm">Keine Spieler auf der Whitelist</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,8 +580,8 @@ const formatUptime = (seconds) => {
|
|||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
{server.running || server.status === 'starting' || server.status === 'stopping' ? (
|
{server.running || server.status === 'starting' || server.status === 'stopping' ? (
|
||||||
<div className="card p-8 text-center">
|
<div className="card p-8 text-center">
|
||||||
<div className="text-neutral-400 mb-2">World management is locked while the server is running</div>
|
<div className="text-neutral-400 mb-2">Weltverwaltung ist gesperrt während der Server läuft</div>
|
||||||
<div className="text-neutral-500 text-sm">Stop the server to manage saves</div>
|
<div className="text-neutral-500 text-sm">Stoppe den Server um Spielstände zu verwalten</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FactorioWorldManager
|
<FactorioWorldManager
|
||||||
@@ -482,6 +592,165 @@ const formatUptime = (seconds) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Config Tab - Palworld only */}
|
||||||
|
{activeTab === 'config' && isModerator && server.type === 'palworld' && (
|
||||||
|
<div className="tab-content">
|
||||||
|
<PalworldConfigEditor token={token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Config Tab - Zomboid only */}
|
||||||
|
{activeTab === 'zomboid-config' && isModerator && server.type === 'zomboid' && (
|
||||||
|
<div className="tab-content">
|
||||||
|
<ZomboidConfigEditor token={token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings Tab */}
|
||||||
|
{activeTab === 'settings' && isModerator && (
|
||||||
|
<div className="space-y-4 tab-content">
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-300 mb-4">Auto-Shutdown</h3>
|
||||||
|
<p className="text-neutral-500 text-sm mb-4">
|
||||||
|
Server automatisch stoppen wenn keine Spieler online sind
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toggle Switch */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleAutoShutdownToggle}
|
||||||
|
disabled={autoShutdownLoading}
|
||||||
|
className={`
|
||||||
|
relative w-14 h-8 rounded-full transition-all duration-200
|
||||||
|
${autoShutdown.enabled
|
||||||
|
? 'bg-green-600 border-green-500'
|
||||||
|
: 'bg-neutral-700 border-neutral-600'
|
||||||
|
}
|
||||||
|
border-2 focus:outline-none focus:ring-2 focus:ring-neutral-500
|
||||||
|
${autoShutdownLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
absolute top-1 w-5 h-5 rounded-full bg-white shadow-md
|
||||||
|
transition-all duration-200 ease-in-out
|
||||||
|
${autoShutdown.enabled ? 'left-7' : 'left-1'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className={`text-sm font-medium ${autoShutdown.enabled ? 'text-green-500' : 'text-neutral-500'}`}>
|
||||||
|
{autoShutdown.enabled ? 'Aktiviert' : 'Deaktiviert'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeout mit +/- Buttons */}
|
||||||
|
{autoShutdown.enabled && (
|
||||||
|
<div className="flex items-center gap-4 pt-2">
|
||||||
|
<span className="text-neutral-400 text-sm">Timeout:</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAutoShutdownTimeoutChange(autoShutdown.timeoutMinutes - 5)}
|
||||||
|
disabled={autoShutdown.timeoutMinutes <= 5}
|
||||||
|
className="btn btn-secondary px-3"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span className="text-white text-lg font-medium w-16 text-center">
|
||||||
|
{autoShutdown.timeoutMinutes}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAutoShutdownTimeoutChange(autoShutdown.timeoutMinutes + 5)}
|
||||||
|
disabled={autoShutdown.timeoutMinutes >= 1440}
|
||||||
|
className="btn btn-secondary px-3"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-neutral-500 text-sm">Minuten</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
{autoShutdown.enabled && server.running && (
|
||||||
|
<div className="border-t border-neutral-800 pt-4 mt-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-neutral-400 text-sm">Status:</span>
|
||||||
|
{autoShutdown.emptySinceMinutes !== null ? (
|
||||||
|
<span className="badge badge-warning">
|
||||||
|
Leer seit {autoShutdown.emptySinceMinutes} Min. Shutdown in {autoShutdown.timeoutMinutes - autoShutdown.emptySinceMinutes} Min.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-success">
|
||||||
|
Spieler online
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display Tab - Superadmin only */}
|
||||||
|
{activeTab === 'display' && isSuperadmin && (
|
||||||
|
<div className="space-y-4 tab-content">
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-300 mb-4">Anzeigeeinstellungen</h3>
|
||||||
|
<p className="text-neutral-500 text-sm mb-4">
|
||||||
|
Diese Einstellungen werden in der Server-Übersicht angezeigt
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Address Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-2">Verbindungsadresse</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displaySettings.address}
|
||||||
|
onChange={(e) => setDisplaySettingsState(prev => ({ ...prev, address: e.target.value }))}
|
||||||
|
placeholder="z.B. server.example.com:25565"
|
||||||
|
className="input w-full max-w-md"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-600 mt-1">
|
||||||
|
Die Adresse die Spieler zum Verbinden nutzen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hint Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-2">Hinweis / Kommentar</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displaySettings.hint}
|
||||||
|
onChange={(e) => setDisplaySettingsState(prev => ({ ...prev, hint: e.target.value }))}
|
||||||
|
placeholder="z.B. Passwort: geheim123"
|
||||||
|
className="input w-full max-w-md"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-600 mt-1">
|
||||||
|
Wird unter der Server-Karte angezeigt (z.B. Passwort, Version)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSaveDisplaySettings}
|
||||||
|
disabled={displaySettingsLoading}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{displaySettingsLoading ? 'Speichern...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
{displaySettingsSaved && (
|
||||||
|
<span className="text-green-500 text-sm">Gespeichert!</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
168
temp_Dashboard.jsx
Normal file
168
temp_Dashboard.jsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { getServers } from '../api'
|
||||||
|
import { useUser } from '../context/UserContext'
|
||||||
|
import ServerCard from '../components/ServerCard'
|
||||||
|
import SettingsModal from '../components/SettingsModal'
|
||||||
|
import UserManagement from '../components/UserManagement'
|
||||||
|
import LoginModal from '../components/LoginModal'
|
||||||
|
|
||||||
|
export default function Dashboard({ onLogin, onLogout }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, token, loading: userLoading, isSuperadmin, role } = useUser()
|
||||||
|
const [servers, setServers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [showUserMgmt, setShowUserMgmt] = useState(false)
|
||||||
|
const [showLogin, setShowLogin] = useState(false)
|
||||||
|
|
||||||
|
const isAuthenticated = !!token
|
||||||
|
|
||||||
|
const fetchServers = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getServers(token)
|
||||||
|
setServers(data)
|
||||||
|
setError('')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('401') || err.message.includes('403')) {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
onLogout()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('Verbindung zum Server fehlgeschlagen')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userLoading) {
|
||||||
|
fetchServers()
|
||||||
|
const interval = setInterval(fetchServers, 10000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [token, userLoading])
|
||||||
|
|
||||||
|
const roleLabels = {
|
||||||
|
user: 'Betrachter',
|
||||||
|
moderator: 'Operator',
|
||||||
|
superadmin: 'Admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-neutral-400">Laden...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlineCount = servers.filter(s => s.running).length
|
||||||
|
document.title = 'Dashboard | Zeasy GSM'
|
||||||
|
const totalPlayers = servers.reduce((sum, s) => sum + (s.players?.online || 0), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen page-enter">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-neutral-800 bg-neutral-900/50 backdrop-blur-sm sticky top-0 z-10">
|
||||||
|
<div className="container-main py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a href="https://zeasy.software" target="_blank" rel="noopener noreferrer" className="relative block group"><img src="/navbarlogograuer.png" alt="Logo" className="h-8 transition-opacity duration-300 group-hover:opacity-0" /><img src="/navbarlogoweiß.png" alt="Logo" className="h-8 absolute top-0 left-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" /></a>
|
||||||
|
<span className="text-xl font-semibold text-white hidden sm:inline">Gameserver Management</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex items-center gap-4 text-sm text-neutral-400">
|
||||||
|
<span>
|
||||||
|
<span className="text-white font-medium">{onlineCount}</span>/{servers.length} online
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-600">|</span>
|
||||||
|
<span>
|
||||||
|
<span className="text-white font-medium">{totalPlayers}</span> Spieler
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block text-right mr-2">
|
||||||
|
<div className="text-sm text-white">{user?.username}</div>
|
||||||
|
<div className="text-xs text-neutral-500">{roleLabels[role]}</div>
|
||||||
|
</div>
|
||||||
|
{isSuperadmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUserMgmt(true)}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
>
|
||||||
|
Benutzer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
>
|
||||||
|
Einstellungen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="btn btn-outline"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLogin(true)}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="container-main py-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 alert alert-error fade-in">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-neutral-400">Server werden geladen...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{servers.map((server, index) => (
|
||||||
|
<div
|
||||||
|
key={server.id}
|
||||||
|
className="fade-in-up"
|
||||||
|
style={{ animationDelay: index * 50 + 'ms', animationFillMode: 'both' }}
|
||||||
|
>
|
||||||
|
<ServerCard
|
||||||
|
server={server}
|
||||||
|
onClick={() => navigate('/server/' + server.id)}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showSettings && (
|
||||||
|
<SettingsModal onClose={() => setShowSettings(false)} />
|
||||||
|
)}
|
||||||
|
{showUserMgmt && (
|
||||||
|
<UserManagement onClose={() => setShowUserMgmt(false)} />
|
||||||
|
)}
|
||||||
|
{showLogin && (
|
||||||
|
<LoginModal onLogin={onLogin} onClose={() => setShowLogin(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
208
temp_ServerCard.jsx
Normal file
208
temp_ServerCard.jsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
const serverInfo = {
|
||||||
|
minecraft: {
|
||||||
|
address: 'minecraft.dimension47.de',
|
||||||
|
logo: '/minecraft.png',
|
||||||
|
links: [
|
||||||
|
{ label: 'ATM10 Modpack', url: 'https://www.curseforge.com/minecraft/modpacks/all-the-mods-10' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
factorio: {
|
||||||
|
hint: 'Serverpasswort: affe',
|
||||||
|
address: 'factorio.dimension47.de',
|
||||||
|
logo: '/factorio.png',
|
||||||
|
links: [
|
||||||
|
{ label: 'Steam', url: 'https://store.steampowered.com/app/427520/Factorio/' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
vrising: {
|
||||||
|
address: 'Zeasy Software Vampire',
|
||||||
|
logo: '/vrising.png',
|
||||||
|
links: [
|
||||||
|
{ label: 'Steam', url: 'https://store.steampowered.com/app/1604030/V_Rising/' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
zomboid: {
|
||||||
|
hint: 'Version 42.13',
|
||||||
|
address: 'pz.zeasy.dev:16261',
|
||||||
|
logo: '/zomboid.png',
|
||||||
|
links: [
|
||||||
|
{ label: 'Steam', url: 'https://store.steampowered.com/app/108600/Project_Zomboid/' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getServerInfo = (serverName) => {
|
||||||
|
const name = serverName.toLowerCase()
|
||||||
|
if (name.includes('minecraft') || name.includes('all the mods')) return serverInfo.minecraft
|
||||||
|
if (name.includes('factorio')) return serverInfo.factorio
|
||||||
|
if (name.includes('vrising') || name.includes('v rising')) return serverInfo.vrising
|
||||||
|
if (name.includes('zomboid')) return serverInfo.zomboid
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServerCard({ server, onClick, isAuthenticated }) {
|
||||||
|
const info = getServerInfo(server.name)
|
||||||
|
|
||||||
|
const formatUptime = (seconds) => {
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
if (hours > 24) {
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return days + 'd ' + (hours % 24) + 'h'
|
||||||
|
}
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
return hours + 'h ' + minutes + 'm'
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpuPercent = Math.min(server.metrics.cpu, 100)
|
||||||
|
const memPercent = Math.min(server.metrics.memory, 100)
|
||||||
|
|
||||||
|
const getProgressColor = (percent) => {
|
||||||
|
if (percent > 80) return 'progress-bar-danger'
|
||||||
|
if (percent > 60) return 'progress-bar-warning'
|
||||||
|
return 'progress-bar-success'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = () => {
|
||||||
|
const status = server.status || (server.running ? 'online' : 'offline')
|
||||||
|
switch (status) {
|
||||||
|
case 'online':
|
||||||
|
return { class: 'badge badge-success', text: 'Online' }
|
||||||
|
case 'starting':
|
||||||
|
return { class: 'badge badge-warning', text: 'Startet...' }
|
||||||
|
case 'stopping':
|
||||||
|
return { class: 'badge badge-warning', text: 'Stoppt...' }
|
||||||
|
case 'unreachable':
|
||||||
|
return { class: 'badge badge-muted', text: 'Nicht erreichbar' }
|
||||||
|
default:
|
||||||
|
return { class: 'badge badge-destructive', text: 'Offline' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadge = getStatusBadge()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`card ${server.status !== "unreachable" ? "card-clickable" : "cursor-not-allowed"} p-5 ${server.status === "unreachable" ? "opacity-50" : ""}`}
|
||||||
|
onClick={server.status !== "unreachable" ? onClick : undefined}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{info && info.logo && <img src={info.logo} alt="" className="h-8 w-8 object-contain" />}
|
||||||
|
<h3 className="text-lg font-semibold text-white">{server.name}</h3>
|
||||||
|
</div>
|
||||||
|
<span className={statusBadge.class}>
|
||||||
|
{statusBadge.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Address & Links */}
|
||||||
|
{info && (
|
||||||
|
<div className="mb-4 flex items-center gap-3 text-sm">
|
||||||
|
<code className="text-neutral-400 bg-neutral-800 px-2 py-0.5 rounded">
|
||||||
|
{info.address}
|
||||||
|
</code>
|
||||||
|
{info.links.map((link, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-blue-400 hover:text-blue-300 hover:underline"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Whitelist notice for Minecraft - only for authenticated users */}
|
||||||
|
{isAuthenticated && server.type === 'minecraft' && (
|
||||||
|
<div className="mb-4 text-xs text-neutral-500">
|
||||||
|
Whitelist erforderlich - im Whitelist-Tab freischalten
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Factorio notice - only for authenticated users */}
|
||||||
|
{isAuthenticated && server.type === 'factorio' && (
|
||||||
|
<div className="mb-4 text-xs text-neutral-500">
|
||||||
|
Serverpasswort: affe
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* V Rising notice - only for authenticated users */}
|
||||||
|
{isAuthenticated && server.type === 'vrising' && (
|
||||||
|
<div className="mb-4 text-xs text-neutral-500">
|
||||||
|
In der Serverliste suchen - Passwort: affe
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Project Zomboid notice - only for authenticated users */}
|
||||||
|
{isAuthenticated && server.type === 'zomboid' && (
|
||||||
|
<div className="mb-4 text-xs text-neutral-500">
|
||||||
|
Version 42.13
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* CPU */}
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-neutral-400">CPU</span>
|
||||||
|
<span className="text-white">{server.metrics.cpu.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress">
|
||||||
|
<div
|
||||||
|
className={'progress-bar ' + getProgressColor(cpuPercent)}
|
||||||
|
style={{ width: cpuPercent + '%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RAM */}
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-neutral-400">Arbeitsspeicher</span>
|
||||||
|
<span className="text-white">
|
||||||
|
{server.metrics.memoryUsed?.toFixed(1) || 0} / {server.metrics.memoryTotal?.toFixed(1) || 0} {server.metrics.memoryUnit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress">
|
||||||
|
<div
|
||||||
|
className={'progress-bar ' + getProgressColor(memPercent)}
|
||||||
|
style={{ width: memPercent + '%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Stats */}
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-neutral-800 text-sm">
|
||||||
|
<div className="text-neutral-400">
|
||||||
|
<span className="text-white font-medium">{server.players.online}</span>
|
||||||
|
{server.players.max ? ' / ' + server.players.max : ''} Spieler
|
||||||
|
</div>
|
||||||
|
{server.running && (
|
||||||
|
<div className="text-neutral-400">
|
||||||
|
Laufzeit: <span className="text-white">{formatUptime(server.metrics.uptime)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Players List */}
|
||||||
|
{server.players?.list?.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-neutral-800">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{server.players.list.map((player, i) => (
|
||||||
|
<span key={i} className="badge badge-secondary">
|
||||||
|
{player}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
640
temp_ServerDetail.jsx
Normal file
640
temp_ServerDetail.jsx
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { getServers, serverAction, sendRcon, getServerLogs, getWhitelist, getFactorioCurrentSave, getAutoShutdownSettings, setAutoShutdownSettings } from '../api'
|
||||||
|
import { useUser } from '../context/UserContext'
|
||||||
|
import MetricsChart from '../components/MetricsChart'
|
||||||
|
import FactorioWorldManager from '../components/FactorioWorldManager'
|
||||||
|
|
||||||
|
const getServerLogo = (serverName) => {
|
||||||
|
const name = serverName.toLowerCase()
|
||||||
|
if (name.includes("minecraft") || name.includes("all the mods")) return "/minecraft.png"
|
||||||
|
if (name.includes("factorio")) return "/factorio.png"
|
||||||
|
if (name.includes("vrising") || name.includes("v rising")) return "/vrising.png"
|
||||||
|
if (name.includes("zomboid")) return "/zomboid.png"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
export default function ServerDetail() {
|
||||||
|
const { serverId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { token, isModerator } = useUser()
|
||||||
|
const [server, setServer] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState('overview')
|
||||||
|
const [rconCommand, setRconCommand] = useState('')
|
||||||
|
const [rconHistory, setRconHistory] = useState([])
|
||||||
|
const [logs, setLogs] = useState('')
|
||||||
|
const [whitelistPlayers, setWhitelistPlayers] = useState([])
|
||||||
|
|
||||||
|
const [whitelistInput, setWhitelistInput] = useState('')
|
||||||
|
const [whitelistLoading, setWhitelistLoading] = useState(false)
|
||||||
|
const [currentSave, setCurrentSave] = useState(null)
|
||||||
|
const [logsUpdated, setLogsUpdated] = useState(null)
|
||||||
|
const logsRef = useRef(null)
|
||||||
|
const rconRef = useRef(null)
|
||||||
|
|
||||||
|
// Auto-shutdown state
|
||||||
|
const [autoShutdown, setAutoShutdown] = useState({ enabled: false, timeoutMinutes: 15, emptySinceMinutes: null })
|
||||||
|
const [autoShutdownLoading, setAutoShutdownLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchCurrentSave = async () => {
|
||||||
|
if (token && serverId === 'factorio') {
|
||||||
|
try {
|
||||||
|
const result = await getFactorioCurrentSave(token)
|
||||||
|
setCurrentSave(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch current save:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchServer = async () => {
|
||||||
|
try {
|
||||||
|
const servers = await getServers(token)
|
||||||
|
const found = servers.find(s => s.id === serverId)
|
||||||
|
if (found) {
|
||||||
|
setServer(found); document.title = found.name + " | Zeasy GSM"
|
||||||
|
} else {
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
navigate('/')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAutoShutdownSettings = async () => {
|
||||||
|
if (token && serverId) {
|
||||||
|
try {
|
||||||
|
const data = await getAutoShutdownSettings(token, serverId)
|
||||||
|
setAutoShutdown(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load auto-shutdown settings:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAutoShutdownToggle = async () => {
|
||||||
|
setAutoShutdownLoading(true)
|
||||||
|
try {
|
||||||
|
await setAutoShutdownSettings(token, serverId, !autoShutdown.enabled, autoShutdown.timeoutMinutes)
|
||||||
|
setAutoShutdown(prev => ({ ...prev, enabled: !prev.enabled }))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
setAutoShutdownLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAutoShutdownTimeoutChange = async (newTimeout) => {
|
||||||
|
const timeout = Math.max(1, Math.min(1440, parseInt(newTimeout) || 15))
|
||||||
|
setAutoShutdown(prev => ({ ...prev, timeoutMinutes: timeout }))
|
||||||
|
|
||||||
|
clearTimeout(window.autoShutdownSaveTimeout)
|
||||||
|
window.autoShutdownSaveTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await setAutoShutdownSettings(token, serverId, autoShutdown.enabled, timeout)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchServer()
|
||||||
|
fetchCurrentSave()
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchServer()
|
||||||
|
fetchCurrentSave()
|
||||||
|
}, 10000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [token, serverId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'settings' && isModerator) {
|
||||||
|
fetchAutoShutdownSettings()
|
||||||
|
const interval = setInterval(fetchAutoShutdownSettings, 10000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [activeTab, isModerator, token, serverId])
|
||||||
|
|
||||||
|
const handleAction = async (action) => {
|
||||||
|
// Immediately set status locally
|
||||||
|
const newStatus = action === 'start' ? 'starting' : (action === 'stop' ? 'stopping' : 'starting')
|
||||||
|
setServer(prev => ({ ...prev, status: newStatus }))
|
||||||
|
try {
|
||||||
|
await serverAction(token, server.id, action)
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchServer()
|
||||||
|
|
||||||
|
}, 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRcon = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!rconCommand.trim()) return
|
||||||
|
const cmd = rconCommand
|
||||||
|
setRconCommand('')
|
||||||
|
try {
|
||||||
|
const { response } = await sendRcon(token, server.id, cmd)
|
||||||
|
setRconHistory([...rconHistory, { cmd, res: response, time: new Date() }])
|
||||||
|
} catch (err) {
|
||||||
|
setRconHistory([...rconHistory, { cmd, res: 'Error: ' + err.message, time: new Date(), error: true }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getServerLogs(token, server.id, 50)
|
||||||
|
setLogs(data.logs || '')
|
||||||
|
setLogsUpdated(new Date())
|
||||||
|
if (logsRef.current) {
|
||||||
|
logsRef.current.scrollTop = logsRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'console' && isModerator && server) {
|
||||||
|
fetchLogs()
|
||||||
|
const interval = setInterval(fetchLogs, 5000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [activeTab, isModerator, server])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'whitelist' && isModerator && server?.type === 'minecraft') {
|
||||||
|
fetchWhitelist()
|
||||||
|
}
|
||||||
|
}, [activeTab, server])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rconRef.current) {
|
||||||
|
rconRef.current.scrollTop = rconRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [rconHistory])
|
||||||
|
|
||||||
|
|
||||||
|
const fetchWhitelist = async () => {
|
||||||
|
if (!server?.hasRcon) return
|
||||||
|
try {
|
||||||
|
const { players } = await getWhitelist(token, server.id)
|
||||||
|
setWhitelistPlayers(players)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch whitelist:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addToWhitelist = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!whitelistInput.trim()) return
|
||||||
|
setWhitelistLoading(true)
|
||||||
|
try {
|
||||||
|
await sendRcon(token, server.id, 'whitelist add ' + whitelistInput.trim())
|
||||||
|
setWhitelistInput('')
|
||||||
|
await fetchWhitelist()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add to whitelist:', err)
|
||||||
|
} finally {
|
||||||
|
setWhitelistLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromWhitelist = async (player) => {
|
||||||
|
setWhitelistLoading(true)
|
||||||
|
try {
|
||||||
|
await sendRcon(token, server.id, 'whitelist remove ' + player)
|
||||||
|
await fetchWhitelist()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove from whitelist:', err)
|
||||||
|
} finally {
|
||||||
|
setWhitelistLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatUptime = (seconds) => {
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (days > 0) return days + 'd ' + hours + 'h ' + minutes + 'm'
|
||||||
|
return hours + 'h ' + minutes + 'm'
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'overview', label: 'Übersicht' },
|
||||||
|
{ id: 'metrics', label: 'Metriken' },
|
||||||
|
...(isModerator ? [
|
||||||
|
{ id: 'console', label: 'Konsole' },
|
||||||
|
] : []),
|
||||||
|
...(isModerator && server?.type === 'minecraft' ? [
|
||||||
|
{ id: 'whitelist', label: 'Whitelist' },
|
||||||
|
] : []),
|
||||||
|
...(isModerator && server?.type === 'factorio' ? [
|
||||||
|
{ id: 'worlds', label: 'Welten' },
|
||||||
|
] : []),
|
||||||
|
...(isModerator ? [
|
||||||
|
{ id: 'settings', label: 'Einstellungen' },
|
||||||
|
] : []),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-neutral-400">Laden...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-neutral-400">Server nicht gefunden</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpuPercent = Math.min(server.metrics.cpu, 100)
|
||||||
|
const memPercent = Math.min(server.metrics.memory, 100)
|
||||||
|
|
||||||
|
const getStatusBadge = () => {
|
||||||
|
const status = server.status || (server.running ? 'online' : 'offline')
|
||||||
|
switch (status) {
|
||||||
|
case 'online':
|
||||||
|
return { class: 'badge badge-success', text: 'Online' }
|
||||||
|
case 'starting':
|
||||||
|
return { class: 'badge badge-warning', text: 'Startet...' }
|
||||||
|
case 'stopping':
|
||||||
|
return { class: 'badge badge-warning', text: 'Stoppt...' }
|
||||||
|
default:
|
||||||
|
return { class: 'badge badge-destructive', text: 'Offline' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadge = getStatusBadge()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen page-enter">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-neutral-800 bg-neutral-900/50 backdrop-blur-sm sticky top-0 z-10">
|
||||||
|
<div className="container-main py-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getServerLogo(server.name) && <img src={getServerLogo(server.name)} alt="" className="h-8 w-8 object-contain" />}
|
||||||
|
<h1 className="text-xl font-semibold text-white">{server.name}</h1>
|
||||||
|
<span className={statusBadge.class}>
|
||||||
|
{statusBadge.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{server.running && (
|
||||||
|
<p className="text-sm text-neutral-400 mt-1">
|
||||||
|
Laufzeit: {formatUptime(server.metrics.uptime)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="tabs mt-4">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={'tab ' + (activeTab === tab.id ? 'tab-active' : '')}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="container-main py-6">
|
||||||
|
{/* Overview Tab */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="space-y-6 tab-content">
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-sm text-neutral-400">CPU Auslastung</div>
|
||||||
|
<div className="text-2xl font-semibold text-white mt-1">{server.metrics.cpu.toFixed(1)}%</div>
|
||||||
|
<div className="progress mt-2">
|
||||||
|
<div className="progress-bar" style={{ width: cpuPercent + '%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-sm text-neutral-400">Arbeitsspeicher</div>
|
||||||
|
<div className="text-2xl font-semibold text-white mt-1">
|
||||||
|
{server.metrics.memoryUsed?.toFixed(1)} {server.metrics.memoryUnit}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500 mt-1">
|
||||||
|
von {server.metrics.memoryTotal?.toFixed(1)} {server.metrics.memoryUnit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-sm text-neutral-400">Spieler</div>
|
||||||
|
<div className="text-2xl font-semibold text-white mt-1">{server.players.online}</div>
|
||||||
|
<div className="text-xs text-neutral-500 mt-1">
|
||||||
|
{server.players.max ? 'von ' + server.players.max + ' max' : 'Kein Limit'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-sm text-neutral-400">CPU Kerne</div>
|
||||||
|
<div className="text-2xl font-semibold text-white mt-1">{server.metrics.cpuCores}</div>
|
||||||
|
</div>
|
||||||
|
{server.type === 'factorio' && currentSave?.save && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-sm text-neutral-400">{server.running ? 'Aktuelle Welt' : 'Nächste Welt'}</div>
|
||||||
|
<div className="text-lg font-semibold text-white mt-1 truncate">{currentSave.save}</div>
|
||||||
|
{!server.running && currentSave.source === 'newest' && (
|
||||||
|
<div className="text-xs text-neutral-500 mt-1">neuester Speicherstand</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Players List */}
|
||||||
|
{server.players?.list?.length > 0 && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-300 mb-3">Spieler Online</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{server.players.list.map((player, i) => (
|
||||||
|
<span key={i} className="badge badge-secondary">{player}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Power Controls */}
|
||||||
|
{isModerator && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-300 mb-3">Server Steuerung</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{(server.status === 'online' || (server.running && server.status !== 'starting' && server.status !== 'stopping')) ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction('stop')}
|
||||||
|
disabled={server.status === 'stopping' || server.status === 'starting'}
|
||||||
|
className="btn btn-destructive"
|
||||||
|
>
|
||||||
|
{server.status === 'stopping' ? 'Stoppt...' : 'Server Stoppen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction('restart')}
|
||||||
|
disabled={server.status === 'stopping' || server.status === 'starting'}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{server.status === 'starting' ? 'Startet...' : 'Server Neustarten'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction('start')}
|
||||||
|
disabled={server.status === 'stopping' || server.status === 'starting'}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{server.status === 'starting' ? 'Startet...' : 'Server Starten'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metrics Tab */}
|
||||||
|
{activeTab === 'metrics' && (
|
||||||
|
<div className="tab-content"><MetricsChart serverId={server.id} serverName={server.name} expanded={true} /></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Console Tab - Logs + RCON */}
|
||||||
|
{activeTab === 'console' && isModerator && (
|
||||||
|
<div className="space-y-4 tab-content">
|
||||||
|
{/* Logs */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-neutral-400">Server Logs (letzte 50 Zeilen)</span>
|
||||||
|
{logsUpdated && (
|
||||||
|
<span className="text-xs text-neutral-600">
|
||||||
|
Aktualisiert: {logsUpdated.toLocaleTimeString('de-DE')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={fetchLogs} className="btn btn-secondary">
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={logsRef}
|
||||||
|
className="terminal p-4 logs-container text-xs text-neutral-300 whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
{logs || 'Laden...'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RCON History */}
|
||||||
|
{rconHistory.length > 0 && (
|
||||||
|
<div ref={rconRef} className="terminal p-4 max-h-40 overflow-y-auto">
|
||||||
|
<div className="text-neutral-500 text-xs mb-2">RCON Verlauf</div>
|
||||||
|
{rconHistory.map((entry, i) => (
|
||||||
|
<div key={i} className="mb-2 text-sm">
|
||||||
|
<div className="text-neutral-400">
|
||||||
|
<span className="text-neutral-600">[{entry.time.toLocaleTimeString('de-DE')}]</span> > {entry.cmd}
|
||||||
|
</div>
|
||||||
|
<div className={'whitespace-pre-wrap pl-4 ' + (entry.error ? 'text-red-400' : 'text-neutral-300')}>
|
||||||
|
{entry.res}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RCON Input */}
|
||||||
|
{server.hasRcon && (
|
||||||
|
<form onSubmit={handleRcon} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rconCommand}
|
||||||
|
onChange={(e) => setRconCommand(e.target.value)}
|
||||||
|
placeholder="RCON Befehl..."
|
||||||
|
className="input flex-1"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Senden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Whitelist Tab - Minecraft only */}
|
||||||
|
{activeTab === 'whitelist' && isModerator && server.type === 'minecraft' && (
|
||||||
|
<div className="space-y-4 tab-content">
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-300 mb-3">Zur Whitelist hinzufügen</h3>
|
||||||
|
<form onSubmit={addToWhitelist} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={whitelistInput}
|
||||||
|
onChange={(e) => setWhitelistInput(e.target.value)}
|
||||||
|
placeholder="Minecraft Benutzername..."
|
||||||
|
className="input flex-1"
|
||||||
|
disabled={whitelistLoading || !server.running}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={whitelistLoading || !server.running}>
|
||||||
|
{whitelistLoading ? 'Wird hinzugefügt...' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-300">Whitelist Spieler ({whitelistPlayers.length})</h3>
|
||||||
|
<button onClick={fetchWhitelist} className="btn btn-ghost text-sm">
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{whitelistPlayers.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{whitelistPlayers.map((player, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1 bg-neutral-800 rounded-full pl-3 pr-1 py-1">
|
||||||
|
<span className="text-sm text-neutral-200">{player}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFromWhitelist(player)}
|
||||||
|
disabled={whitelistLoading || !server.running}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-neutral-700 text-neutral-400 hover:text-red-400"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-neutral-500 text-sm">Keine Spieler auf der Whitelist</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Worlds Tab - Factorio only */}
|
||||||
|
{activeTab === 'worlds' && isModerator && server.type === 'factorio' && (
|
||||||
|
<div className="tab-content">
|
||||||
|
{server.running || server.status === 'starting' || server.status === 'stopping' ? (
|
||||||
|
<div className="card p-8 text-center">
|
||||||
|
<div className="text-neutral-400 mb-2">Weltverwaltung ist gesperrt während der Server läuft</div>
|
||||||
|
<div className="text-neutral-500 text-sm">Server stoppen um Speicherstände zu verwalten</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FactorioWorldManager
|
||||||
|
server={server}
|
||||||
|
token={token}
|
||||||
|
onServerAction={fetchServer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings Tab */}
|
||||||
|
{activeTab === 'settings' && isModerator && (
|
||||||
|
<div className="space-y-4 tab-content">
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-300 mb-4">Auto-Shutdown</h3>
|
||||||
|
<p className="text-neutral-500 text-sm mb-4">
|
||||||
|
Server automatisch stoppen wenn keine Spieler online sind
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toggle Switch */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleAutoShutdownToggle}
|
||||||
|
disabled={autoShutdownLoading}
|
||||||
|
className={`
|
||||||
|
relative w-14 h-8 rounded-full transition-all duration-200
|
||||||
|
${autoShutdown.enabled
|
||||||
|
? 'bg-green-600 border-green-500'
|
||||||
|
: 'bg-neutral-700 border-neutral-600'
|
||||||
|
}
|
||||||
|
border-2 focus:outline-none focus:ring-2 focus:ring-neutral-500
|
||||||
|
${autoShutdownLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
absolute top-1 w-5 h-5 rounded-full bg-white shadow-md
|
||||||
|
transition-all duration-200 ease-in-out
|
||||||
|
${autoShutdown.enabled ? 'left-7' : 'left-1'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className={`text-sm font-medium ${autoShutdown.enabled ? 'text-green-500' : 'text-neutral-500'}`}>
|
||||||
|
{autoShutdown.enabled ? 'Aktiviert' : 'Deaktiviert'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeout mit +/- Buttons */}
|
||||||
|
{autoShutdown.enabled && (
|
||||||
|
<div className="flex items-center gap-4 pt-2">
|
||||||
|
<span className="text-neutral-400 text-sm">Timeout:</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAutoShutdownTimeoutChange(autoShutdown.timeoutMinutes - 5)}
|
||||||
|
disabled={autoShutdown.timeoutMinutes <= 5}
|
||||||
|
className="btn btn-secondary px-3"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span className="text-white text-lg font-medium w-16 text-center">
|
||||||
|
{autoShutdown.timeoutMinutes}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAutoShutdownTimeoutChange(autoShutdown.timeoutMinutes + 5)}
|
||||||
|
disabled={autoShutdown.timeoutMinutes >= 1440}
|
||||||
|
className="btn btn-secondary px-3"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-neutral-500 text-sm">Minuten</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
{autoShutdown.enabled && server.running && (
|
||||||
|
<div className="border-t border-neutral-800 pt-4 mt-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-neutral-400 text-sm">Status:</span>
|
||||||
|
{autoShutdown.emptySinceMinutes !== null ? (
|
||||||
|
<span className="badge badge-warning">
|
||||||
|
Leer seit {autoShutdown.emptySinceMinutes} Min. Shutdown in {autoShutdown.timeoutMinutes - autoShutdown.emptySinceMinutes} Min.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-success">
|
||||||
|
Spieler online
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
zomboid_funcs.js
Normal file
86
zomboid_funcs.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
|
||||||
|
|
||||||
|
// ============ ZOMBOID CONFIG FUNCTIONS ============
|
||||||
|
|
||||||
|
const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server";
|
||||||
|
const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"];
|
||||||
|
|
||||||
|
export async function listZomboidConfigs(server) {
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`;
|
||||||
|
const result = await ssh.execCommand(cmd);
|
||||||
|
|
||||||
|
if (result.code !== 0 || !result.stdout.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
const lines = result.stdout.trim().split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
const fullPath = match[7];
|
||||||
|
const filename = fullPath.split("/").pop();
|
||||||
|
|
||||||
|
if (!ALLOWED_CONFIG_FILES.includes(filename)) continue;
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
filename,
|
||||||
|
size: parseInt(match[5]),
|
||||||
|
modified: match[6]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readZomboidConfig(server, filename) {
|
||||||
|
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||||
|
throw new Error("File not allowed");
|
||||||
|
}
|
||||||
|
if (filename.includes("/") || filename.includes("..")) {
|
||||||
|
throw new Error("Invalid filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`);
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(result.stderr || "Failed to read config file");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeZomboidConfig(server, filename, content) {
|
||||||
|
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||||
|
throw new Error("File not allowed");
|
||||||
|
}
|
||||||
|
if (filename.includes("/") || filename.includes("..")) {
|
||||||
|
throw new Error("Invalid filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssh = await getConnection(server.host, server.sshUser);
|
||||||
|
|
||||||
|
// Create backup
|
||||||
|
const backupName = `${filename}.backup.${Date.now()}`;
|
||||||
|
await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||||
|
|
||||||
|
// Write file using sftp
|
||||||
|
const sftp = await ssh.requestSFTP();
|
||||||
|
const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`;
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
sftp.writeFile(filePath, content, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up old backups (keep last 5)
|
||||||
|
await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user