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 ( +
{ + 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.