Files
Cs2-Mapveto/client/src/pages/AdminPage.jsx
T

689 lines
28 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';
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>
);
}