Spaces:
Running
Running
| /** | |
| * 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> | |
| ); | |
| } |