Spaces:
Running
Running
Dontcryx_07 commited on
Commit Β·
98f0a17
1
Parent(s): d660fa9
terminal
Browse files- backend/Odin.py +24 -0
- backend/src/core/log.py +14 -3
- backend/src/core/log_relay.py +82 -0
- frontend/src/App.jsx +5 -0
- frontend/src/components/InfoBar.jsx +15 -6
- frontend/src/components/LogTerminal.jsx +189 -0
backend/Odin.py
CHANGED
|
@@ -235,6 +235,30 @@ async def require_admin(authorization: str = Header(None)):
|
|
| 235 |
return user
|
| 236 |
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
def _print_agent_plans(engine):
|
| 239 |
"""Print each agent's full action plan to the CLI."""
|
| 240 |
from src.core.agent_registry import AgentRuntimeState
|
|
|
|
| 235 |
return user
|
| 236 |
|
| 237 |
|
| 238 |
+
# ---------------------------------------------------------------------------
|
| 239 |
+
# Admin log relay β live view of backend logs without the Space console.
|
| 240 |
+
# The relay mirror lives in src/core/log_relay.py and is installed by
|
| 241 |
+
# src/core/log.py setup_logging(); only these two endpoints expose it.
|
| 242 |
+
# ---------------------------------------------------------------------------
|
| 243 |
+
|
| 244 |
+
@app.get("/api/logs")
|
| 245 |
+
async def get_log_lines(
|
| 246 |
+
since: int = Query(default=0, ge=0),
|
| 247 |
+
_user: dict = Depends(require_admin),
|
| 248 |
+
):
|
| 249 |
+
"""Return relayed log lines newer than `since`; `next` is the poll cursor."""
|
| 250 |
+
from src.core.log_relay import relay_lines
|
| 251 |
+
return relay_lines(since)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@app.post("/api/logs/clear")
|
| 255 |
+
async def clear_log_lines(_user: dict = Depends(require_admin)):
|
| 256 |
+
"""Clear the in-memory relay buffer. The live log file is left intact."""
|
| 257 |
+
from src.core.log_relay import clear_relay
|
| 258 |
+
clear_relay()
|
| 259 |
+
return {"ok": True}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
def _print_agent_plans(engine):
|
| 263 |
"""Print each agent's full action plan to the CLI."""
|
| 264 |
from src.core.agent_registry import AgentRuntimeState
|
backend/src/core/log.py
CHANGED
|
@@ -5,7 +5,9 @@ and level stay consistent across the engine, planner, and server.
|
|
| 5 |
|
| 6 |
Architecture: a dependency of every backend module; no business logic.
|
| 7 |
Design: logs land in backend/output/logs with timestamps for post-hoc
|
| 8 |
-
analysis of long runs.
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
|
@@ -37,7 +39,7 @@ def _build_log_path(run_id: Optional[str] = None) -> Path:
|
|
| 37 |
def setup_logging(
|
| 38 |
level: int = DEFAULT_LEVEL,
|
| 39 |
run_id: Optional[str] = None,
|
| 40 |
-
console: bool =
|
| 41 |
file: bool = True,
|
| 42 |
max_bytes: int = 5 * 1024 * 1024,
|
| 43 |
backup_count: int = 5,
|
|
@@ -53,7 +55,8 @@ def setup_logging(
|
|
| 53 |
run_id -- optional tag folded into the log filename, e.g. a
|
| 54 |
persona name or simulation id, so a run's logs are
|
| 55 |
easy to find in output/logs/
|
| 56 |
-
console -- also stream logs to stdout
|
|
|
|
| 57 |
file -- also write logs to output/logs/<timestamp>[_<run_id>].log
|
| 58 |
max_bytes /
|
| 59 |
backup_count -- rotation settings for the file handler
|
|
@@ -113,6 +116,14 @@ def setup_logging(
|
|
| 113 |
logging.config.dictConfig(config)
|
| 114 |
_configured = True
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
if log_path:
|
| 117 |
logging.getLogger(__name__).info("[log] logging initialized -> %s", log_path)
|
| 118 |
|
|
|
|
| 5 |
|
| 6 |
Architecture: a dependency of every backend module; no business logic.
|
| 7 |
Design: logs land in backend/output/logs with timestamps for post-hoc
|
| 8 |
+
analysis of long runs. Console output is off by default β the admin log
|
| 9 |
+
terminal (src/core/log_relay.py) mirrors every line to the live log file
|
| 10 |
+
and the admin-only /api/logs endpoints instead.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 39 |
def setup_logging(
|
| 40 |
level: int = DEFAULT_LEVEL,
|
| 41 |
run_id: Optional[str] = None,
|
| 42 |
+
console: bool = False,
|
| 43 |
file: bool = True,
|
| 44 |
max_bytes: int = 5 * 1024 * 1024,
|
| 45 |
backup_count: int = 5,
|
|
|
|
| 55 |
run_id -- optional tag folded into the log filename, e.g. a
|
| 56 |
persona name or simulation id, so a run's logs are
|
| 57 |
easy to find in output/logs/
|
| 58 |
+
console -- also stream logs to stdout (default off; watch the
|
| 59 |
+
admin log terminal instead)
|
| 60 |
file -- also write logs to output/logs/<timestamp>[_<run_id>].log
|
| 61 |
max_bytes /
|
| 62 |
backup_count -- rotation settings for the file handler
|
|
|
|
| 116 |
logging.config.dictConfig(config)
|
| 117 |
_configured = True
|
| 118 |
|
| 119 |
+
# Mirror every emitted line into the admin log terminal's buffer and the
|
| 120 |
+
# live log file, regardless of the console/file toggles above.
|
| 121 |
+
try:
|
| 122 |
+
from src.core.log_relay import install_relay
|
| 123 |
+
install_relay()
|
| 124 |
+
except Exception:
|
| 125 |
+
pass
|
| 126 |
+
|
| 127 |
if log_path:
|
| 128 |
logging.getLogger(__name__).info("[log] logging initialized -> %s", log_path)
|
| 129 |
|
backend/src/core/log_relay.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""log_relay β mirror of every project log line for the admin log terminal.
|
| 2 |
+
|
| 3 |
+
Attaches a second handler to the ROOT logger: each emitted record is
|
| 4 |
+
formatted with the project's standard format and appended to a bounded
|
| 5 |
+
in-memory ring buffer (polled by the admin-only /api/logs endpoints) and
|
| 6 |
+
to a live file backend/output/logs/live.log, so the same output also
|
| 7 |
+
survives restarts and stays viewable without the Space console.
|
| 8 |
+
|
| 9 |
+
Architecture: installed once by src/core/log.py setup_logging(); consumed
|
| 10 |
+
by Odin.py's admin-gated endpoints and the frontend LogTerminal panel.
|
| 11 |
+
Design: stdlib-only, a one-way mirror β never changes existing handlers,
|
| 12 |
+
levels, or the file rotation policy.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import threading
|
| 19 |
+
from collections import deque
|
| 20 |
+
from itertools import count
|
| 21 |
+
|
| 22 |
+
from src.config import LOG_DIR
|
| 23 |
+
from src.core.log import DATE_FORMAT, LOG_FORMAT
|
| 24 |
+
|
| 25 |
+
MAX_LINES = 2000
|
| 26 |
+
LIVE_LOG_PATH = LOG_DIR / "live.log"
|
| 27 |
+
|
| 28 |
+
_seq = count(1)
|
| 29 |
+
_buffer: deque = deque(maxlen=MAX_LINES)
|
| 30 |
+
_latest_seq = 0
|
| 31 |
+
_lock = threading.Lock()
|
| 32 |
+
_installed = False
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class LogRelayHandler(logging.Handler):
|
| 36 |
+
"""Appends each formatted record to the ring buffer and the live file."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, level: int = logging.NOTSET) -> None:
|
| 39 |
+
super().__init__(level)
|
| 40 |
+
self._formatter = logging.Formatter(LOG_FORMAT, DATE_FORMAT)
|
| 41 |
+
|
| 42 |
+
def emit(self, record: logging.LogRecord) -> None:
|
| 43 |
+
global _latest_seq
|
| 44 |
+
try:
|
| 45 |
+
text = self._formatter.format(record)
|
| 46 |
+
except Exception:
|
| 47 |
+
text = f"{record.name} | {record.getMessage()}"
|
| 48 |
+
with _lock:
|
| 49 |
+
_latest_seq = next(_seq)
|
| 50 |
+
_buffer.append({"seq": _latest_seq, "level": record.levelname, "text": text})
|
| 51 |
+
try:
|
| 52 |
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 53 |
+
with open(LIVE_LOG_PATH, "a", encoding="utf-8") as fh:
|
| 54 |
+
fh.write(text + "\n")
|
| 55 |
+
except OSError:
|
| 56 |
+
pass
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def install_relay() -> None:
|
| 60 |
+
"""Attach the relay handler to the root logger exactly once."""
|
| 61 |
+
global _installed
|
| 62 |
+
if _installed:
|
| 63 |
+
return
|
| 64 |
+
logging.getLogger().addHandler(LogRelayHandler())
|
| 65 |
+
_installed = True
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def relay_lines(since: int = 0) -> dict:
|
| 69 |
+
"""Return relayed entries with seq > since, plus the latest seq as cursor.
|
| 70 |
+
|
| 71 |
+
The cursor tracks the last emitted sequence, so polling stays
|
| 72 |
+
incremental even after the buffer is cleared.
|
| 73 |
+
"""
|
| 74 |
+
with _lock:
|
| 75 |
+
lines = [entry for entry in _buffer if entry["seq"] > since]
|
| 76 |
+
return {"lines": lines, "next": _latest_seq}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def clear_relay() -> None:
|
| 80 |
+
"""Drop the in-memory buffer (the live file is intentionally kept)."""
|
| 81 |
+
with _lock:
|
| 82 |
+
_buffer.clear()
|
frontend/src/App.jsx
CHANGED
|
@@ -23,6 +23,7 @@ import EventsPanel from "./components/EventsPanel";
|
|
| 23 |
import DebugPanel from "./components/DebugPanel";
|
| 24 |
import RosterManager from "./components/RosterManager";
|
| 25 |
import LoginButton from "./components/LoginButton";
|
|
|
|
| 26 |
import { apiUrl } from "./utils/api";
|
| 27 |
import "./App.css";
|
| 28 |
|
|
@@ -56,6 +57,7 @@ function AppContent() {
|
|
| 56 |
const [controlError, setControlError] = useState(null);
|
| 57 |
const [simulationRunning, setSimulationRunning] = useState(true);
|
| 58 |
const [rosterOpen, setRosterOpen] = useState(false);
|
|
|
|
| 59 |
|
| 60 |
useEffect(() => {
|
| 61 |
if (!snapshot) return;
|
|
@@ -153,6 +155,7 @@ function AppContent() {
|
|
| 153 |
<EventsPanel events={snapshot?.events} />
|
| 154 |
{showDebug && <DebugPanel health={snapshot?.health} />}
|
| 155 |
<RosterManager open={rosterOpen} onClose={() => setRosterOpen(false)} simulationRunning={simulationRunning} onError={setControlError} isAuthenticated={isAuthenticated} />
|
|
|
|
| 156 |
|
| 157 |
{agentIds.map((id, index) => {
|
| 158 |
const expanded = expandedAgentIds.has(id);
|
|
@@ -185,6 +188,8 @@ function AppContent() {
|
|
| 185 |
}}
|
| 186 |
onToggleRoster={() => setRosterOpen((value) => !value)}
|
| 187 |
isAuthenticated={isAuthenticated}
|
|
|
|
|
|
|
| 188 |
/>
|
| 189 |
<LoginButton />
|
| 190 |
</div>
|
|
|
|
| 23 |
import DebugPanel from "./components/DebugPanel";
|
| 24 |
import RosterManager from "./components/RosterManager";
|
| 25 |
import LoginButton from "./components/LoginButton";
|
| 26 |
+
import LogTerminal from "./components/LogTerminal";
|
| 27 |
import { apiUrl } from "./utils/api";
|
| 28 |
import "./App.css";
|
| 29 |
|
|
|
|
| 57 |
const [controlError, setControlError] = useState(null);
|
| 58 |
const [simulationRunning, setSimulationRunning] = useState(true);
|
| 59 |
const [rosterOpen, setRosterOpen] = useState(false);
|
| 60 |
+
const [showTerminal, setShowTerminal] = useState(false);
|
| 61 |
|
| 62 |
useEffect(() => {
|
| 63 |
if (!snapshot) return;
|
|
|
|
| 155 |
<EventsPanel events={snapshot?.events} />
|
| 156 |
{showDebug && <DebugPanel health={snapshot?.health} />}
|
| 157 |
<RosterManager open={rosterOpen} onClose={() => setRosterOpen(false)} simulationRunning={simulationRunning} onError={setControlError} isAuthenticated={isAuthenticated} />
|
| 158 |
+
{isAuthenticated && showTerminal && <LogTerminal onClose={() => setShowTerminal(false)} />}
|
| 159 |
|
| 160 |
{agentIds.map((id, index) => {
|
| 161 |
const expanded = expandedAgentIds.has(id);
|
|
|
|
| 188 |
}}
|
| 189 |
onToggleRoster={() => setRosterOpen((value) => !value)}
|
| 190 |
isAuthenticated={isAuthenticated}
|
| 191 |
+
terminalOpen={showTerminal}
|
| 192 |
+
onToggleTerminal={() => setShowTerminal((value) => !value)}
|
| 193 |
/>
|
| 194 |
<LoginButton />
|
| 195 |
</div>
|
frontend/src/components/InfoBar.jsx
CHANGED
|
@@ -13,7 +13,7 @@
|
|
| 13 |
|
| 14 |
import { useState } from "react";
|
| 15 |
|
| 16 |
-
export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForward, onSlowDown, onRewind, simulationRunning, onToggleSimulation, onToggleRoster, isAuthenticated }) {
|
| 17 |
const [rewindAmount, setRewindAmount] = useState("10");
|
| 18 |
const [rewindUnit, setRewindUnit] = useState("ticks");
|
| 19 |
if (!snapshot) return null;
|
|
@@ -140,11 +140,20 @@ export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForw
|
|
| 140 |
{showDebug ? "DEBUG ON" : "DEBUG"}
|
| 141 |
</button>
|
| 142 |
{isAuthenticated && (
|
| 143 |
-
<
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
)}
|
| 149 |
</div>
|
| 150 |
);
|
|
|
|
| 13 |
|
| 14 |
import { useState } from "react";
|
| 15 |
|
| 16 |
+
export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForward, onSlowDown, onRewind, simulationRunning, onToggleSimulation, onToggleRoster, isAuthenticated, terminalOpen, onToggleTerminal }) {
|
| 17 |
const [rewindAmount, setRewindAmount] = useState("10");
|
| 18 |
const [rewindUnit, setRewindUnit] = useState("ticks");
|
| 19 |
if (!snapshot) return null;
|
|
|
|
| 140 |
{showDebug ? "DEBUG ON" : "DEBUG"}
|
| 141 |
</button>
|
| 142 |
{isAuthenticated && (
|
| 143 |
+
<>
|
| 144 |
+
<button onClick={onToggleRoster} style={{
|
| 145 |
+
pointerEvents: "auto", border: "1px solid rgba(212,160,74,.3)", borderRadius: 3,
|
| 146 |
+
background: "rgba(212,160,74,.08)", color: "#e7bd70", padding: "2px 5px",
|
| 147 |
+
fontFamily: "'Space Mono', monospace", fontSize: 8, cursor: "pointer",
|
| 148 |
+
}} title="Add, retire, or rename agents while the simulation is stopped">ROSTER</button>
|
| 149 |
+
<button onClick={onToggleTerminal} style={{
|
| 150 |
+
pointerEvents: "auto", border: "1px solid rgba(91,155,213,.3)", borderRadius: 3,
|
| 151 |
+
background: terminalOpen ? "rgba(91,155,213,.22)" : "transparent", color: "#8fbbe8", padding: "2px 5px",
|
| 152 |
+
fontFamily: "'Space Mono', monospace", fontSize: 8, cursor: "pointer",
|
| 153 |
+
}} title="Open the live backend log terminal">
|
| 154 |
+
{terminalOpen ? "TERMINAL ON" : "TERMINAL"}
|
| 155 |
+
</button>
|
| 156 |
+
</>
|
| 157 |
)}
|
| 158 |
</div>
|
| 159 |
);
|
frontend/src/components/LogTerminal.jsx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LogTerminal β admin-only live view of the backend log relay.
|
| 3 |
+
*
|
| 4 |
+
* Polls GET /api/logs?since=<cursor> with the admin bearer token and
|
| 5 |
+
* renders the captured logger output as a read-only terminal.
|
| 6 |
+
*
|
| 7 |
+
* Architecture: rendered by App.jsx only for authenticated users; toggled
|
| 8 |
+
* by the TERMINAL button in InfoBar.
|
| 9 |
+
*
|
| 10 |
+
* Design: 2s REST polling reuses the existing bearer-token flow instead of
|
| 11 |
+
* adding WebSocket authentication; auto-scroll halts while the pointer is
|
| 12 |
+
* over the terminal so lines can be read mid-stream.
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
import { useEffect, useRef, useState, useCallback } from "react";
|
| 16 |
+
import { useAuth } from "../hooks/useAuth";
|
| 17 |
+
import { apiUrl } from "../utils/api";
|
| 18 |
+
|
| 19 |
+
const POLL_MS = 2000;
|
| 20 |
+
const LEVEL_COLORS = {
|
| 21 |
+
DEBUG: "#6b6b78",
|
| 22 |
+
INFO: "#8fbbe8",
|
| 23 |
+
WARNING: "#e7bd70",
|
| 24 |
+
ERROR: "#ff8b8b",
|
| 25 |
+
CRITICAL: "#ff6b6b",
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
export default function LogTerminal({ onClose }) {
|
| 29 |
+
const { token } = useAuth();
|
| 30 |
+
const [lines, setLines] = useState([]);
|
| 31 |
+
const [cursor, setCursor] = useState(0);
|
| 32 |
+
const [error, setError] = useState(null);
|
| 33 |
+
const [paused, setPaused] = useState(false);
|
| 34 |
+
const bodyRef = useRef(null);
|
| 35 |
+
const pausedRef = useRef(false);
|
| 36 |
+
|
| 37 |
+
const fetchNew = useCallback(async () => {
|
| 38 |
+
try {
|
| 39 |
+
const res = await fetch(apiUrl(`/api/logs?since=${cursor}`), {
|
| 40 |
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
| 41 |
+
});
|
| 42 |
+
if (!res.ok) throw new Error(`Log fetch failed (${res.status})`);
|
| 43 |
+
const data = await res.json();
|
| 44 |
+
if (data.lines?.length) {
|
| 45 |
+
setLines((previous) => [...previous.slice(-2000), ...data.lines]);
|
| 46 |
+
}
|
| 47 |
+
if (typeof data.next === "number") setCursor(data.next);
|
| 48 |
+
setError(null);
|
| 49 |
+
} catch (err) {
|
| 50 |
+
setError(err.message);
|
| 51 |
+
}
|
| 52 |
+
}, [cursor, token]);
|
| 53 |
+
|
| 54 |
+
useEffect(() => {
|
| 55 |
+
fetchNew();
|
| 56 |
+
const timer = setInterval(fetchNew, POLL_MS);
|
| 57 |
+
return () => clearInterval(timer);
|
| 58 |
+
}, [fetchNew]);
|
| 59 |
+
|
| 60 |
+
useEffect(() => {
|
| 61 |
+
const el = bodyRef.current;
|
| 62 |
+
if (el && !pausedRef.current) el.scrollTop = el.scrollHeight;
|
| 63 |
+
}, [lines]);
|
| 64 |
+
|
| 65 |
+
async function clearLogs() {
|
| 66 |
+
try {
|
| 67 |
+
const res = await fetch(apiUrl("/api/logs/clear"), {
|
| 68 |
+
method: "POST",
|
| 69 |
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
| 70 |
+
});
|
| 71 |
+
if (!res.ok) throw new Error(`Clear failed (${res.status})`);
|
| 72 |
+
setLines([]);
|
| 73 |
+
} catch (err) {
|
| 74 |
+
setError(err.message);
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
async function copyLogs() {
|
| 79 |
+
try {
|
| 80 |
+
await navigator.clipboard.writeText(lines.map((line) => line.text).join("\n"));
|
| 81 |
+
} catch {
|
| 82 |
+
/* clipboard unavailable β ignore */
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
const headerButton = {
|
| 87 |
+
background: "rgba(91,155,213,.08)",
|
| 88 |
+
border: "1px solid rgba(91,155,213,.30)",
|
| 89 |
+
borderRadius: 3,
|
| 90 |
+
color: "#8fbbe8",
|
| 91 |
+
padding: "2px 6px",
|
| 92 |
+
fontFamily: "'Space Mono', monospace",
|
| 93 |
+
fontSize: 8,
|
| 94 |
+
cursor: "pointer",
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
return (
|
| 98 |
+
<aside
|
| 99 |
+
style={{
|
| 100 |
+
position: "fixed",
|
| 101 |
+
right: 16,
|
| 102 |
+
top: 66,
|
| 103 |
+
bottom: 96,
|
| 104 |
+
width: 460,
|
| 105 |
+
maxWidth: "calc(100vw - 32px)",
|
| 106 |
+
display: "flex",
|
| 107 |
+
flexDirection: "column",
|
| 108 |
+
background: "rgba(8,10,12,0.95)",
|
| 109 |
+
backdropFilter: "blur(8px)",
|
| 110 |
+
border: "1px solid rgba(91,155,213,0.30)",
|
| 111 |
+
borderRadius: 6,
|
| 112 |
+
boxShadow: "0 14px 44px rgba(0,0,0,0.6)",
|
| 113 |
+
fontFamily: "'Space Mono', monospace",
|
| 114 |
+
zIndex: 1300,
|
| 115 |
+
}}
|
| 116 |
+
>
|
| 117 |
+
<header
|
| 118 |
+
style={{
|
| 119 |
+
display: "flex",
|
| 120 |
+
alignItems: "center",
|
| 121 |
+
gap: 8,
|
| 122 |
+
padding: "8px 12px",
|
| 123 |
+
borderBottom: "1px solid rgba(255,255,255,0.07)",
|
| 124 |
+
}}
|
| 125 |
+
>
|
| 126 |
+
<span style={{ color: "#8fbbe8", fontWeight: 700, fontSize: 10, letterSpacing: "0.14em" }}>
|
| 127 |
+
LOG TERMINAL
|
| 128 |
+
</span>
|
| 129 |
+
<span style={{ fontSize: 9, color: "#6b6b78" }}>{lines.length} lines</span>
|
| 130 |
+
<span style={{ flex: 1 }} />
|
| 131 |
+
<button onClick={copyLogs} style={headerButton} title="Copy all relayed lines to the clipboard">
|
| 132 |
+
COPY
|
| 133 |
+
</button>
|
| 134 |
+
<button onClick={clearLogs} style={headerButton} title="Clear the in-memory relay buffer">
|
| 135 |
+
CLEAR
|
| 136 |
+
</button>
|
| 137 |
+
<button onClick={onClose} style={headerButton} title="Close the log terminal">
|
| 138 |
+
X
|
| 139 |
+
</button>
|
| 140 |
+
</header>
|
| 141 |
+
|
| 142 |
+
<div
|
| 143 |
+
ref={bodyRef}
|
| 144 |
+
onMouseEnter={() => {
|
| 145 |
+
pausedRef.current = true;
|
| 146 |
+
setPaused(true);
|
| 147 |
+
}}
|
| 148 |
+
onMouseLeave={() => {
|
| 149 |
+
pausedRef.current = false;
|
| 150 |
+
setPaused(false);
|
| 151 |
+
}}
|
| 152 |
+
style={{
|
| 153 |
+
flex: 1,
|
| 154 |
+
overflowY: "auto",
|
| 155 |
+
padding: "10px 12px",
|
| 156 |
+
fontSize: 9,
|
| 157 |
+
lineHeight: 1.65,
|
| 158 |
+
}}
|
| 159 |
+
>
|
| 160 |
+
{lines.length === 0 && (
|
| 161 |
+
<div style={{ color: "#6b6b78" }}>no lines yet β the relay captures log output once the simulation starts</div>
|
| 162 |
+
)}
|
| 163 |
+
{lines.map((entry) => (
|
| 164 |
+
<div
|
| 165 |
+
key={entry.seq}
|
| 166 |
+
style={{
|
| 167 |
+
color: LEVEL_COLORS[entry.level] || "#d0d0da",
|
| 168 |
+
whiteSpace: "pre-wrap",
|
| 169 |
+
wordBreak: "break-word",
|
| 170 |
+
}}
|
| 171 |
+
>
|
| 172 |
+
{entry.text}
|
| 173 |
+
</div>
|
| 174 |
+
))}
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<footer
|
| 178 |
+
style={{
|
| 179 |
+
padding: "6px 12px",
|
| 180 |
+
borderTop: "1px solid rgba(255,255,255,0.07)",
|
| 181 |
+
fontSize: 9,
|
| 182 |
+
color: error ? "#ff8b8b" : paused ? "#e7bd70" : "#6b6b78",
|
| 183 |
+
}}
|
| 184 |
+
>
|
| 185 |
+
{error ? `ERROR: ${error}` : paused ? "PAUSED β move the pointer away to resume auto-scroll" : "TAILING β 2s poll"}
|
| 186 |
+
</footer>
|
| 187 |
+
</aside>
|
| 188 |
+
);
|
| 189 |
+
}
|