Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c57b8c170 | ||
|
|
7fe4c2ec79 | ||
|
|
ee9c488a7e | ||
|
|
af2ecf7a3a |
No files matched your search
@@ -283,7 +283,7 @@ Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen au
|
|||||||
Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder.
|
Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder.
|
||||||
|
|
||||||
- Modus wählbar: **Competitive 5v5** (12 Plätze, Maps aus dem Competitive-Pool) oder **Wingman 2v2** (6 Plätze, Maps aus dem Wingman-Pool).
|
- Modus wählbar: **Competitive 5v5** (12 Plätze, Maps aus dem Competitive-Pool) oder **Wingman 2v2** (6 Plätze, Maps aus dem Wingman-Pool).
|
||||||
- Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`Maxlan | Messer 1 5v5 (Seitenwahl)`) und auf der Startseite mit „Verbinden“.
|
- Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`Maxlan | Messer 1 5v5 (Seitenwahl)`). In der Lobby eines Matches mit Messerrunde stehen die freien Messer-Server mit „Verbinden“.
|
||||||
- Nur Messer, kein Geld, eine Runde. Vorher läuft eine Aufwärmphase zum Verbinden (`KNIFE_WARMUP_SECONDS`, Standard 90). Nach der Runde startet das Spiel von selbst neu (`mp_match_end_restart`). Wird der Server leer, setzt die App ihn zusätzlich zurück.
|
- Nur Messer, kein Geld, eine Runde. Vorher läuft eine Aufwärmphase zum Verbinden (`KNIFE_WARMUP_SECONDS`, Standard 90). Nach der Runde startet das Spiel von selbst neu (`mp_match_end_restart`). Wird der Server leer, setzt die App ihn zusätzlich zurück.
|
||||||
- Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
|
- Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
|
||||||
- Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
|
- Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
|
||||||
@@ -472,6 +472,7 @@ Unter `/admin` mit dem Passwort aus `ADMIN_PASSWORD` (leer = Admin-Bereich aus).
|
|||||||
- **Suche und Filter:** Suche nach Team, Spieler oder Match-ID. Filter Alle, Laufend, Fehler (Zahl rot, wenn es Fehler gibt), Abgeschlossen und Archiv. Der Filter bleibt im Browser gespeichert.
|
- **Suche und Filter:** Suche nach Team, Spieler oder Match-ID. Filter Alle, Laufend, Fehler (Zahl rot, wenn es Fehler gibt), Abgeschlossen und Archiv. Der Filter bleibt im Browser gespeichert.
|
||||||
- **Matchkarte:** Teams, Modus, Format, Status, Live-Stand, Server- und CSTV-Adresse (`Server 192.168.178.45:27015 · CSTV …:27115`), Zeitpunkt der Server-Anforderung, Pausen-Abzeichen. Bei einem Serverfehler steht die Ursache rot in der Karte.
|
- **Matchkarte:** Teams, Modus, Format, Status, Live-Stand, Server- und CSTV-Adresse (`Server 192.168.178.45:27015 · CSTV …:27115`), Zeitpunkt der Server-Anforderung, Pausen-Abzeichen. Bei einem Serverfehler steht die Ursache rot in der Karte.
|
||||||
- **Aktionen:** Ansehen, Veto starten, Aufwärmphase beenden, Pausieren/Fortsetzen, Nächste Map, Server neu starten / wiederholen, Server stoppen, Match abschließen, Match abbrechen, Archivieren / Aus dem Archiv holen, Löschen. Destruktive Aktionen fragen nach.
|
- **Aktionen:** Ansehen, Veto starten, Aufwärmphase beenden, Pausieren/Fortsetzen, Nächste Map, Server neu starten / wiederholen, Server stoppen, Match abschließen, Match abbrechen, Archivieren / Aus dem Archiv holen, Löschen. Destruktive Aktionen fragen nach.
|
||||||
|
- **Konsole:** Knopf „Konsole“ in der Matchkarte und bei jedem Messer-Server. Zeigt die Ausgabe des Containers live (`docker logs --follow`, die letzten 300 Zeilen und alles Neue), mit Filter, „Mitlaufen“, „Leeren“ und „Neu verbinden“. Passwörter des Servers sind ausgeblendet. Höchstens 8 Konsolen gleichzeitig.
|
||||||
- **Details:** Einladungslinks und Codes (erneuerbar), Verbindungsbefehle für Spieler und CSTV, Spielerliste mit „Entfernen“, Rollback der laufenden Map.
|
- **Details:** Einladungslinks und Codes (erneuerbar), Verbindungsbefehle für Spieler und CSTV, Spielerliste mit „Entfernen“, Rollback der laufenden Map.
|
||||||
- **Archiv:** abgeschlossene oder abgebrochene Matches lassen sich archivieren. Nach `ARCHIVE_AFTER_DAYS` Tagen (Standard 3, 0 = nie) geschieht das automatisch. Archivierte Matches stehen nur unter dem Filter „Archiv“.
|
- **Archiv:** abgeschlossene oder abgebrochene Matches lassen sich archivieren. Nach `ARCHIVE_AFTER_DAYS` Tagen (Standard 3, 0 = nie) geschieht das automatisch. Archivierte Matches stehen nur unter dem Filter „Archiv“.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const MAX_LINES = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-Konsole eines Containers im Admin-Bereich. Liest `GET /api/admin/logs/...` als Stream
|
||||||
|
* (fetch statt EventSource, weil die Anmeldung im Authorization-Kopf mitgeht).
|
||||||
|
*/
|
||||||
|
export default function ConsoleDialog({ title, path, token, onClose, note }) {
|
||||||
|
const [lines, setLines] = useState([]);
|
||||||
|
const [status, setStatus] = useState('verbinde');
|
||||||
|
const [follow, setFollow] = useState(true);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [run, setRun] = useState(0);
|
||||||
|
const boxRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
let rest = '';
|
||||||
|
setLines([]);
|
||||||
|
setStatus('verbinde');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api${path}?tail=300`, { headers: { authorization: `Bearer ${token}` }, signal: ctl.signal });
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error ?? `Fehler ${res.status}`);
|
||||||
|
}
|
||||||
|
setStatus('live');
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
for (;;) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const parts = (rest + decoder.decode(value, { stream: true })).split('\n');
|
||||||
|
rest = parts.pop();
|
||||||
|
if (parts.length) setLines((cur) => [...cur, ...parts].slice(-MAX_LINES));
|
||||||
|
}
|
||||||
|
if (rest) setLines((cur) => [...cur, rest].slice(-MAX_LINES));
|
||||||
|
setStatus('beendet');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') setStatus(`Fehler: ${err.message}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => ctl.abort();
|
||||||
|
}, [path, token, run]);
|
||||||
|
|
||||||
|
// Mitlaufen: ans Ende scrollen, solange "Mitlaufen" an ist
|
||||||
|
useEffect(() => {
|
||||||
|
if (follow && boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
|
||||||
|
}, [lines, follow]);
|
||||||
|
|
||||||
|
const onKey = useCallback((e) => { if (e.key === 'Escape') onClose(); }, [onClose]);
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [onKey]);
|
||||||
|
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
const shown = q ? lines.filter((l) => l.toLowerCase().includes(q)) : lines;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="console-backdrop" onClick={onClose}>
|
||||||
|
<section className="console-dialog" role="dialog" aria-modal="true" aria-label={`Konsole ${title}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<header className="console-head">
|
||||||
|
<strong>Konsole: {title}</strong>
|
||||||
|
<span className={`console-status ${status === 'live' ? 'is-live' : ''}`}>{status === 'live' ? 'live' : status}</span>
|
||||||
|
<button className="btn btn-sm" onClick={onClose}>Schließen</button>
|
||||||
|
</header>
|
||||||
|
<div className="console-tools">
|
||||||
|
<input type="search" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Zeilen filtern" aria-label="Zeilen filtern" />
|
||||||
|
<label className="admin-check"><input type="checkbox" checked={follow} onChange={(e) => setFollow(e.target.checked)} /> Mitlaufen</label>
|
||||||
|
<button className="btn btn-sm" onClick={() => setLines([])}>Leeren</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => setRun((n) => n + 1)}>Neu verbinden</button>
|
||||||
|
<span className="small muted">{shown.length} Zeilen</span>
|
||||||
|
</div>
|
||||||
|
<pre className="console-out" ref={boxRef} onScroll={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||||
|
if (atEnd !== follow) setFollow(atEnd);
|
||||||
|
}}>
|
||||||
|
{shown.length ? shown.join('\n') : status === 'verbinde' ? 'Verbinde …' : 'Noch keine Ausgabe.'}
|
||||||
|
</pre>
|
||||||
|
<p className="small muted console-note">{note ?? 'Passwörter sind ausgeblendet.'} Die Ansicht zeigt die letzten {MAX_LINES} Zeilen.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { api } from '../lib/api.js';
|
|||||||
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
|
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
|
||||||
import CopyField from '../components/CopyField.jsx';
|
import CopyField from '../components/CopyField.jsx';
|
||||||
import MapImage from '../components/MapImage.jsx';
|
import MapImage from '../components/MapImage.jsx';
|
||||||
|
import ConsoleDialog from '../components/ConsoleDialog.jsx';
|
||||||
|
|
||||||
const KEY = 'cs2mv:admin';
|
const KEY = 'cs2mv:admin';
|
||||||
const FILTER_KEY = 'cs2mv:admin:filter';
|
const FILTER_KEY = 'cs2mv:admin:filter';
|
||||||
@@ -327,6 +328,7 @@ function RollbackPanel({ matchId, token, reload, setNotice }) {
|
|||||||
|
|
||||||
function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [showConsole, setShowConsole] = useState(false);
|
||||||
const server = serverLabel(m.server);
|
const server = serverLabel(m.server);
|
||||||
const closed = ['finished', 'cancelled'].includes(m.status);
|
const closed = ['finished', 'cancelled'].includes(m.status);
|
||||||
const id = m.id;
|
const id = m.id;
|
||||||
@@ -395,6 +397,9 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
|
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
|
||||||
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
|
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 && !['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 abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })}
|
||||||
{!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')}
|
{!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')}
|
||||||
{closed && !m.archivedAt && btn('Archivieren', { path: '/archive', success: 'Match archiviert' })}
|
{closed && !m.archivedAt && btn('Archivieren', { path: '/archive', success: 'Match archiviert' })}
|
||||||
@@ -402,6 +407,10 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
{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')}
|
{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>
|
</div>
|
||||||
|
|
||||||
|
{showConsole && (
|
||||||
|
<ConsoleDialog title={`${m.teams.A.name} vs. ${m.teams.B.name}`} path={`/admin/logs/match/${id}`} token={token} onClose={() => setShowConsole(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="admin-details">
|
<div className="admin-details">
|
||||||
<div className="admin-grid">
|
<div className="admin-grid">
|
||||||
@@ -488,14 +497,19 @@ function HostUsageLive({ token }) {
|
|||||||
clearInterval(t);
|
clearInterval(t);
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [token]);
|
||||||
return <HostUsage hosts={hosts} />;
|
return <HostUsage hosts={hosts} token={token} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auslastung der Docker-Hosts: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */
|
/** Auslastung der Docker-Hosts: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */
|
||||||
function HostUsage({ hosts }) {
|
function HostUsage({ hosts, token }) {
|
||||||
|
const [logFor, setLogFor] = useState(null);
|
||||||
if (hosts.length === 0) return null;
|
if (hosts.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section className="host-usage">
|
<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>
|
<h2>Docker-Hosts</h2>
|
||||||
<div className="host-grid">
|
<div className="host-grid">
|
||||||
{hosts.map((h) => {
|
{hosts.map((h) => {
|
||||||
@@ -506,6 +520,7 @@ function HostUsage({ hosts }) {
|
|||||||
<strong>{h.name}</strong>
|
<strong>{h.name}</strong>
|
||||||
<span className="muted small">{h.publicHost}</span>
|
<span className="muted small">{h.publicHost}</span>
|
||||||
<span className="host-count">{h.used} / {h.total} belegt</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>
|
||||||
<div className="host-bar" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
|
<div className="host-bar" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
|
||||||
<span style={{ width: `${percent}%` }} />
|
<span style={{ width: `${percent}%` }} />
|
||||||
@@ -831,6 +846,7 @@ const KNIFE_STATUS = {
|
|||||||
/** Messer-Server für die Seitenwahl: laufen dauerhaft, setzen sich nach jeder Messerrunde selbst zurück. */
|
/** Messer-Server für die Seitenwahl: laufen dauerhaft, setzen sich nach jeder Messerrunde selbst zurück. */
|
||||||
function KnifePanel({ token, setNotice }) {
|
function KnifePanel({ token, setNotice }) {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
|
const [consoleFor, setConsoleFor] = useState(null);
|
||||||
const [mode, setMode] = useState('');
|
const [mode, setMode] = useState('');
|
||||||
const [map, setMap] = useState('');
|
const [map, setMap] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -904,6 +920,7 @@ function KnifePanel({ token, setNotice }) {
|
|||||||
<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}/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 || !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" 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>
|
<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>
|
</div>
|
||||||
@@ -911,6 +928,9 @@ function KnifePanel({ token, setNotice }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{consoleFor && (
|
||||||
|
<ConsoleDialog title={consoleFor.name} path={`/admin/logs/knife/${consoleFor.id}`} token={token} onClose={() => setConsoleFor(null)} />
|
||||||
|
)}
|
||||||
<div className="admin-actions knife-new">
|
<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}>
|
<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>)}
|
{data.modes.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
|
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
|
||||||
@@ -30,7 +30,6 @@ export default function Overview() {
|
|||||||
|
|
||||||
<Steps current={step} />
|
<Steps current={step} />
|
||||||
|
|
||||||
<KnifeServers />
|
|
||||||
|
|
||||||
{matches === null ? (
|
{matches === null ? (
|
||||||
<p className="muted">Lade Matches …</p>
|
<p className="muted">Lade Matches …</p>
|
||||||
@@ -253,37 +252,3 @@ function CodeTicket() {
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Offene Messer-Server für die Seitenwahl, zum direkten Verbinden. */
|
|
||||||
function KnifeServers() {
|
|
||||||
const [servers, setServers] = useState([]);
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => {});
|
|
||||||
load();
|
|
||||||
const t = setInterval(load, 10_000);
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
clearInterval(t);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
if (servers.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<section className="group knife-public">
|
|
||||||
<h2>Messer-Server für die Seitenwahl <span className="count">{servers.length}</span></h2>
|
|
||||||
<p className="muted small">Ohne Passwort, auch im LAN-Reiter von Steam. Nach eurer Messerrunde setzt sich der Server selbst zurück.</p>
|
|
||||||
<ul className="knife-list">
|
|
||||||
{servers.map((k) => (
|
|
||||||
<li key={k.id}>
|
|
||||||
<strong>{k.name}</strong>
|
|
||||||
{k.modeShort && <span className="knife-mode">{k.modeShort}</span>}
|
|
||||||
<span className="muted small">{k.mapName}</span>
|
|
||||||
<span className="small">{k.playerCount ? `${k.playerCount} Spieler` : 'frei'}</span>
|
|
||||||
<code>{k.connect}</code>
|
|
||||||
<a className="btn btn-sm" href={k.steamUrl}>Verbinden</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -752,3 +752,20 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
.chat-form input { flex: 1; min-width: 0; }
|
.chat-form input { flex: 1; min-width: 0; }
|
||||||
.chat-closed { margin: 0; padding: 10px 16px; border-top: 1px solid var(--line); }
|
.chat-closed { margin: 0; padding: 10px 16px; border-top: 1px solid var(--line); }
|
||||||
.chat-box .field-error { margin: 0 16px 10px; }
|
.chat-box .field-error { margin: 0 16px 10px; }
|
||||||
|
/* Live-Konsole der Container */
|
||||||
|
.console-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 16px; background: rgba(var(--bg-rgb), .78); }
|
||||||
|
.console-dialog { display: flex; flex-direction: column; width: min(1100px, 100%); height: min(760px, 100%); background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius); overflow: hidden; }
|
||||||
|
.console-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
||||||
|
.console-head strong { font: 700 1.1rem var(--display); margin-right: auto; }
|
||||||
|
.console-status { font-size: .8rem; color: var(--muted); }
|
||||||
|
.console-status.is-live { color: var(--ok); }
|
||||||
|
.console-status.is-live::before { content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: var(--ok); vertical-align: middle; }
|
||||||
|
.console-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 8px 16px; border-bottom: 1px solid var(--line); }
|
||||||
|
.console-tools input[type="search"] { flex: 1 1 200px; min-width: 0; }
|
||||||
|
.console-out { flex: 1; margin: 0; padding: 12px 16px; overflow: auto; background: #05060a; color: #cfd6e6; font: .8rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.console-note { margin: 0; padding: 6px 16px 10px; }
|
||||||
|
.host-log-btn { margin-left: 4px; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.console-backdrop { padding: 0; }
|
||||||
|
.console-dialog { height: 100%; border-radius: 0; }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { formatPortList } from './config.js';
|
import { formatPortList } from './config.js';
|
||||||
|
import { formatDockerEvent } from './servers/dockerAdapter.js';
|
||||||
import { HttpError } from './matchService.js';
|
import { HttpError } from './matchService.js';
|
||||||
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
||||||
|
|
||||||
@@ -164,6 +165,93 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
||||||
|
// Konsolenausgabe eines Containers live (Match-Server oder Messer-Server). Passwörter werden ausgeblendet.
|
||||||
|
let openStreams = 0;
|
||||||
|
router.get('/logs/:kind/:id', requireAdmin, (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { kind, id } = req.params;
|
||||||
|
const row = kind === 'match' ? manager.row(id) : kind === 'knife' ? knife.row(id) : null;
|
||||||
|
if (!row) throw new HttpError(404, 'Server nicht gefunden', 'not_found');
|
||||||
|
if (!row.handle) throw new HttpError(409, 'Für diesen Server gibt es noch keinen Container', 'no_container');
|
||||||
|
if (!manager.adapter.streamLogs) throw new HttpError(501, 'Dieser Adapter hat keine Konsolenausgabe', 'unsupported');
|
||||||
|
if (openStreams >= 8) throw new HttpError(429, 'Zu viele offene Konsolen, erst eine schließen', 'rate_limited');
|
||||||
|
const tail = Math.max(0, Math.min(2000, Number.parseInt(req.query.tail, 10) || 200));
|
||||||
|
const secrets = [row.rcon_password, row.server_password, row.tv_password].filter((s) => s && s.length >= 4);
|
||||||
|
const mask = (text) => secrets.reduce((t, s) => t.split(s).join('••••'), text);
|
||||||
|
|
||||||
|
const proc = manager.adapter.streamLogs(JSON.parse(row.handle), { tail });
|
||||||
|
openStreams += 1;
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
|
||||||
|
// Zeilenweise ausgeben, damit ein Passwort nie an einer Paketgrenze zerrissen und so doch sichtbar wird
|
||||||
|
const pipe = (stream) => {
|
||||||
|
let rest = '';
|
||||||
|
stream.setEncoding?.('utf8');
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
const lines = (rest + chunk).split('\n');
|
||||||
|
rest = lines.pop();
|
||||||
|
if (lines.length) res.write(mask(lines.join('\n')) + '\n');
|
||||||
|
});
|
||||||
|
stream.on('end', () => { if (rest) res.write(mask(rest) + '\n'); });
|
||||||
|
};
|
||||||
|
pipe(proc.stdout);
|
||||||
|
pipe(proc.stderr);
|
||||||
|
let done = false;
|
||||||
|
const finish = (note) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
openStreams -= 1;
|
||||||
|
if (note && !res.writableEnded) res.end(`\n[${note}]\n`);
|
||||||
|
};
|
||||||
|
proc.on('close', () => finish('Ausgabe beendet'));
|
||||||
|
proc.on?.('error', (err) => finish(`Fehler: ${err.message}`));
|
||||||
|
req.on('close', () => {
|
||||||
|
proc.kill();
|
||||||
|
finish(null);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live-Log eines Docker-Hosts: seine Ereignisse (Container gestartet, beendet, entfernt, Images geladen …). Nur lesen.
|
||||||
|
router.get('/host-log/:name', requireAdmin, (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!manager.adapter.streamEvents) throw new HttpError(501, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||||||
|
if (openStreams >= 8) throw new HttpError(429, 'Zu viele offene Konsolen, erst eine schließen', 'rate_limited');
|
||||||
|
const since = Math.max(0, Math.min(24 * 60, Number.parseInt(req.query.since, 10) || 120));
|
||||||
|
const proc = manager.adapter.streamEvents(req.params.name, { sinceMinutes: since });
|
||||||
|
if (!proc) throw new HttpError(404, 'Docker-Host nicht gefunden', 'not_found');
|
||||||
|
openStreams += 1;
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
|
||||||
|
res.write(`Ereignisse von ${req.params.name} seit ${since} Minuten, neue erscheinen live.\n`);
|
||||||
|
let rest = '';
|
||||||
|
proc.stdout.setEncoding('utf8');
|
||||||
|
proc.stdout.on('data', (chunk) => {
|
||||||
|
const lines = (rest + chunk).split('\n');
|
||||||
|
rest = lines.pop();
|
||||||
|
const out = lines.map(formatDockerEvent).filter(Boolean);
|
||||||
|
if (out.length) res.write(out.join('\n') + '\n');
|
||||||
|
});
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stderr.on('data', (chunk) => res.write(`[docker] ${chunk}`));
|
||||||
|
let done = false;
|
||||||
|
const finish = (note) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
openStreams -= 1;
|
||||||
|
if (note && !res.writableEnded) res.end(`\n[${note}]\n`);
|
||||||
|
};
|
||||||
|
proc.on('close', () => finish('Ausgabe beendet'));
|
||||||
|
proc.on('error', (err) => finish(`Fehler: ${err.message}`));
|
||||||
|
req.on('close', () => {
|
||||||
|
proc.kill();
|
||||||
|
finish(null);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Auslastung und Speicher der Docker-Hosts mit den Servern darauf
|
// Auslastung und Speicher der Docker-Hosts mit den Servern darauf
|
||||||
router.get('/hosts', requireAdmin, wrap(async () => ({ hosts: await manager.hostUsage() })));
|
router.get('/hosts', requireAdmin, wrap(async () => ({ hosts: await manager.hostUsage() })));
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
|
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
|
||||||
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
|
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
|
||||||
|
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
import { backupPrefix, parseBackupFiles } from '../rounds.js';
|
import { backupPrefix, parseBackupFiles } from '../rounds.js';
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -51,6 +52,20 @@ export class DemoServerAdapter {
|
|||||||
|
|
||||||
async loadRoundBackup() {}
|
async loadRoundBackup() {}
|
||||||
|
|
||||||
|
/** Simulierte Konsolenausgabe, damit die Konsolen-Ansicht ohne echten Server testbar ist. */
|
||||||
|
streamLogs(handle, { intervalMs = 1000 } = {}) {
|
||||||
|
const stdout = new PassThrough();
|
||||||
|
const stderr = new PassThrough();
|
||||||
|
let n = 0;
|
||||||
|
stdout.write('Demo-Adapter: kein echter Container, die Ausgabe ist simuliert\n');
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
n += 1;
|
||||||
|
stdout.write(`[Demo] Server läuft (${handle.map ?? 'de_demo'}), Zeile ${n}\n`);
|
||||||
|
}, intervalMs);
|
||||||
|
const proc = { stdout, stderr, kill() { clearInterval(timer); stdout.end(); stderr.end(); proc.onExit?.(0); }, on(evt, fn) { if (evt === 'close') proc.onExit = fn; return proc; } };
|
||||||
|
return proc;
|
||||||
|
}
|
||||||
|
|
||||||
async sendCommands(handle, commands) {
|
async sendCommands(handle, commands) {
|
||||||
return commands.map(() => '(Demo – nicht ausgeführt)');
|
return commands.map(() => '(Demo – nicht ausgeführt)');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile, spawn } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||||
import { runRcon } from './rcon.js';
|
import { runRcon } from './rcon.js';
|
||||||
@@ -33,6 +33,39 @@ export function countSlots(host, used) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ereignisse des Docker-Hosts, auf Deutsch. exec_* (Healthchecks fremder Container) und Mount-Rauschen fallen weg,
|
||||||
|
// weil sie nichts aussagen und Befehlszeilen mit Zugangsdaten enthalten können.
|
||||||
|
const EVENT_LABELS = {
|
||||||
|
container: {
|
||||||
|
create: 'angelegt', start: 'gestartet', restart: 'neu gestartet', stop: 'gestoppt', kill: 'abgebrochen',
|
||||||
|
die: 'beendet', destroy: 'entfernt', pause: 'angehalten', unpause: 'fortgesetzt', oom: 'kein Speicher mehr',
|
||||||
|
'health_status: healthy': 'gesund', 'health_status: unhealthy': 'ungesund',
|
||||||
|
},
|
||||||
|
image: { pull: 'geladen', delete: 'gelöscht', untag: 'Markierung entfernt', tag: 'markiert' },
|
||||||
|
volume: { create: 'angelegt', destroy: 'gelöscht' },
|
||||||
|
};
|
||||||
|
const EVENT_TYPES = { container: 'Container', image: 'Image', volume: 'Volume' };
|
||||||
|
|
||||||
|
/** Eine JSON-Zeile von `docker events` lesbar machen. null = Ereignis wird nicht angezeigt. */
|
||||||
|
export function formatDockerEvent(line) {
|
||||||
|
let e;
|
||||||
|
try {
|
||||||
|
e = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = EVENT_LABELS[e.Type]?.[e.Action];
|
||||||
|
if (!label) return null;
|
||||||
|
const attrs = e.Actor?.Attributes ?? {};
|
||||||
|
const time = new Date((e.time ?? 0) * 1000).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
const name = attrs.name ?? e.Actor?.ID?.slice(0, 12) ?? '';
|
||||||
|
const app = attrs['cs2-mapveto'] === '1' ? ' [Mapveto]' : '';
|
||||||
|
const extra = e.Action === 'die' && attrs.exitCode != null ? ` (Exit-Code ${attrs.exitCode})`
|
||||||
|
: e.Action === 'kill' && attrs.signal ? ` (Signal ${attrs.signal})`
|
||||||
|
: e.Type === 'container' && attrs.image ? ` (${attrs.image})` : '';
|
||||||
|
return `${time} ${EVENT_TYPES[e.Type]} ${name}${app} ${label}${extra}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */
|
/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */
|
||||||
const PORT_BUSY = /port is already allocated|address already in use/i;
|
const PORT_BUSY = /port is already allocated|address already in use/i;
|
||||||
const DISK_CACHE_MS = 60_000;
|
const DISK_CACHE_MS = 60_000;
|
||||||
@@ -278,6 +311,18 @@ export class DockerServerAdapter {
|
|||||||
return runRcon(this.target(handle), commands);
|
return runRcon(this.target(handle), commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ereignisse des Docker-Hosts live (`docker events`), ab `sinceMinutes` in der Vergangenheit. Nur lesen. */
|
||||||
|
streamEvents({ sinceMinutes = 120 } = {}) {
|
||||||
|
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
||||||
|
return spawn(this.options.bin, ['events', '--since', `${Math.max(0, sinceMinutes)}m`, '--format', '{{json .}}'], { env, windowsHide: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Konsolenausgabe des Containers live: `docker logs --follow`. Liefert den Kindprozess (stdout/stderr, kill). */
|
||||||
|
streamLogs(handle, { tail = 200 } = {}) {
|
||||||
|
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
||||||
|
return spawn(this.options.bin, ['logs', '--follow', '--tail', String(tail), handle.container], { env, windowsHide: true });
|
||||||
|
}
|
||||||
|
|
||||||
async stop(handle) {
|
async stop(handle) {
|
||||||
await this.docker(['rm', '-f', handle.container]).catch(() => {});
|
await this.docker(['rm', '-f', handle.container]).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -474,6 +519,15 @@ export class DockerHostPool {
|
|||||||
return this.adapterFor(handle).sendCommands(handle, commands);
|
return this.adapterFor(handle).sendCommands(handle, commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
streamLogs(handle, options) {
|
||||||
|
return this.adapterFor(handle).streamLogs(handle, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ereignisse eines Hosts nach Namen; null, wenn es den Host nicht gibt. */
|
||||||
|
streamEvents(name, options) {
|
||||||
|
return this.adapters.get(name)?.streamEvents(options) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async stop(handle) {
|
async stop(handle) {
|
||||||
return this.adapterFor(handle).stop(handle);
|
return this.adapterFor(handle).stop(handle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -690,3 +690,26 @@ test('Chat: Match-Chat für beide Teams und Organisator, Team-Chat nur fürs eig
|
|||||||
await close();
|
await close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
test('Konsole: nur für Admins, Ausgabe kommt live, unbekannte Server 404', async () => {
|
||||||
|
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
|
||||||
|
try {
|
||||||
|
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
|
||||||
|
const m = (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' } })).body;
|
||||||
|
await waitFor(() => app.service.serverRow(m.id)?.handle, { label: 'Server gestartet' });
|
||||||
|
const base = `http://127.0.0.1:${app.server.address().port}/api/admin/logs`;
|
||||||
|
|
||||||
|
assert.equal((await fetch(`${base}/match/${m.id}`)).status, 401);
|
||||||
|
assert.equal((await fetch(`${base}/match/gibtsnicht`, { headers: { authorization: `Bearer ${token}` } })).status, 404);
|
||||||
|
assert.equal((await fetch(`${base}/sonstwas/${m.id}`, { headers: { authorization: `Bearer ${token}` } })).status, 404);
|
||||||
|
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const res = await fetch(`${base}/match/${m.id}`, { headers: { authorization: `Bearer ${token}` }, signal: ctl.signal });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const { value } = await reader.read();
|
||||||
|
assert.match(new TextDecoder().decode(value), /Demo-Adapter/);
|
||||||
|
ctl.abort();
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { formatDockerEvent } from '../src/servers/dockerAdapter.js';
|
||||||
|
|
||||||
|
const ev = (o) => JSON.stringify({ time: 1793549403, ...o });
|
||||||
|
|
||||||
|
test('Docker-Ereignisse werden lesbar, App-Container markiert', () => {
|
||||||
|
const start = formatDockerEvent(ev({ Type: 'container', Action: 'start', Actor: { ID: 'abc', Attributes: { name: 'cs2mv-knife1', image: 'joedwards32/cs2:latest', 'cs2-mapveto': '1' } } }));
|
||||||
|
assert.match(start, /Container cs2mv-knife1 \[Mapveto\] gestartet \(joedwards32\/cs2:latest\)$/);
|
||||||
|
const die = formatDockerEvent(ev({ Type: 'container', Action: 'die', Actor: { ID: 'abc', Attributes: { name: 'gitea', exitCode: '137' } } }));
|
||||||
|
assert.match(die, /Container gitea beendet \(Exit-Code 137\)$/);
|
||||||
|
assert.match(formatDockerEvent(ev({ Type: 'image', Action: 'pull', Actor: { ID: 'joedwards32/cs2:latest', Attributes: { name: 'joedwards32/cs2' } } })), /Image joedwards32\/cs2 geladen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Healthchecks, Mounts und kaputte Zeilen fallen weg', () => {
|
||||||
|
assert.equal(formatDockerEvent(ev({ Type: 'container', Action: 'exec_start: /bin/sh -c mysql --password=geheim', Actor: { Attributes: {} } })), null);
|
||||||
|
assert.equal(formatDockerEvent(ev({ Type: 'volume', Action: 'mount', Actor: { Attributes: {} } })), null);
|
||||||
|
assert.equal(formatDockerEvent('kein json'), null);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user