From ee9c488a7e11121e70a2fe87043133da51a315e8 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Sun, 1 Nov 2026 17:16:58 +0100 Subject: [PATCH] =?UTF-8?q?Admin:=20Live-Konsole=20der=20Container=20und?= =?UTF-8?q?=20Live-Log=20der=20Docker-Hosts=20(nur=20lesen,=20Passw=C3=B6r?= =?UTF-8?q?ter=20ausgeblendet)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + client/src/components/ConsoleDialog.jsx | 88 +++++++++++++++++++++++++ client/src/pages/AdminPage.jsx | 24 ++++++- client/src/styles.css | 17 +++++ server/src/adminRoutes.js | 88 +++++++++++++++++++++++++ server/src/servers/demoAdapter.js | 15 +++++ server/src/servers/dockerAdapter.js | 56 +++++++++++++++- server/test/api.test.js | 23 +++++++ server/test/dockerEvents.test.js | 19 ++++++ 9 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 client/src/components/ConsoleDialog.jsx create mode 100644 server/test/dockerEvents.test.js diff --git a/README.md b/README.md index 357b393..89b2513 100644 --- a/README.md +++ b/README.md @@ -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. - **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. +- **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. - **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“. diff --git a/client/src/components/ConsoleDialog.jsx b/client/src/components/ConsoleDialog.jsx new file mode 100644 index 0000000..d199fb2 --- /dev/null +++ b/client/src/components/ConsoleDialog.jsx @@ -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 ( +
+
e.stopPropagation()}> +
+ Konsole: {title} + {status === 'live' ? 'live' : status} + +
+
+ setFilter(e.target.value)} placeholder="Zeilen filtern" aria-label="Zeilen filtern" /> + + + + {shown.length} Zeilen +
+
 {
+          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.'}
+        
+

{note ?? 'Passwörter sind ausgeblendet.'} Die Ansicht zeigt die letzten {MAX_LINES} Zeilen.

+
+
+ ); +} diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index 544d6be..57660d2 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -4,6 +4,7 @@ import { api } from '../lib/api.js'; import { FORMAT_LABEL, serverLabel } from '../lib/labels.js'; import CopyField from '../components/CopyField.jsx'; import MapImage from '../components/MapImage.jsx'; +import ConsoleDialog from '../components/ConsoleDialog.jsx'; const KEY = 'cs2mv:admin'; const FILTER_KEY = 'cs2mv:admin:filter'; @@ -327,6 +328,7 @@ function RollbackPanel({ matchId, token, reload, setNotice }) { function MatchAdminCard({ m, run, busy, token, reload, setNotice }) { const [open, setOpen] = useState(false); + const [showConsole, setShowConsole] = useState(false); const server = serverLabel(m.server); const closed = ['finished', 'cancelled'].includes(m.status); const id = m.id; @@ -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 && btn('Server neu starten / wiederholen', { path: '/server/retry' })} {m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })} + {m.server && m.server.status !== 'stopped' && ( + + )} {!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })} {!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')} {closed && !m.archivedAt && btn('Archivieren', { path: '/archive', success: 'Match archiviert' })} @@ -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')} + {showConsole && ( + setShowConsole(false)} /> + )} + {open && (
@@ -488,14 +497,19 @@ function HostUsageLive({ token }) { clearInterval(t); }; }, [token]); - return ; + return ; } /** 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; return (
+ {logFor && ( + setLogFor(null)} /> + )}

Docker-Hosts

{hosts.map((h) => { @@ -506,6 +520,7 @@ function HostUsage({ hosts }) { {h.name} {h.publicHost} {h.used} / {h.total} belegt + {token && }
@@ -831,6 +846,7 @@ const KNIFE_STATUS = { /** Messer-Server für die Seitenwahl: laufen dauerhaft, setzen sich nach jeder Messerrunde selbst zurück. */ function KnifePanel({ token, setNotice }) { const [data, setData] = useState(null); + const [consoleFor, setConsoleFor] = useState(null); const [mode, setMode] = useState(''); const [map, setMap] = useState(''); const [busy, setBusy] = useState(false); @@ -904,6 +920,7 @@ function KnifePanel({ token, setNotice }) { +
@@ -911,6 +928,9 @@ function KnifePanel({ token, setNotice }) { })}
)} + {consoleFor && ( + setConsoleFor(null)} /> + )}