valhalla / frontend /src /components /LogTerminal.jsx
Dontcryx_07
terminal
98f0a17
Raw
History Blame Contribute Delete
5.56 kB
/**
* LogTerminal — admin-only live view of the backend log relay.
*
* Polls GET /api/logs?since=<cursor> with the admin bearer token and
* renders the captured logger output as a read-only terminal.
*
* Architecture: rendered by App.jsx only for authenticated users; toggled
* by the TERMINAL button in InfoBar.
*
* Design: 2s REST polling reuses the existing bearer-token flow instead of
* adding WebSocket authentication; auto-scroll halts while the pointer is
* over the terminal so lines can be read mid-stream.
*/
import { useEffect, useRef, useState, useCallback } from "react";
import { useAuth } from "../hooks/useAuth";
import { apiUrl } from "../utils/api";
const POLL_MS = 2000;
const LEVEL_COLORS = {
DEBUG: "#6b6b78",
INFO: "#8fbbe8",
WARNING: "#e7bd70",
ERROR: "#ff8b8b",
CRITICAL: "#ff6b6b",
};
export default function LogTerminal({ onClose }) {
const { token } = useAuth();
const [lines, setLines] = useState([]);
const [cursor, setCursor] = useState(0);
const [error, setError] = useState(null);
const [paused, setPaused] = useState(false);
const bodyRef = useRef(null);
const pausedRef = useRef(false);
const fetchNew = useCallback(async () => {
try {
const res = await fetch(apiUrl(`/api/logs?since=${cursor}`), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Log fetch failed (${res.status})`);
const data = await res.json();
if (data.lines?.length) {
setLines((previous) => [...previous.slice(-2000), ...data.lines]);
}
if (typeof data.next === "number") setCursor(data.next);
setError(null);
} catch (err) {
setError(err.message);
}
}, [cursor, token]);
useEffect(() => {
fetchNew();
const timer = setInterval(fetchNew, POLL_MS);
return () => clearInterval(timer);
}, [fetchNew]);
useEffect(() => {
const el = bodyRef.current;
if (el && !pausedRef.current) el.scrollTop = el.scrollHeight;
}, [lines]);
async function clearLogs() {
try {
const res = await fetch(apiUrl("/api/logs/clear"), {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Clear failed (${res.status})`);
setLines([]);
} catch (err) {
setError(err.message);
}
}
async function copyLogs() {
try {
await navigator.clipboard.writeText(lines.map((line) => line.text).join("\n"));
} catch {
/* clipboard unavailable — ignore */
}
}
const headerButton = {
background: "rgba(91,155,213,.08)",
border: "1px solid rgba(91,155,213,.30)",
borderRadius: 3,
color: "#8fbbe8",
padding: "2px 6px",
fontFamily: "'Space Mono', monospace",
fontSize: 8,
cursor: "pointer",
};
return (
<aside
style={{
position: "fixed",
right: 16,
top: 66,
bottom: 96,
width: 460,
maxWidth: "calc(100vw - 32px)",
display: "flex",
flexDirection: "column",
background: "rgba(8,10,12,0.95)",
backdropFilter: "blur(8px)",
border: "1px solid rgba(91,155,213,0.30)",
borderRadius: 6,
boxShadow: "0 14px 44px rgba(0,0,0,0.6)",
fontFamily: "'Space Mono', monospace",
zIndex: 1300,
}}
>
<header
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderBottom: "1px solid rgba(255,255,255,0.07)",
}}
>
<span style={{ color: "#8fbbe8", fontWeight: 700, fontSize: 10, letterSpacing: "0.14em" }}>
LOG TERMINAL
</span>
<span style={{ fontSize: 9, color: "#6b6b78" }}>{lines.length} lines</span>
<span style={{ flex: 1 }} />
<button onClick={copyLogs} style={headerButton} title="Copy all relayed lines to the clipboard">
COPY
</button>
<button onClick={clearLogs} style={headerButton} title="Clear the in-memory relay buffer">
CLEAR
</button>
<button onClick={onClose} style={headerButton} title="Close the log terminal">
X
</button>
</header>
<div
ref={bodyRef}
onMouseEnter={() => {
pausedRef.current = true;
setPaused(true);
}}
onMouseLeave={() => {
pausedRef.current = false;
setPaused(false);
}}
style={{
flex: 1,
overflowY: "auto",
padding: "10px 12px",
fontSize: 9,
lineHeight: 1.65,
}}
>
{lines.length === 0 && (
<div style={{ color: "#6b6b78" }}>no lines yet — the relay captures log output once the simulation starts</div>
)}
{lines.map((entry) => (
<div
key={entry.seq}
style={{
color: LEVEL_COLORS[entry.level] || "#d0d0da",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{entry.text}
</div>
))}
</div>
<footer
style={{
padding: "6px 12px",
borderTop: "1px solid rgba(255,255,255,0.07)",
fontSize: 9,
color: error ? "#ff8b8b" : paused ? "#e7bd70" : "#6b6b78",
}}
>
{error ? `ERROR: ${error}` : paused ? "PAUSED — move the pointer away to resume auto-scroll" : "TAILING — 2s poll"}
</footer>
</aside>
);
}