/** * LogTerminal — admin-only live view of the backend log relay. * * Polls GET /api/logs?since= 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 ( ); }