Files
Cs2-Mapveto/client/src/pages/AdminPage.jsx
T
mregosar 3c5a9b4162 Admin: Spieldateien auf allen Hosts per SteamCMD aktualisieren, mit Prozentanzeige
Ein kurzlebiger Container je Host aktualisiert CS2 im Spieldateien-Volume. Hosts mit
laufenden Servern werden ausgelassen (erzwingbar), während des Updates startet dort
kein neuer Server. Fertig nur, wenn Steam die App als vollständig installiert meldet.
2026-11-01 18:22:23 +01:00

1177 lines
49 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <AdminLogin onLogin={(t) => { 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 (
<div className="admin">
<header className="admin-head">
<div>
<h1>Admin</h1>
<p className="muted small">Alle Matches, Server und Teams. Aktualisiert sich alle 3 Sekunden.</p>
</div>
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
</header>
<nav className="admin-tabs" aria-label="Admin-Bereiche">
<button className={`admin-tab ${tab === 'matches' ? 'is-active' : ''}`} onClick={() => setTab('matches')}>Matches</button>
<button className={`admin-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Einstellungen</button>
</nav>
{tab === 'settings' && token && <SettingsLayout token={token} setNotice={setNotice} />}
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
{error && <div className="alert alert-error">{error}</div>}
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
{tab === 'matches' && (
<MatchToolbar
matches={filter === 'archive' ? [] : matches ?? []}
archivedCount={archivedCount}
filter={filter}
setFilter={setFilter}
query={query}
setQuery={setQuery}
/>
)}
{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 <p className="muted">{filter === 'archive' ? 'Das Archiv ist leer.' : 'Noch keine Matches.'}</p>;
}
if (shown.length === 0) return <p className="muted">Keine Matches für diesen Filter{query ? ` und „${query}“` : ''}.</p>;
return (
<div className="admin-list">
{shown.map((m) => (
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} />
))}
</div>
);
})()}
</div>
);
}
/** 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 (
<section className="admin-settings">
<h2>Discord-Meldungen</h2>
{!settings.enabled && (
<p className="alert alert-error">Kein Webhook eingerichtet. Setze DISCORD_WEBHOOK_URL und starte die Anwendung neu.</p>
)}
<div className="admin-settings-grid">
{settings.catalog.map(({ key, label }) => (
<label key={key} className="admin-check">
<input
type="checkbox"
checked={!!events[key]}
onChange={(e) => setEvents({ ...events, [key]: e.target.checked })}
/>
{label}
</label>
))}
</div>
<div className="admin-actions">
<button className="btn btn-sm btn-primary" onClick={save} disabled={busy || !dirty}>Speichern</button>
<button className="btn btn-sm" onClick={test} disabled={busy || !settings.enabled}>Testmeldung senden</button>
</div>
</section>
);
}
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 (
<form className="create admin-login" onSubmit={submit}>
<h1>Admin-Anmeldung</h1>
<p className="lead">Das Passwort ist in der Konfiguration unter <code>ADMIN_PASSWORD</code> hinterlegt.</p>
<label className="field">
<span>Passwort</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus autoComplete="current-password" required />
</label>
{error && <div className="alert alert-error">{error}</div>}
<button className="btn btn-primary btn-lg" disabled={busy}>{busy ? 'Melde an …' : 'Anmelden'}</button>
<p className="small muted"><Link to="/">Zur Übersicht</Link></p>
</form>
);
}
/** 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 (
<div className="rollback">
<h3>Runde zurückspielen <span className="admin-badge">nur Admin</span></h3>
{!points && <button className="btn btn-sm" onClick={load} disabled={loading}>{loading ? 'Lade …' : 'Sicherungen laden'}</button>}
{points && points.length === 0 && <p className="small muted">Keine Sicherungen für diese Map gefunden.</p>}
{points && points.length > 0 && (
<div className="row">
<select value={file} onChange={(e) => setFile(e.target.value)}>
{points.map((p) => <option key={p.file} value={p.file}>Nach Runde {p.round}</option>)}
</select>
<button className="btn btn-sm btn-danger" onClick={rollback}>Zurückspielen</button>
</div>
)}
</div>
);
}
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 = '') => (
<button className={`btn btn-sm ${cls}`} disabled={!!busy} onClick={() => run({ ...cfg, path: `/matches/${id}${cfg.path}` })}>
{label}
</button>
);
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 (
<section className={`panel admin-card status-${m.status}`}>
<div className="admin-card-head">
<div>
<strong className="team-a">{m.teams.A.name}</strong>
<span className="vs"> vs. </span>
<strong className="team-b">{m.teams.B.name}</strong>
<div className="small muted">
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
</div>
{m.status === 'live' && current && (
<div className="admin-live">
<span>Map {current.number}: {current.name}</span>
<strong>{current.score.A} : {current.score.B}</strong>
{current.live?.roundsPlayed != null && <span className="muted">Runde {current.live.roundsPlayed + 1}</span>}
</div>
)}
{m.server && (m.server.requestedAt || (m.server.address && m.server.status !== 'stopped')) && (
<div className="admin-address">
{m.server.address && m.server.status !== 'stopped' && (
<>
<span><span className="muted">Server</span> <code>{m.server.address.host}:{m.server.address.port}</code></span>
{m.server.address.tvPort && (
<span><span className="muted">CSTV</span> <code>{m.server.address.host}:{m.server.address.tvPort}</code></span>
)}
</>
)}
{m.server.requestedAt && (
<span><span className="muted">Angefordert</span> {formatDateTime(m.server.requestedAt)}</span>
)}
</div>
)}
{hasError(m) && m.server.message && (
<p className="admin-error small" role="alert">{m.server.message}</p>
)}
{m.archivedAt && <span className="admin-badge is-archived">Archiviert {formatDateTime(m.archivedAt)}</span>}
{paused && <span className="admin-badge is-paused">Pausiert</span>}
</div>
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
</div>
<div className="admin-actions">
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
{m.status === 'lobby' && m.vetoStart !== 'knife' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
{m.status === 'lobby' && m.vetoStart === 'knife' && ['A', 'B'].map((t) => (
<span key={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')}</span>
))}
{(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' && (
<button className="btn btn-sm" onClick={() => setShowConsole(true)}>Konsole</button>
)}
{!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')}
</div>
{showConsole && (
<ConsoleDialog title={`${m.teams.A.name} vs. ${m.teams.B.name}`} path={`/admin/logs/match/${id}`} token={token} onClose={() => setShowConsole(false)} />
)}
{open && (
<div className="admin-details">
<div className="admin-grid">
<div>
<h3>Einladungen</h3>
<div className="small muted">Team A</div>
<CopyField value={m.organizer.links.A} />
<div className="small muted">Team B</div>
<CopyField value={m.organizer.links.B} />
<div className="small muted">Zuschauer</div>
<CopyField value={m.organizer.links.spectator} />
<div className="small muted">Stream-Ansicht</div>
<CopyField value={m.organizer.links.stream} />
<div className="row">
{['A', 'B', 'spectator'].map((t) => (
<button key={t} className="link-btn" disabled={!!busy}
onClick={() => run({ path: `/matches/${id}/codes/regenerate`, body: { target: t }, confirm: 'Neuer Code? Der alte Link wird ungültig.', success: 'Neuer Code erzeugt' })}>
Code {t === 'spectator' ? 'Zuschauer' : t} erneuern
</button>
))}
</div>
</div>
<div>
<h3>Server</h3>
{m.server?.connect && <CopyField label="Spieler-Verbindung" value={m.server.connect.command} />}
{m.server?.gotv && <CopyField label="CSTV" value={m.server.gotv.command} />}
<p className="small muted">{m.server?.message ?? 'kein Server'}</p>
<p className="small muted">Versuche: {m.server?.attempts ?? 0} · Spieler auf dem Server: {m.server?.playerCount ?? '–'}</p>
</div>
</div>
<h3>Spieler</h3>
<div className="admin-players">
{['A', 'B'].map((t) => (
<div key={t}>
<div className="small muted">{m.teams[t].name} ({m.teams[t].readiness.count}/{m.teams[t].teamSize})</div>
<ul>
{m.teams[t].players.map((p) => (
<li key={p.id}>
{p.nickname} {p.isCaptain && <em>Captain</em>} {p.ready && <span className="tone-ok">bereit</span>}
{!closed && (
<button className="link-btn danger" disabled={!!busy}
onClick={() => run({ path: `/matches/${id}/kick`, body: { playerId: p.id }, confirm: `${p.nickname} entfernen?`, success: `${p.nickname} entfernt` })}>
Entfernen
</button>
)}
</li>
))}
{m.teams[t].players.length === 0 && <li className="muted">niemand</li>}
</ul>
</div>
))}
</div>
{m.status === 'live' && m.server?.status === 'configured' && (
<RollbackPanel matchId={id} token={token} reload={reload} setNotice={setNotice} />
)}
{m.veto && !m.veto.done && m.veto.current && (
<p className="small">Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).</p>
)}
<h3>Verlauf</h3>
<ol className="admin-log">
{m.events.slice(0, 15).map((e, i) => (
<li key={i}><time>{new Date(e.at).toLocaleTimeString('de-DE')}</time> {e.message}</li>
))}
</ol>
</div>
)}
</section>
);
}
/** 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 <HostUsage hosts={hosts} token={token} />;
}
/** 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 (
<section className="host-usage">
{logFor && (
<ConsoleDialog title={`Docker-Host ${logFor}`} path={`/admin/host-log/${encodeURIComponent(logFor)}`} token={token}
note="Nur lesen: Ereignisse des Hosts der letzten 2 Stunden und alles Neue." onClose={() => setLogFor(null)} />
)}
<h2>Docker-Hosts</h2>
<div className="host-grid">
{hosts.map((h) => {
const percent = h.total ? Math.round((h.used / h.total) * 100) : 0;
return (
<div key={h.name} className={`host-card ${percent >= 90 ? 'is-full' : ''}`}>
<div className="host-head">
<strong>{h.name}</strong>
<span className="muted small">{h.publicHost}</span>
<span className="host-count">{h.used} / {h.total} belegt</span>
{token && <button className="btn btn-sm host-log-btn" onClick={() => setLogFor(h.name)}>Live-Log</button>}
</div>
<div className="host-bar" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${percent}%` }} />
</div>
{h.disk && (
<div className={`host-disk ${h.disk.freeBytes < 20e9 ? 'is-low' : ''}`}>
Speicher: {gb(h.disk.freeBytes)} frei von {gb(h.disk.totalBytes)}
</div>
)}
{h.matches.length === 0 ? (
<p className="muted small">Keine Gameserver</p>
) : (
<ul className="host-matches">
{h.matches.map((m) => <li key={m.id}>{m.title} <span className="muted small">{m.status}</span></li>)}
</ul>
)}
</div>
);
})}
</div>
</section>
);
}
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 (
<label className="admin-check">
<input type="checkbox" checked={!!values[f.key]} onChange={(e) => setValues({ ...values, [f.key]: e.target.checked })} />
{f.label}
</label>
);
}
return (
<label className="setting-field">
<span>{f.label}</span>
{f.type === 'commands' ? (
<textarea rows={2} value={values[f.key]} placeholder="z. B. mp_freezetime 15; sv_alltalk 0"
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })} />
) : (
<input type="number" min={f.min} max={f.max} value={values[f.key]}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })} />
)}
</label>
);
};
return (
<section className="admin-settings">
<h2>{title}</h2>
<p className="small muted">
Markierte Werte (live) gehen beim Speichern sofort an alle laufenden Server. Die übrigen gelten für neue Server und Vetos.
</p>
{SETTING_GROUPS.filter((g) => groups.includes(g.key)).map((g) => (
<div key={g.key} className="setting-group">
{groups.length > 1 && <h3>{g.title}</h3>}
<div className="setting-grid">
{fields.filter((f) => f.group === g.key).map((f) => (
<div key={f.key} className="setting-item">
{input(f)}
<div className="small muted">
{f.live && <span className="setting-live">live</span>}
{f.overridden ? `Vorgabe aus .env: ${shownDefault(f)}` : 'Vorgabe aus .env'}
</div>
</div>
))}
</div>
</div>
))}
<div className="admin-actions">
<button className="btn btn-sm btn-primary" onClick={save} disabled={busy || changedKeys.length === 0}>Speichern</button>
{changedKeys.length > 0 && (
<button className="btn btn-sm" disabled={busy} onClick={() => apply(fields)}>Verwerfen</button>
)}
</div>
</section>
);
}
const EMPTY_HOST = { name: '', dockerHost: 'ssh://', publicHost: '', portStart: 27100, portEnd: 27139, lanPorts: '27015-27020', volume: 'cs2-mapveto-gamefiles' };
const HOST_COLUMNS = [
{ key: 'name', label: 'Name', placeholder: 'host1' },
{ key: 'dockerHost', label: 'Docker-Ziel', placeholder: 'ssh://server@192.168.178.45' },
{ key: 'publicHost', label: 'Adresse für Spieler', placeholder: '192.168.178.45' },
{ key: 'portStart', label: 'Port von', type: 'number' },
{ key: 'portEnd', label: 'Port bis', type: 'number' },
{ key: 'lanPorts', label: 'LAN-Ports (Steam)', placeholder: '27015-27020' },
{ key: 'volume', label: 'Volume', placeholder: 'cs2-mapveto-gamefiles' },
];
/** Docker-Hosts verwalten und das CS2-Image auf allen Hosts gleichzeitig aktualisieren. */
function DockerHostsPanel({ token, setNotice }) {
const [data, setData] = useState(null);
const [rows, setRows] = useState([]);
const [busy, setBusy] = useState(false);
const apply = useCallback((res) => {
setData(res);
setRows(res.hosts.map((h) => ({ ...EMPTY_HOST, ...h, dockerHost: h.dockerHost ?? '' })));
}, []);
const load = useCallback(() => api('/admin/docker-hosts', { token }), [token]);
useEffect(() => {
load().then(apply).catch((err) => setNotice({ tone: 'error', text: err.message }));
}, [load, apply, setNotice]);
// Während das Image lädt, alle 3 Sekunden den Stand holen (ohne die bearbeiteten Zeilen zu überschreiben)
const pulling = !!data?.pull?.running;
const updating = !!data?.files?.running;
const [forceFiles, setForceFiles] = useState(false);
useEffect(() => {
if (!pulling && !updating) return undefined;
const timer = setInterval(() => {
load().then((res) => setData((d) => ({ ...d, pull: res.pull, files: res.files }))).catch(() => {});
}, 3000);
return () => clearInterval(timer);
}, [pulling, updating, load]);
if (!data) return null;
if (!data.editable) {
return (
<section className="admin-settings">
<h2>Docker-Hosts</h2>
<p className="muted">Docker-Hosts und Image-Update gibt es nur mit dem Docker-Adapter (<code>SERVER_ADAPTER=docker</code>).</p>
</section>
);
}
const original = JSON.stringify(data.hosts.map((h) => ({ ...EMPTY_HOST, ...h, dockerHost: h.dockerHost ?? '' })));
const dirty = JSON.stringify(rows) !== original;
const setCell = (i, key, value) => setRows(rows.map((r, j) => (j === i ? { ...r, [key]: value } : r)));
const run = async (fn, success) => {
setBusy(true);
try {
const res = await fn();
apply(res);
if (success) setNotice({ tone: 'ok', text: success });
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(false);
}
};
const save = () => run(() => api('/admin/docker-hosts', {
method: 'PUT',
token,
body: {
hosts: rows.map((r) => ({
name: r.name.trim(),
dockerHost: r.dockerHost.trim() || null,
publicHost: r.publicHost.trim(),
...(r.rconHost && r.rconHost !== r.publicHost ? { rconHost: r.rconHost } : {}),
portStart: Number(r.portStart),
portEnd: Number(r.portEnd),
lanPorts: String(r.lanPorts ?? '').trim(),
volume: r.volume.trim() || undefined,
})),
},
}), 'Docker-Hosts gespeichert');
const reset = () => {
if (!window.confirm('Hosts aus der .env verwenden? Die hier gespeicherte Liste wird gelöscht.')) return;
run(() => api('/admin/docker-hosts', { method: 'DELETE', token }), 'Hosts aus der .env aktiv');
};
const updateFiles = () => {
const warn = forceFiles
? 'Spieldateien auf ALLEN Hosts aktualisieren, auch wo Server laufen? Laufende Server können dabei abstürzen.'
: 'Spieldateien auf allen Hosts ohne laufende Server aktualisieren?';
if (!window.confirm(warn)) return;
run(() => api('/admin/docker-hosts/update-files', { method: 'POST', body: { force: forceFiles }, token }));
};
const pull = () => {
if (!window.confirm(`Image ${data.image} auf allen Hosts neu laden? Laufende Server laufen weiter.`)) return;
run(() => api('/admin/docker-hosts/pull', { method: 'POST', token }));
};
return (
<section className="admin-settings">
<h2>Docker-Hosts</h2>
<p className="small muted">
{data.overridden ? 'Diese Liste ist im Admin-Bereich gespeichert und ersetzt CS2_DOCKER_HOSTS aus der .env.' : 'Aktuell gilt die Liste aus der .env.'}
{' '}Hosts mit laufenden Servern lassen sich erst nach dem Stoppen entfernen.
</p>
<div className="host-edit">
{rows.map((r, i) => (
<div key={i} className="host-edit-row">
{HOST_COLUMNS.map((c) => (
<label key={c.key} className={`setting-field host-col-${c.key}`}>
<span>{c.label}</span>
<input type={c.type ?? 'text'} value={r[c.key] ?? ''} placeholder={c.placeholder}
onChange={(e) => setCell(i, c.key, e.target.value)} />
</label>
))}
<button className="link-btn danger" disabled={busy || rows.length === 1}
onClick={() => setRows(rows.filter((_, j) => j !== i))}>Entfernen</button>
</div>
))}
</div>
<div className="admin-actions">
<button className="btn btn-sm" disabled={busy} onClick={() => setRows([...rows, { ...EMPTY_HOST }])}>Host hinzufügen</button>
<button className="btn btn-sm btn-primary" disabled={busy || !dirty} onClick={save}>Hosts speichern</button>
{dirty && <button className="btn btn-sm" disabled={busy} onClick={() => apply(data)}>Verwerfen</button>}
{data.overridden && <button className="btn btn-sm btn-ghost" disabled={busy} onClick={reset}>Liste aus .env verwenden</button>}
</div>
<h3>Image aktualisieren</h3>
<p className="small muted">
Lädt <code>{data.image}</code> auf allen Hosts gleichzeitig neu. Laufende Server laufen weiter, neue Server starten mit dem neuen Image.
</p>
<div className="admin-actions">
<button className="btn btn-sm btn-primary" disabled={busy || pulling || dirty} onClick={pull}>
{pulling ? 'Wird aktualisiert …' : 'Auf allen Hosts aktualisieren'}
</button>
</div>
{data.pull?.results?.length > 0 && (
<ul className="pull-results">
{data.pull.results.map((r) => (
<li key={r.name} className={`pull-${r.state}`}>
<strong>{r.name}</strong>
<span>{r.state === 'running' ? 'lädt …' : r.state === 'ok' ? 'aktuell' : 'fehlgeschlagen'}</span>
{r.message && <code className="small">{r.message}</code>}
</li>
))}
{data.pull.finishedAt && (
<li className="small muted">Zuletzt fertig: {new Date(data.pull.finishedAt).toLocaleString('de-DE')}</li>
)}
</ul>
)}
<h3>Spieldateien aktualisieren</h3>
<p className="small muted">
Holt das neueste CS2-Update per SteamCMD in das Volume jedes Hosts. Das Image-Update oben reicht dafür nicht.
Hosts mit laufenden Servern werden ausgelassen, damit niemandem die Dateien unter dem laufenden Server ausgetauscht werden.
Bestehende Server nutzen die neuen Dateien erst nach einem Neustart.
</p>
<div className="admin-actions">
<button className="btn btn-sm btn-primary" disabled={busy || updating || dirty} onClick={updateFiles}>
{updating ? filesLabel(data.files) : 'Spieldateien aktualisieren'}
</button>
<label className="admin-check small">
<input type="checkbox" checked={forceFiles} onChange={(e) => setForceFiles(e.target.checked)} disabled={updating} />
auch Hosts mit laufenden Servern
</label>
</div>
{data.files?.results?.length > 0 && (
<ul className="pull-results">
{data.files.results.map((r) => (
<li key={r.name} className={`pull-${r.state === 'skipped' ? 'skipped' : r.state}`}>
<strong>{r.name}</strong>
<span>{r.state === 'running' ? 'läuft' : r.state === 'ok' ? 'fertig' : r.state === 'skipped' ? 'ausgelassen' : 'fehlgeschlagen'}</span>
{r.message && <code className="small">{r.message}</code>}
{r.state === 'running' && r.progress != null && (
<span className="files-bar" role="progressbar" aria-valuenow={Math.round(r.progress)} aria-valuemin={0} aria-valuemax={100}>
<i style={{ width: `${Math.min(100, r.progress)}%` }} />
</span>
)}
</li>
))}
{data.files.finishedAt && (
<li className="small muted">Zuletzt fertig: {new Date(data.files.finishedAt).toLocaleString('de-DE')}</li>
)}
</ul>
)}
</section>
);
}
/** Knopftext während des Spieldateien-Updates: "Update läuft 43 %" (niedrigster Stand der laufenden Hosts) */
function filesLabel(files) {
const running = files?.results?.filter((r) => r.state === 'running') ?? [];
const known = running.filter((r) => r.progress != null).map((r) => r.progress);
return known.length ? `Update läuft ${Math.floor(Math.min(...known))} %` : 'Update läuft …';
}
/** Datum und Uhrzeit auf Deutsch, z. B. 29.10.2026, 12:54 Uhr */
function formatDateTime(ms) {
return `${new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} Uhr`;
}
/** Suche und Filter über der Matchliste, mit Anzahl pro Filter. */
function MatchToolbar({ matches, archivedCount, filter, setFilter, query, setQuery }) {
const count = (f) => (f.key === 'archive' ? archivedCount : matches.filter(f.test).length);
return (
<div className="admin-toolbar">
<input
type="search"
className="admin-search"
placeholder="Team, Spieler oder Match-ID suchen"
aria-label="Matches durchsuchen"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="admin-filters" role="group" aria-label="Matches filtern">
{FILTERS.map((f) => (
<button
key={f.key}
className={`admin-filter ${filter === f.key ? 'is-active' : ''} ${f.key === 'errors' && count(f) > 0 ? 'has-errors' : ''}`}
aria-pressed={filter === f.key}
onClick={() => setFilter(f.key)}
>
{f.label}
{(f.key !== 'archive' || archivedCount > 0) && <span className="admin-filter-count">{count(f)}</span>}
</button>
))}
</div>
</div>
);
}
const KNIFE_STATUS = {
starting: { text: 'startet', tone: 'pending' },
configuring: { text: 'wird eingerichtet', tone: 'pending' },
running: { text: 'bereit', tone: 'ok' },
config_failed: { text: 'Einrichtung fehlgeschlagen', tone: 'error' },
failed: { text: 'ausgefallen', tone: 'error' },
};
/** Messer-Server für die Seitenwahl: laufen dauerhaft, setzen sich nach jeder Messerrunde selbst zurück. */
function KnifePanel({ token, setNotice }) {
const [data, setData] = useState(null);
const [consoleFor, setConsoleFor] = useState(null);
const [mode, setMode] = useState('');
const [map, setMap] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const res = await api('/admin/knife', { token });
setData(res);
setMode((m) => m || res.modes[0]?.id || '');
setMap((m) => m || res.modes[0]?.defaultMap || '');
} catch {
/* Fehler zeigt die Matchliste schon an */
}
}, [token]);
useEffect(() => {
load();
const t = setInterval(load, REFRESH_MS);
return () => clearInterval(t);
}, [load]);
if (!data) return null;
const run = async (fn, success, question) => {
if (question && !window.confirm(question)) return;
setBusy(true);
try {
await fn();
if (success) setNotice({ tone: 'ok', text: success });
load();
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(false);
}
};
const post = (path, body) => api(`/admin/knife${path}`, { method: 'POST', body, token });
const full = data.servers.length >= data.maxServers;
const modeInfo = data.modes.find((x) => x.id === mode) ?? data.modes[0];
const chooseMode = (id) => {
setMode(id);
setMap(data.modes.find((x) => x.id === id)?.defaultMap ?? '');
};
return (
<section className="admin-settings knife-panel">
<h2>Messer-Server (Seitenwahl)</h2>
<p className="small muted">
Laufen dauerhaft und ohne Passwort, sichtbar im LAN-Reiter von Steam. Teams verbinden sich, spielen eine Messerrunde
und gehen wieder. Nach dem Spiel oder wenn der Server leer ist, setzt er sich selbst zurück.
</p>
{data.servers.length > 0 && (
<div className="knife-grid">
{data.servers.map((k) => {
const st = KNIFE_STATUS[k.status] ?? { text: k.status, tone: 'pending' };
const ready = k.status === 'running';
return (
<div key={k.id} className="knife-card">
<div className="knife-head">
<strong>{k.name}</strong>
{k.modeShort && <span className="admin-badge knife-mode">{k.modeShort}</span>}
<span className="muted small">{k.mapName}</span>
<span className={`tone-${st.tone} small knife-state`}>{st.text}</span>
</div>
{k.connect && <code className="knife-addr">{k.host}:{k.port}</code>}
<div className="small muted">
{k.playerCount != null ? `${k.playerCount} Spieler` : 'Spieler unbekannt'}
{k.message ? ` · ${k.message}` : ''}
</div>
<div className="admin-actions">
<button className="btn btn-sm" disabled={busy || !ready} onClick={() => run(() => post(`/${k.id}/round`), 'Messerrunde gestartet')}>Runde starten</button>
<button className="btn btn-sm" disabled={busy || !ready} onClick={() => run(() => post(`/${k.id}/reset`), 'Server zurückgesetzt')}>Zurücksetzen</button>
<button className="btn btn-sm" disabled={busy} onClick={() => run(() => post(`/${k.id}/restart`), 'Server wird neu gestartet', `${k.name} komplett neu starten?`)}>Neu starten</button>
<button className="btn btn-sm" onClick={() => setConsoleFor(k)}>Konsole</button>
<button className="btn btn-sm btn-ghost danger" disabled={busy} onClick={() => run(() => api(`/admin/knife/${k.id}`, { method: 'DELETE', token }), `${k.name} entfernt`, `${k.name} stoppen und entfernen?`)}>Entfernen</button>
</div>
</div>
);
})}
</div>
)}
{consoleFor && (
<ConsoleDialog title={consoleFor.name} path={`/admin/logs/knife/${consoleFor.id}`} token={token} onClose={() => setConsoleFor(null)} />
)}
<div className="admin-actions knife-new">
<select value={modeInfo?.id ?? ''} onChange={(e) => chooseMode(e.target.value)} aria-label="Modus für den neuen Messer-Server" disabled={busy || full}>
{data.modes.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
<select value={map} onChange={(e) => setMap(e.target.value)} aria-label="Map für den neuen Messer-Server" disabled={busy || full}>
{modeInfo?.maps.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
<button className="btn btn-sm btn-primary" disabled={busy || full} onClick={() => run(() => api('/admin/knife', { method: 'POST', body: { mode: modeInfo?.id, map }, token }), 'Messer-Server wird gestartet')}>
Messer-Server starten
</button>
<span className="small muted">{data.servers.length} von {data.maxServers}</span>
</div>
</section>
);
}
const STEP_LABEL = { ban: 'Ban', pick: 'Pick', side: 'Seite' };
/** Veto-Ablauf als kurze Schrittfolge; X = Team, das das Veto beginnt, Y = das andere Team. */
function FlowSteps({ flow }) {
return (
<ol className="flow-steps">
{flow.map((raw, i) => {
if (raw === 'decider') return <li key={i} className="flow-decider">Entscheidung</li>;
const [type, actor] = raw.split(':');
return <li key={i} className={`flow-${type}`}>{STEP_LABEL[type]} {actor === 'X' ? '1' : '2'}</li>;
})}
</ol>
);
}
/** Welche Maps gespielt werden, pro Modus. Gilt für neue Matches, laufende behalten ihre Maps. */
function MapPoolsPanel({ token, setNotice }) {
const [pools, setPools] = useState(null);
const [selection, setSelection] = useState({});
const [busy, setBusy] = useState(false);
const apply = (res) => {
setPools(res.pools);
setSelection(Object.fromEntries(res.pools.map((p) => [p.mode, p.maps.filter((m) => m.active).map((m) => m.id)])));
};
useEffect(() => {
api('/admin/map-pools', { token }).then(apply).catch((err) => setNotice({ tone: 'error', text: err.message }));
}, [token, setNotice]);
if (!pools) return null;
const toggle = (mode, id) => {
const cur = new Set(selection[mode]);
if (cur.has(id)) cur.delete(id);
else cur.add(id);
setSelection({ ...selection, [mode]: [...cur] });
};
const save = async (mode) => {
setBusy(true);
try {
apply(await api('/admin/map-pools', { method: 'PUT', body: { mode, maps: selection[mode] }, token }));
setNotice({ tone: 'ok', text: 'Maps gespeichert – gilt für neue Matches' });
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(false);
}
};
return (
<section className="admin-settings">
<h2>Map-Pools</h2>
<p className="small muted">
Welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an: Für jede weitere Map gibt es einen Ban mehr.
Laufende Matches behalten ihre Maps. „1“ ist das Team, das das Veto beginnt.
</p>
{pools.map((p) => {
const chosen = new Set(selection[p.mode]);
const saved = new Set(p.maps.filter((m) => m.active).map((m) => m.id));
const dirty = chosen.size !== saved.size || [...chosen].some((id) => !saved.has(id));
return (
<div key={p.mode} className="setting-group">
<h3>{p.label} <span className="muted small">{chosen.size} von {p.maps.length} Maps</span></h3>
<div className="pool-maps">
{p.maps.map((m) => (
<label key={m.id} className={`pool-map ${chosen.has(m.id) ? 'is-on' : ''}`}>
<input type="checkbox" checked={chosen.has(m.id)} onChange={() => toggle(p.mode, m.id)} />
<MapImage map={m} className="pool-map-art" />
<span className="pool-map-name">{m.name}</span>
</label>
))}
</div>
{!dirty && (
<div className="pool-flows">
{Object.entries(p.formats).map(([f, info]) => (
<div key={f} className="pool-flow">
<strong>{f.toUpperCase()}</strong>
{info.supported ? <FlowSteps flow={info.flow} /> : <span className="muted small">{info.reason}</span>}
</div>
))}
</div>
)}
<div className="admin-actions">
<button className="btn btn-sm btn-primary" disabled={busy || !dirty || chosen.size < 2} onClick={() => save(p.mode)}>Speichern</button>
{dirty && <button className="btn btn-sm" disabled={busy} onClick={() => setSelection({ ...selection, [p.mode]: [...saved] })}>Verwerfen</button>}
{chosen.size < 2 && <span className="small tone-error">Mindestens 2 Maps</span>}
{dirty && chosen.size >= 2 && <span className="small muted">Nach dem Speichern steht hier der neue Veto-Ablauf.</span>}
</div>
</div>
);
})}
</section>
);
}
const SETTINGS_KEY = 'cs2mv:admin:settings';
/** Menüpunkte der Einstellungen, in Gruppen für die Seitenleiste. */
const SETTINGS_MENU = [
{
title: 'Veto',
items: [{ key: 'maps', label: 'Map-Pools', hint: 'Maps im Veto' }],
},
{
title: 'Gameserver',
items: [
{ key: 'match', label: 'Match-Regeln', hint: 'Runden, Verlängerung, Befehle' },
{ key: 'tv', label: 'CSTV', hint: 'an/aus, Verzögerung' },
{ key: 'times', label: 'Zeiten', hint: 'Veto, Leerlauf, Start, Archiv' },
{ key: 'knife', label: 'Messer-Server', hint: 'Seitenwahl, 5v5 und 2v2' },
],
},
{
title: 'Server-Betrieb',
items: [{ key: 'hosts', label: 'Docker-Hosts', hint: 'Hosts und Image-Update' }],
},
{
title: 'Benachrichtigungen',
items: [{ key: 'discord', label: 'Discord', hint: 'Meldungen und Test' }],
},
];
const SETTINGS_KEYS = SETTINGS_MENU.flatMap((g) => g.items.map((i) => i.key));
const readSettingsSection = () => {
try {
const v = localStorage.getItem(SETTINGS_KEY);
return SETTINGS_KEYS.includes(v) ? v : SETTINGS_KEYS[0];
} catch {
return SETTINGS_KEYS[0];
}
};
/** Einstellungen mit Seitenleiste: links die Menüpunkte, rechts der gewählte Bereich. */
function SettingsLayout({ token, setNotice }) {
const [section, setSectionState] = useState(readSettingsSection);
const setSection = (key) => {
setSectionState(key);
try {
localStorage.setItem(SETTINGS_KEY, key);
} catch {
/* ignorieren */
}
};
const current = SETTINGS_MENU.flatMap((g) => g.items).find((i) => i.key === section);
return (
<div className="settings-layout">
<nav className="settings-nav" aria-label="Einstellungen">
{SETTINGS_MENU.map((g) => (
<div key={g.title} className="settings-nav-group">
<div className="settings-nav-title">{g.title}</div>
{g.items.map((i) => (
<button
key={i.key}
className={`settings-nav-item ${section === i.key ? 'is-active' : ''}`}
aria-current={section === i.key ? 'page' : undefined}
onClick={() => setSection(i.key)}
>
<span>{i.label}</span>
<small>{i.hint}</small>
</button>
))}
</div>
))}
</nav>
<div className="settings-content">
{section === 'maps' && <MapPoolsPanel token={token} setNotice={setNotice} />}
{['match', 'tv', 'times'].includes(section) && (
<ServerSettingsPanel key={section} token={token} setNotice={setNotice} groups={[section]} title={current.label} />
)}
{section === 'knife' && <KnifePanel token={token} setNotice={setNotice} />}
{section === 'hosts' && <HostUsageLive token={token} />}
{section === 'hosts' && <DockerHostsPanel token={token} setNotice={setNotice} />}
{section === 'discord' && <NotificationSettings token={token} setNotice={setNotice} />}
</div>
</div>
);
}