Spaces:
Running
Running
File size: 5,555 Bytes
98f0a17 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | /**
* 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>
);
} |