Admin: Live-Konsole der Container und Live-Log der Docker-Hosts (nur lesen, Passwörter ausgeblendet)
This commit is contained in:
1 parent
fb96fb5d08
commit
ee9c488a7e
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user