Merge feature/container-konsole in main
This commit is contained in:
commit
2c57b8c170
9 files changed
+328
-3
No files matched your search
@@ -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 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' && (
|
||||
<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 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')}
|
||||
</div>
|
||||
|
||||
{showConsole && (
|
||||
<ConsoleDialog title={`${m.teams.A.name} vs. ${m.teams.B.name}`} path={`/admin/logs/match/${id}`} token={token} onClose={() => setShowConsole(false)} />
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="admin-details">
|
||||
<div className="admin-grid">
|
||||
@@ -488,14 +497,19 @@ function HostUsageLive({ token }) {
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [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. */
|
||||
function HostUsage({ hosts }) {
|
||||
function HostUsage({ hosts, token }) {
|
||||
const [logFor, setLogFor] = useState(null);
|
||||
if (hosts.length === 0) return null;
|
||||
return (
|
||||
<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>
|
||||
<div className="host-grid">
|
||||
{hosts.map((h) => {
|
||||
@@ -506,6 +520,7 @@ function HostUsage({ hosts }) {
|
||||
<strong>{h.name}</strong>
|
||||
<span className="muted small">{h.publicHost}</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 className="host-bar" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
|
||||
<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. */
|
||||
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 }) {
|
||||
<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} 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -911,6 +928,9 @@ function KnifePanel({ token, setNotice }) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{consoleFor && (
|
||||
<ConsoleDialog title={consoleFor.name} path={`/admin/logs/knife/${consoleFor.id}`} token={token} onClose={() => setConsoleFor(null)} />
|
||||
)}
|
||||
<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}>
|
||||
{data.modes.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
|
||||
@@ -752,3 +752,20 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
||||
.chat-form input { flex: 1; min-width: 0; }
|
||||
.chat-closed { margin: 0; padding: 10px 16px; border-top: 1px solid var(--line); }
|
||||
.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; }
|
||||
}
|
||||
Reference in new issue
Block a user