import { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../lib/api.js'; import { FORMAT_LABEL, serverLabel } from '../lib/labels.js'; import CopyField from '../components/CopyField.jsx'; import MapImage from '../components/MapImage.jsx'; import ConsoleDialog from '../components/ConsoleDialog.jsx'; const KEY = 'cs2mv:admin'; const FILTER_KEY = 'cs2mv:admin:filter'; const REFRESH_MS = 3000; const RUNNING = ['lobby', 'veto', 'server_setup', 'live']; const CLOSED = ['finished', 'cancelled']; const SERVER_ERRORS = ['start_failed', 'failed', 'config_failed']; const hasError = (m) => SERVER_ERRORS.includes(m.server?.status); /** Filter der Matchliste. "archive" lädt eine eigene Liste vom Server. */ const FILTERS = [ { key: 'all', label: 'Alle', test: () => true }, { key: 'running', label: 'Laufend', test: (m) => RUNNING.includes(m.status) }, { key: 'errors', label: 'Fehler', test: hasError }, { key: 'closed', label: 'Abgeschlossen', test: (m) => CLOSED.includes(m.status) }, { key: 'archive', label: 'Archiv', test: () => true }, ]; const readFilter = () => { try { const f = localStorage.getItem(FILTER_KEY); return FILTERS.some((x) => x.key === f) ? f : 'all'; } catch { return 'all'; } }; /** Suche nach Teamnamen, Spielern und Match-ID, ohne Groß-/Kleinschreibung. */ function matchesSearch(m, query) { const q = query.trim().toLowerCase(); if (!q) return true; const hay = [m.id, m.teams.A.name, m.teams.B.name, ...['A', 'B'].flatMap((t) => m.teams[t].players.map((p) => p.nickname))]; return hay.some((x) => String(x ?? '').toLowerCase().includes(q)); } const getToken = () => { try { return sessionStorage.getItem(KEY); } catch { return null; } }; const storeToken = (t) => { try { if (t) sessionStorage.setItem(KEY, t); else sessionStorage.removeItem(KEY); } catch { /* ignorieren */ } }; export default function AdminPage() { const [token, setToken] = useState(getToken); const [matches, setMatches] = useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [busy, setBusy] = useState(null); const [tab, setTab] = useState('matches'); const [filter, setFilterState] = useState(readFilter); const [query, setQuery] = useState(''); const [archivedCount, setArchivedCount] = useState(0); const setFilter = (f) => { setFilterState(f); // Beim Wechsel zwischen Archiv und normaler Liste neu laden if ((f === 'archive') !== (filter === 'archive')) setMatches(null); try { localStorage.setItem(FILTER_KEY, f); } catch { /* ignorieren */ } }; const logout = useCallback(() => { api('/admin/logout', { method: 'POST', token }).catch(() => {}); storeToken(null); setToken(null); setMatches(null); }, [token]); const load = useCallback(async () => { try { const res = await api(`/admin/matches${filter === 'archive' ? '?archived=1' : ''}`, { token }); setMatches(res.matches); setArchivedCount(res.archivedCount ?? 0); setError(null); } catch (err) { if (err.status === 401) { storeToken(null); setToken(null); } else { setError(err.message); } } }, [token, filter]); useEffect(() => { if (!token) return undefined; load(); const t = setInterval(load, REFRESH_MS); return () => clearInterval(t); }, [token, load]); useEffect(() => { if (!notice) return undefined; const t = setTimeout(() => setNotice(null), 5000); return () => clearTimeout(t); }, [notice]); if (!token) return { storeToken(t); setToken(t); }} />; /** Aktion ausführen; destruktive Aktionen müssen bestätigt werden. */ const run = async ({ method = 'POST', path, body, confirm: question, success }) => { if (question && !window.confirm(question)) return; setBusy(path); try { await api(`/admin${path}`, { method, body, token }); setNotice({ tone: 'ok', text: success ?? 'Erledigt' }); load(); } catch (err) { setNotice({ tone: 'error', text: err.message }); } finally { setBusy(null); } }; return (

Admin

Alle Matches, Server und Teams. Aktualisiert sich alle 3 Sekunden.

{tab === 'settings' && token && } {notice &&
{notice.text}
} {error &&
{error}
} {tab === 'matches' && matches === null && !error &&

Lade Matches …

} {tab === 'matches' && ( )} {tab === 'matches' && matches && (() => { const active = FILTERS.find((f) => f.key === filter); const shown = matches.filter((m) => active.test(m) && matchesSearch(m, query)); if (matches.length === 0) { return

{filter === 'archive' ? 'Das Archiv ist leer.' : 'Noch keine Matches.'}

; } if (shown.length === 0) return

Keine Matches für diesen Filter{query ? ` und „${query}“` : ''}.

; return (
{shown.map((m) => ( ))}
); })()}
); } /** Auswahl, welche Ereignisse Discord erhalten. Die Werte werden erst beim Speichern übernommen. */ function NotificationSettings({ token, setNotice }) { const [settings, setSettings] = useState(null); const [events, setEvents] = useState({}); const [busy, setBusy] = useState(false); useEffect(() => { api('/admin/notifications', { token }) .then((res) => { setSettings(res); setEvents(res.events); }) .catch((err) => setNotice({ tone: 'error', text: err.message })); }, [token, setNotice]); const save = async () => { setBusy(true); try { const res = await api('/admin/notifications', { method: 'PUT', body: { events }, token }); setSettings(res); setEvents(res.events); setNotice({ tone: 'ok', text: 'Meldungen gespeichert' }); } catch (err) { setNotice({ tone: 'error', text: err.message }); } finally { setBusy(false); } }; const test = async () => { setBusy(true); try { await api('/admin/notifications/test', { method: 'POST', token }); setNotice({ tone: 'ok', text: 'Testmeldung gesendet' }); } catch (err) { setNotice({ tone: 'error', text: err.message }); } finally { setBusy(false); } }; if (!settings) return null; const dirty = settings.catalog.some(({ key }) => !!events[key] !== settings.events[key]); return (

Discord-Meldungen

{!settings.enabled && (

Kein Webhook eingerichtet. Setze DISCORD_WEBHOOK_URL und starte die Anwendung neu.

)}
{settings.catalog.map(({ key, label }) => ( ))}
); } function AdminLogin({ onLogin }) { const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const submit = async (e) => { e.preventDefault(); setBusy(true); setError(null); try { const res = await api('/admin/login', { method: 'POST', body: { password } }); onLogin(res.token); } catch (err) { setError(err.message); setBusy(false); } }; return (

Admin-Anmeldung

Das Passwort ist in der Konfiguration unter ADMIN_PASSWORD hinterlegt.

{error &&
{error}
}

Zur Übersicht

); } /** Rollback: nur auf der Admin-Seite. Lädt die Sicherungen der aktuellen Map und spielt eine davon zurück. */ function RollbackPanel({ matchId, token, reload, setNotice }) { const [points, setPoints] = useState(null); const [file, setFile] = useState(''); const [loading, setLoading] = useState(false); const load = async () => { setLoading(true); try { const res = await api(`/admin/matches/${matchId}/rollback-points`, { token }); setPoints(res.points); setFile(res.points[0]?.file ?? ''); } catch (err) { setNotice({ tone: 'error', text: err.message }); } finally { setLoading(false); } }; const rollback = async () => { const p = points.find((x) => x.file === file); if (!p || !window.confirm(`Match auf den Stand nach Runde ${p.round} zurückspielen? Spätere Runden dieser Map gehen verloren.`)) return; try { await api(`/admin/matches/${matchId}/rollback`, { method: 'POST', body: { file }, token }); setNotice({ tone: 'ok', text: `Zurückgespielt auf Runde ${p.round}` }); setPoints(null); reload(); } catch (err) { setNotice({ tone: 'error', text: err.message }); } }; return (

Runde zurückspielen nur Admin

{!points && } {points && points.length === 0 &&

Keine Sicherungen für diese Map gefunden.

} {points && points.length > 0 && (
)}
); } function MatchAdminCard({ m, run, busy, token, reload, setNotice }) { const [open, setOpen] = useState(false); const [showConsole, setShowConsole] = useState(false); const server = serverLabel(m.server); const closed = ['finished', 'cancelled'].includes(m.status); const id = m.id; const btn = (label, cfg, cls = '') => ( ); const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1); const current = m.series.find((s) => s.number === m.currentMapNumber); const configured = m.server?.status === 'configured'; const paused = !!m.server?.paused; return (
{m.teams.A.name} vs. {m.teams.B.name}
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · {server.text} {m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}}
{m.status === 'live' && current && (
Map {current.number}: {current.name} {current.score.A} : {current.score.B} {current.live?.roundsPlayed != null && Runde {current.live.roundsPlayed + 1}}
)} {m.server && (m.server.requestedAt || (m.server.address && m.server.status !== 'stopped')) && (
{m.server.address && m.server.status !== 'stopped' && ( <> Server {m.server.address.host}:{m.server.address.port} {m.server.address.tvPort && ( CSTV {m.server.address.host}:{m.server.address.tvPort} )} )} {m.server.requestedAt && ( Angefordert {formatDateTime(m.server.requestedAt)} )}
)} {hasError(m) && m.server.message && (

{m.server.message}

)} {m.archivedAt && Archiviert {formatDateTime(m.archivedAt)}} {paused && Pausiert}
Ansehen {m.status === 'lobby' && m.vetoStart !== 'knife' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')} {m.status === 'lobby' && m.vetoStart === 'knife' && ['A', 'B'].map((t) => ( {btn(`${m.teams[t].name} beginnt das Veto`, { path: '/veto/start', body: { firstTeam: t }, confirm: `Messerrunde vorbei: ${m.teams[t].name} beginnt das Veto?`, success: 'Veto gestartet' }, 'btn-primary')} ))} {(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })} {configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')} {configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')} {(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })} {m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed && btn('Server neu starten / wiederholen', { path: '/server/retry' })} {m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })} {m.server && m.server.status !== 'stopped' && ( )} {!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })} {!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')} {closed && !m.archivedAt && btn('Archivieren', { path: '/archive', success: 'Match archiviert' })} {m.archivedAt && btn('Aus dem Archiv holen', { path: '/unarchive', success: 'Match ist wieder in der Liste' })} {btn('Löschen', { method: 'DELETE', path: '', confirm: `Match ${m.teams.A.name} vs. ${m.teams.B.name} endgültig löschen?`, success: 'Match gelöscht' }, 'btn-ghost danger')}
{showConsole && ( setShowConsole(false)} /> )} {open && (

Einladungen

Team A
Team B
Zuschauer
Stream-Ansicht
{['A', 'B', 'spectator'].map((t) => ( ))}

Server

{m.server?.connect && } {m.server?.gotv && }

{m.server?.message ?? 'kein Server'}

Versuche: {m.server?.attempts ?? 0} · Spieler auf dem Server: {m.server?.playerCount ?? '–'}

Spieler

{['A', 'B'].map((t) => (
{m.teams[t].name} ({m.teams[t].readiness.count}/{m.teams[t].teamSize})
    {m.teams[t].players.map((p) => (
  • {p.nickname} {p.isCaptain && Captain} {p.ready && bereit} {!closed && ( )}
  • ))} {m.teams[t].players.length === 0 &&
  • niemand
  • }
))}
{m.status === 'live' && m.server?.status === 'configured' && ( )} {m.veto && !m.veto.done && m.veto.current && (

Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).

)}

Verlauf

    {m.events.slice(0, 15).map((e, i) => (
  1. {e.message}
  2. ))}
)}
); } /** Auslastung der Docker-Hosts, regelmäßig neu geladen (Einstellungen → Docker-Hosts). */ function HostUsageLive({ token }) { const [hosts, setHosts] = useState([]); useEffect(() => { let alive = true; const load = () => api('/admin/hosts', { token }).then((res) => alive && setHosts(res.hosts)).catch(() => {}); load(); const t = setInterval(load, REFRESH_MS); return () => { alive = false; clearInterval(t); }; }, [token]); return ; } /** Auslastung der Docker-Hosts: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */ function HostUsage({ hosts, token }) { const [logFor, setLogFor] = useState(null); if (hosts.length === 0) return null; return (
{logFor && ( setLogFor(null)} /> )}

Docker-Hosts

{hosts.map((h) => { const percent = h.total ? Math.round((h.used / h.total) * 100) : 0; return (
= 90 ? 'is-full' : ''}`}>
{h.name} {h.publicHost} {h.used} / {h.total} belegt {token && }
{h.disk && (
Speicher: {gb(h.disk.freeBytes)} frei von {gb(h.disk.totalBytes)}
)} {h.matches.length === 0 ? (

Keine Gameserver

) : (
    {h.matches.map((m) =>
  • {m.title} {m.status}
  • )}
)}
); })}
); } function gb(bytes) { return `${Math.round(bytes / 1e9)} GB`; } const SETTING_GROUPS = [ { key: 'match', title: 'Match-Regeln' }, { key: 'tv', title: 'CSTV' }, { key: 'times', title: 'Zeiten' }, ]; const toInput = (field, value) => (field.type === 'commands' ? value.join('; ') : value); const shownDefault = (field) => { if (field.type === 'bool') return field.default ? 'an' : 'aus'; if (field.type === 'commands') return field.default.length ? field.default.join('; ') : 'keine'; return field.default; }; /** Gameserver-Einstellungen: Vorgaben aus der .env, im Admin-Bereich änderbar. */ function ServerSettingsPanel({ token, setNotice, groups = SETTING_GROUPS.map((g) => g.key), title = 'Gameserver' }) { const [fields, setFields] = useState(null); const [values, setValues] = useState({}); const [busy, setBusy] = useState(false); const apply = (list) => { setFields(list); setValues(Object.fromEntries(list.map((f) => [f.key, toInput(f, f.value)]))); }; useEffect(() => { api('/admin/server-settings', { token }) .then((res) => apply(res.fields)) .catch((err) => setNotice({ tone: 'error', text: err.message })); }, [token, setNotice]); if (!fields) return null; const changedKeys = fields.filter((f) => String(values[f.key]) !== String(toInput(f, f.value))).map((f) => f.key); const save = async () => { setBusy(true); try { const body = Object.fromEntries(changedKeys.map((key) => { const field = fields.find((f) => f.key === key); return [key, field.type === 'int' ? Number(values[key]) : values[key]]; })); const res = await api('/admin/server-settings', { method: 'PUT', body: { values: body }, token }); apply(res.fields); const parts = ['Einstellungen gespeichert']; if (res.live.updated) parts.push(`${res.live.updated} laufende${res.live.updated === 1 ? 'r' : ''} Server aktualisiert`); if (res.live.failed.length) parts.push(`${res.live.failed.length} Server nicht erreichbar`); setNotice({ tone: res.live.failed.length ? 'error' : 'ok', text: parts.join(' · ') }); } catch (err) { setNotice({ tone: 'error', text: err.message }); } finally { setBusy(false); } }; const input = (f) => { if (f.type === 'bool') { return ( ); } return (