Files
Cs2-Mapveto/client/src/components/ConsoleDialog.jsx
T

89 lines
3.9 KiB
React

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>
);
}