Skip to content

Commit dfdb999

Browse files
committed
feat: add live log viewer to dashboard and /api/logs endpoint
Reads the last 80 lines of bot.log (auto-refreshes with the live tab every 60s). Also available via GET /api/logs?lines=N (max 500). Checks systemd log path first, falls back to local live_bot.log.
1 parent 5240684 commit dfdb999

1 file changed

Lines changed: 73 additions & 0 deletions

File tree

python/monitoring/dashboard.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- ``GET /api/risk`` — full risk engine summary from backtest
1717
- ``GET /api/drift`` — latest feature drift report
1818
- ``GET /api/bot`` — bot state (last trade, shutdown reason, etc.)
19+
- ``GET /api/logs`` — latest bot log lines (?lines=N, default 80, max 500)
1920
2021
Public API (unchanged):
2122
- ``create_dashboard(...)`` — standalone backtest-only app
@@ -41,6 +42,9 @@
4142

4243
RESULTS_DIR = Path("data/processed")
4344
STATE_FILE = Path("data/bot_state.json")
45+
BOT_LOG_FILE = Path(os.getenv("LOG_DIR", "/var/log/signum")) / "bot.log"
46+
# Fallback: check for local log file when not on VPS
47+
_LOCAL_LOG = Path("live_bot.log")
4448

4549
# ═══════════════════════════════════════════════════════════════════
4650
# Design Tokens
@@ -1022,6 +1026,35 @@ def _build_live_tab() -> html.Div:
10221026
else:
10231027
children.append(_panel(_empty_state("No trading history yet.")))
10241028

1029+
# ── Bot Log ──
1030+
children.append(html.Div(style={"height": _SP_5}))
1031+
children.append(_section_label("Bot Log"))
1032+
1033+
log_text = _read_log_tail(80)
1034+
children.append(
1035+
_panel(
1036+
html.Pre(
1037+
log_text,
1038+
style={
1039+
"fontFamily": _FONT_MONO,
1040+
"fontSize": "11px",
1041+
"lineHeight": "1.6",
1042+
"color": _TEXT_SECONDARY,
1043+
"backgroundColor": _INSET,
1044+
"padding": _SP_4,
1045+
"borderRadius": _RADIUS_SM,
1046+
"border": f"1px solid {_BORDER}",
1047+
"margin": "0",
1048+
"maxHeight": "500px",
1049+
"overflowY": "auto",
1050+
"whiteSpace": "pre-wrap",
1051+
"wordBreak": "break-all",
1052+
},
1053+
),
1054+
padding=_SP_3,
1055+
)
1056+
)
1057+
10251058
return html.Div(children)
10261059

10271060

@@ -1340,6 +1373,29 @@ def _load_equity_history() -> pd.Series | None:
13401373
return None
13411374

13421375

1376+
def _read_log_tail(n_lines: int = 80) -> str:
1377+
"""Read the last N lines from the bot log file.
1378+
1379+
Checks the systemd log path first, then the local log fallback.
1380+
"""
1381+
for log_path in [BOT_LOG_FILE, _LOCAL_LOG]:
1382+
if log_path.exists():
1383+
try:
1384+
with open(log_path, "rb") as f:
1385+
# Seek from end to find last N lines efficiently
1386+
f.seek(0, 2)
1387+
size = f.tell()
1388+
# Read last 64KB max (enough for ~80 lines)
1389+
chunk_size = min(size, 65536)
1390+
f.seek(max(0, size - chunk_size))
1391+
data = f.read().decode("utf-8", errors="replace")
1392+
lines = data.splitlines()
1393+
return "\n".join(lines[-n_lines:])
1394+
except Exception as e:
1395+
logger.warning(f"Could not read log {log_path}: {e}")
1396+
return "No log file found. Bot may not have started yet."
1397+
1398+
13431399
def _fetch_regime_state() -> dict | None:
13441400
"""Fetch current market regime (VIX + SPY drawdown)."""
13451401
try:
@@ -1384,6 +1440,7 @@ def _fetch_regime_state() -> dict | None:
13841440
"/api/risk": "Full risk engine summary (Sharpe, VaR, drawdowns, etc.)",
13851441
"/api/drift": "Latest ML feature drift report (KS stat, PSI per feature)",
13861442
"/api/bot": "Bot state (last trade date, shutdown reason, positions count)",
1443+
"/api/logs": "Latest bot log lines (default 80, ?lines=N to customize)",
13871444
}
13881445

13891446

@@ -1605,6 +1662,22 @@ def api_bot():
16051662
)
16061663
return _json_response({"timestamp": datetime.now().isoformat(), "bot_state": state})
16071664

1665+
# ── GET /api/logs — bot log tail ──
1666+
@server.route("/api/logs")
1667+
def api_logs():
1668+
from flask import request
1669+
1670+
n_lines = min(int(request.args.get("lines", 80)), 500)
1671+
log_text = _read_log_tail(n_lines)
1672+
lines = log_text.splitlines()
1673+
return _json_response(
1674+
{
1675+
"timestamp": datetime.now().isoformat(),
1676+
"n_lines": len(lines),
1677+
"log": lines,
1678+
}
1679+
)
1680+
16081681

16091682
def _fetch_account_json() -> dict | None:
16101683
"""Fetch Alpaca account data as a dict. Returns None on failure."""

0 commit comments

Comments
 (0)