diff --git a/research_ui/app.js b/research_ui/app.js index 4c11ebe..cb95c17 100644 --- a/research_ui/app.js +++ b/research_ui/app.js @@ -2,6 +2,7 @@ const CONFIG = { registryPath: "/outputs/runs/runs_index.json", launchControlPath: "/api/launch-control", paperHealthPath: "/api/paper-sessions-health", + paperAlertsPath: "/api/paper-sessions-alerts", brokerHealthPath: "/api/broker-submissions-health", hyperliquidSurfacePath: "/api/hyperliquid-surface", pretradeHandoffPath: "/api/pretrade-handoff-intake", @@ -24,6 +25,7 @@ const state = { detailCache: new Map(), isLoading: false, paperHealth: null, + paperAlerts: null, brokerHealth: null, hyperliquidSurface: null, pretradeIntake: null, @@ -81,6 +83,8 @@ const elements = { compareSummaryLong: document.getElementById("compare-summary-long"), compareBody: document.getElementById("compare-body"), opsSummary: document.getElementById("ops-summary"), + paperSummary: document.getElementById("paper-summary"), + paperPanelBody: document.getElementById("paper-panel-body"), pretradeSummary: document.getElementById("pretrade-summary"), pretradePanelBody: document.getElementById("pretrade-panel-body"), paperTotalSessions: document.getElementById("paper-total-sessions"), @@ -169,7 +173,7 @@ async function fetchAll(showNotice = false, silent = false) { try { const registryResponse = await fetchJson(CONFIG.registryPath); - const [launchControl, paperHealth, brokerHealth, hyperliquidSurface, pretradeIntake, stepbitWorkspace, metaTradeWorkspace] = await Promise.all([ + const [launchControl, paperHealth, paperAlerts, brokerHealth, hyperliquidSurface, pretradeIntake, stepbitWorkspace, metaTradeWorkspace] = await Promise.all([ fetchJsonSafe(CONFIG.launchControlPath, { status: "error", available: false, @@ -184,6 +188,15 @@ async function fetchAll(showNotice = false, silent = false) { status_counts: {}, message: "Paper health unavailable.", }), + fetchJsonSafe(CONFIG.paperAlertsPath, { + status: "error", + available: false, + total_sessions: 0, + status_counts: {}, + alert_counts: {}, + alerts: [], + message: "Paper alerts unavailable.", + }), fetchJsonSafe(CONFIG.brokerHealthPath, { status: "error", available: false, @@ -231,6 +244,7 @@ async function fetchAll(showNotice = false, silent = false) { state.generatedAt = registryResponse.generated_at || null; state.launchControl = launchControl; state.paperHealth = paperHealth; + state.paperAlerts = paperAlerts; state.brokerHealth = brokerHealth; state.hyperliquidSurface = hyperliquidSurface; state.pretradeIntake = pretradeIntake; @@ -688,6 +702,7 @@ function renderRuntimeChip(label, value, tone) { function renderOps() { const paperHealth = state.paperHealth || {}; + const paperAlerts = state.paperAlerts || {}; const brokerHealth = state.brokerHealth || {}; const hyperliquidSurface = state.hyperliquidSurface || {}; const pretradeIntake = state.pretradeIntake || {}; @@ -695,7 +710,9 @@ function renderOps() { const metaTradeWorkspace = state.metaTradeWorkspace || {}; elements.paperTotalSessions.textContent = String(paperHealth.total_sessions || 0); - elements.paperHealthMeta.textContent = buildPaperMeta(paperHealth); + elements.paperHealthMeta.textContent = buildPaperMeta(paperHealth, paperAlerts); + elements.paperSummary.textContent = buildPaperSummary(paperHealth, paperAlerts); + elements.paperPanelBody.innerHTML = buildPaperPanel(paperHealth, paperAlerts); elements.brokerTotalSessions.textContent = String(brokerHealth.total_sessions || 0); elements.brokerHealthMeta.textContent = buildBrokerMeta(brokerHealth); elements.hyperliquidState.textContent = buildHyperliquidState(hyperliquidSurface); @@ -706,7 +723,7 @@ function renderOps() { elements.stepbitMeta.textContent = buildStepbitMeta(stepbitWorkspace); elements.metaTradeState.textContent = metaTradeWorkspace.available ? "Ready" : "Boundary"; elements.metaTradeMeta.textContent = buildMetaTradeMeta(metaTradeWorkspace); - elements.opsSummary.textContent = buildOpsSummary(paperHealth, brokerHealth, hyperliquidSurface, pretradeIntake); + elements.opsSummary.textContent = buildOpsSummary(paperHealth, paperAlerts, brokerHealth, hyperliquidSurface, pretradeIntake); elements.sidebarBoundaryMeta.textContent = [ stepbitWorkspace.available ? "Stepbit connected" : "Stepbit boundary", metaTradeWorkspace.available ? "Meta Trade connected" : "Meta Trade boundary", @@ -773,18 +790,153 @@ function keyValue(label, value) { `; } -function buildPaperMeta(health) { +function buildPaperMeta(health, alerts) { if (health.status === "error") { return health.message || "Paper health unavailable"; } if (!health.available) { return "No paper session root yet."; } + if (alerts?.available && alerts.has_alerts) { + const latestAlert = alerts.latest_alert_code ? titleCase(alerts.latest_alert_code) : "Alert active"; + return `${latestAlert} · threshold ${alerts.stale_after_minutes || "-"}m`; + } return health.latest_session_id ? `${health.latest_session_id} · ${titleCase(health.latest_session_status || "unknown")}` : "No paper sessions yet."; } +function buildPaperSummary(health, alerts) { + if (health.status === "error" || alerts.status === "error") { + return "Paper operations unavailable."; + } + if (!health.available) { + return "Waiting for the first paper session root."; + } + if (alerts.has_alerts) { + return `${alerts.alerts?.length || 0} active alert(s) across ${health.total_sessions || 0} session(s)`; + } + if ((alerts.running_sessions || []).length) { + return `${alerts.running_sessions.length} running session(s) below stale threshold`; + } + return health.latest_session_id + ? `Latest session ${health.latest_session_id} looks healthy` + : "No paper sessions recorded yet."; +} + +function buildPaperPanel(health, alerts) { + if (health.status === "error" || alerts.status === "error") { + const message = alerts.message || health.message || "The paper operations surface could not be loaded."; + return ` +
+ Paper operations unavailable + ${escapeHtml(message)} +
+ `; + } + + if (!health.available) { + return ` +
+ No paper session root yet + ${escapeHtml(health.message || "QuantLab will surface paper-session operations here once canonical sessions exist.")} +
+ `; + } + + const statusCounts = health.status_counts || {}; + const alertEntries = Array.isArray(alerts.alerts) ? alerts.alerts.slice(0, 3) : []; + const signalRow = [ + `${escapeHtml(titleCase(alerts.alert_status || "ok"))}`, + `${escapeHtml(`${alerts.running_sessions?.length || 0} running`)}`, + `${escapeHtml(`stale ${alerts.stale_after_minutes || "-"}m`)}`, + ].join(""); + + const latestSession = health.latest_session_id + ? `${health.latest_session_id} · ${titleCase(health.latest_session_status || "unknown")}` + : "No recent session"; + const latestIssue = health.latest_issue_session_id + ? `${health.latest_issue_session_id} · ${titleCase(health.latest_issue_status || "unknown")}` + : "No active issues"; + const latestSuccess = alerts.latest_success_session_id + ? `${alerts.latest_success_session_id} · ${relativeTimeText(alerts.latest_success_at)}` + : "No successful session yet"; + + const alertList = alertEntries.length + ? ` +
+ ${alertEntries.map((alert) => ` +
+ ${escapeHtml(titleCase(alert.code || "alert"))} + ${escapeHtml(alert.message || "Paper session needs attention.")} +
${escapeHtml(alert.session_id || "-")} · ${escapeHtml(relativeTimeText(alert.activity_at))}
+
+ `).join("")} +
+ ` + : ` +
+ No active paper alerts + Latest paper sessions are either successful or still within the bounded running window. +
+ `; + + return ` +
${signalRow}
+
+ ${opsStat("Latest session", latestSession, relativeTimeText(health.latest_session_at))} + ${opsStat("Latest issue", latestIssue, health.latest_issue_error_type || relativeTimeText(health.latest_issue_at))} + ${opsStat("Latest success", latestSuccess, `${statusCounts.success || 0} success`)} + ${opsStat("Session mix", `${health.total_sessions || 0} total`, `${statusCounts.failed || 0} failed · ${statusCounts.running || 0} running`)} +
+ ${alertList} + `; +} + +function opsStat(label, value, meta) { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(value || "-")} +
${escapeHtml(meta || "-")}
+
+ `; +} + +function paperToneClass(status) { + if (status === "critical") { + return "chip-attention"; + } + if (status === "warning") { + return "chip-running"; + } + return "chip-calm"; +} + +function relativeTimeText(value) { + if (!value) { + return "No recent activity"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + const diffMs = Date.now() - date.getTime(); + const diffMinutes = Math.max(0, Math.round(diffMs / 60000)); + if (diffMinutes < 1) { + return "Moments ago"; + } + if (diffMinutes < 60) { + return `${diffMinutes}m ago`; + } + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) { + return `${diffHours}h ago`; + } + const diffDays = Math.round(diffHours / 24); + return `${diffDays}d ago`; +} + function buildBrokerMeta(health) { if (health.status === "error") { return health.message || "Broker health unavailable"; @@ -840,9 +992,11 @@ function buildMetaTradeMeta(workspace) { return `${summary.product_surfaces_present || 0} workbench surfaces · ${summary.engine_modules_present || 0} engine modules`; } -function buildOpsSummary(paperHealth, brokerHealth, hyperliquidSurface, pretradeIntake) { +function buildOpsSummary(paperHealth, paperAlerts, brokerHealth, hyperliquidSurface, pretradeIntake) { const parts = [ - `Paper ${paperHealth.total_sessions || 0}`, + paperAlerts?.has_alerts + ? `Paper attention ${paperAlerts.alerts?.length || 0}` + : `Paper ${paperHealth.total_sessions || 0}`, `Broker ${brokerHealth.total_sessions || 0}`, `Hyperliquid ${buildHyperliquidState(hyperliquidSurface)}`, ]; @@ -1053,7 +1207,9 @@ function updateSyncMeta() { function updateSurfaceSummary() { const runsPart = state.runs.length ? `${state.runs.length} indexed runs` : "no indexed runs yet"; - const paperPart = state.paperHealth?.available ? `paper ${state.paperHealth.total_sessions || 0}` : "paper pending"; + const paperPart = state.paperAlerts?.has_alerts + ? `paper attention ${state.paperAlerts.alerts?.length || 0}` + : (state.paperHealth?.available ? `paper ${state.paperHealth.total_sessions || 0}` : "paper pending"); const brokerPart = state.brokerHealth?.available ? `broker ${state.brokerHealth.total_sessions || 0}` : "broker pending"; const pretradePart = state.pretradeIntake?.has_validation ? (state.pretradeIntake.accepted ? "pre-trade accepted" : "pre-trade rejected") diff --git a/research_ui/index.html b/research_ui/index.html index 7d460a7..e212612 100644 --- a/research_ui/index.html +++ b/research_ui/index.html @@ -248,6 +248,22 @@

Read-only boundaries

+
+
+
+ +

Session health and freshness

+
+
Loading paper operations...
+
+
+
+ Loading + Fetching the latest paper-session health and alert surfaces... +
+
+
+
diff --git a/research_ui/server.py b/research_ui/server.py index 3a65fb5..1764336 100644 --- a/research_ui/server.py +++ b/research_ui/server.py @@ -50,7 +50,11 @@ build_hyperliquid_submission_alerts, build_hyperliquid_submission_health, ) -from quantlab.cli.paper_sessions import build_paper_sessions_health +from quantlab.cli.paper_sessions import ( + DEFAULT_PAPER_STALE_MINUTES, + build_paper_sessions_alerts, + build_paper_sessions_health, +) from quantlab.pretrade.handoff import ( PRETRADE_HANDOFF_VALIDATION_CONTRACT_TYPE, PRETRADE_HANDOFF_VALIDATION_FILENAME, @@ -531,6 +535,9 @@ def do_GET(self): if request_path.startswith('/api/paper-sessions-health'): payload, status = build_paper_health_payload(PROJECT_ROOT) return self._send_json(payload, status=status) + if request_path.startswith('/api/paper-sessions-alerts'): + payload, status = build_paper_alerts_payload(PROJECT_ROOT) + return self._send_json(payload, status=status) if request_path.startswith('/api/broker-submissions-health'): payload, status = build_broker_health_payload(PROJECT_ROOT) return self._send_json(payload, status=status) @@ -660,6 +667,46 @@ def build_paper_health_payload(project_root: Path | None = None) -> tuple[dict, }, 500 +def build_paper_alerts_payload(project_root: Path | None = None) -> tuple[dict, int]: + root = Path(project_root or PROJECT_ROOT) + paper_root = root / "outputs" / "paper_sessions" + + if not paper_root.exists(): + return { + "status": "ok", + "available": False, + "root_dir": str(paper_root), + "message": "No paper session root found yet.", + "generated_at": None, + "stale_after_minutes": DEFAULT_PAPER_STALE_MINUTES, + "total_sessions": 0, + "status_counts": {}, + "running_sessions": [], + "alert_status": "ok", + "has_alerts": False, + "alert_counts": {}, + "latest_success_session_id": None, + "latest_success_at": None, + "latest_alert_session_id": None, + "latest_alert_code": None, + "latest_alert_at": None, + "alerts": [], + }, 200 + + try: + payload = build_paper_sessions_alerts(paper_root) + payload["status"] = "ok" + payload["available"] = True + return payload, 200 + except Exception as exc: # noqa: BLE001 + return { + "status": "error", + "available": False, + "root_dir": str(paper_root), + "message": str(exc), + }, 500 + + def build_broker_health_payload(project_root: Path | None = None) -> tuple[dict, int]: root = Path(project_root or PROJECT_ROOT) broker_root = root / "outputs" / "broker_order_validations" diff --git a/research_ui/styles.css b/research_ui/styles.css index f58812e..abe5463 100644 --- a/research_ui/styles.css +++ b/research_ui/styles.css @@ -469,6 +469,21 @@ th[data-sort].is-active { font-size: 0.78rem; } +.chip-calm { + background: rgba(119, 212, 167, 0.1); + color: var(--success); +} + +.chip-running { + background: rgba(242, 195, 125, 0.12); + color: var(--warning); +} + +.chip-attention { + background: rgba(239, 140, 140, 0.12); + color: var(--danger); +} + .mode-badge.launch-running { background: rgba(242, 195, 125, 0.1); color: var(--warning); @@ -691,6 +706,65 @@ th[data-sort].is-active { margin-top: 14px; } +.ops-signal-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.ops-stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.ops-stat-card { + display: grid; + gap: 6px; + padding: 12px; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--bg-soft); +} + +.ops-stat-card span { + color: var(--muted); + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.ops-stat-card strong { + font-size: 0.95rem; + line-height: 1.4; +} + +.ops-signal-list { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.signal-row { + display: grid; + gap: 6px; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); +} + +.signal-row-attention { + border-color: rgba(239, 140, 140, 0.28); + background: rgba(239, 140, 140, 0.06); +} + +.signal-meta { + color: var(--muted); + font-size: 0.78rem; +} + .panel-empty { display: grid; gap: 6px; @@ -782,7 +856,8 @@ th[data-sort].is-active { .summary-grid, .boundary-grid, .detail-grid, - .launch-grid { + .launch-grid, + .ops-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -815,7 +890,8 @@ th[data-sort].is-active { .boundary-grid, .detail-grid, .launch-grid, - .form-grid { + .form-grid, + .ops-stat-grid { grid-template-columns: 1fr; } diff --git a/test/test_research_ui_server.py b/test/test_research_ui_server.py index fe0476e..7584b6a 100644 --- a/test/test_research_ui_server.py +++ b/test/test_research_ui_server.py @@ -305,6 +305,36 @@ def test_build_paper_health_payload_summarizes_existing_sessions(tmp_path: Path) assert payload["status_counts"]["failed"] == 1 +def test_build_paper_alerts_payload_returns_zero_state_when_root_missing(tmp_path: Path): + payload, status = research_ui_server.build_paper_alerts_payload(tmp_path) + + assert status == 200 + assert payload["status"] == "ok" + assert payload["available"] is False + assert payload["total_sessions"] == 0 + assert payload["has_alerts"] is False + assert payload["alerts"] == [] + + +def test_build_paper_alerts_payload_summarizes_existing_alerts(tmp_path: Path): + paper_root = tmp_path / "outputs" / "paper_sessions" + _write_session(paper_root, "paper_001", "success") + _write_session(paper_root, "paper_002", "failed") + + payload, status = research_ui_server.build_paper_alerts_payload(tmp_path) + + assert status == 200 + assert payload["status"] == "ok" + assert payload["available"] is True + assert payload["total_sessions"] == 2 + assert payload["has_alerts"] is True + assert payload["alert_status"] == "critical" + assert payload["latest_success_session_id"] == "paper_001" + assert payload["latest_alert_session_id"] == "paper_002" + assert payload["latest_alert_code"] == "PAPER_SESSION_FAILED" + assert payload["alert_counts"]["critical"] == 1 + + def test_build_broker_health_payload_returns_zero_state_when_root_missing(tmp_path: Path): payload, status = research_ui_server.build_broker_health_payload(tmp_path)