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'; const KEY = 'cs2mv:admin'; const REFRESH_MS = 3000; 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 [hosts, setHosts] = useState([]); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [busy, setBusy] = useState(null); const [tab, setTab] = useState('matches'); 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', { token }); setMatches(res.matches); setHosts(res.hosts ?? []); setError(null); } catch (err) { if (err.status === 401) { storeToken(null); setToken(null); } else { setError(err.message); } } }, [token]); 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 && } {tab === 'settings' && token && } {tab === 'settings' && token && } {notice &&
{notice.text}
} {error &&
{error}
} {tab === 'matches' && } {tab === 'matches' && matches === null && !error &&

Lade Matches …

} {tab === 'matches' && matches && matches.length === 0 &&

Noch keine Matches.

} {tab === 'matches' &&
{matches?.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 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?.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} )}
)} {paused && Pausiert}
Ansehen {m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, '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?' })} {!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })} {!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')} {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')}
{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: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */ function HostUsage({ hosts }) { if (hosts.length === 0) return null; return (

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
{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 }) { 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 (