689 lines
28 KiB
React
689 lines
28 KiB
React
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 <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 && <ServerSettingsPanel token={token} setNotice={setNotice} />}
|
||
{tab === 'settings' && token && <DockerHostsPanel token={token} setNotice={setNotice} />}
|
||
{tab === 'settings' && token && <NotificationSettings 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' && <HostUsage hosts={hosts} />}
|
||
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||
{tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
||
|
||
{tab === 'matches' && <div className="admin-list">
|
||
{matches?.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 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?.address && m.server.status !== 'stopped' && (
|
||
<div className="admin-address">
|
||
<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>
|
||
)}
|
||
</div>
|
||
)}
|
||
{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' && 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')}
|
||
</div>
|
||
|
||
{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: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */
|
||
function HostUsage({ hosts }) {
|
||
if (hosts.length === 0) return null;
|
||
return (
|
||
<section className="host-usage">
|
||
<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>
|
||
</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 }) {
|
||
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>Gameserver</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.map((g) => (
|
||
<div key={g.key} className="setting-group">
|
||
<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, 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: '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;
|
||
useEffect(() => {
|
||
if (!pulling) return undefined;
|
||
const timer = setInterval(() => {
|
||
load().then((res) => setData((d) => ({ ...d, pull: res.pull }))).catch(() => {});
|
||
}, 3000);
|
||
return () => clearInterval(timer);
|
||
}, [pulling, load]);
|
||
|
||
if (!data) return null;
|
||
if (!data.editable) return null;
|
||
|
||
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),
|
||
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 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>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|