89 lines
3.9 KiB
React
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>
|
|
);
|
|
}
|