// HealthPanel — Phase 6 R · #H surface for /api/admin/health/audit. // // Renders the one-shot daily-life-signal JSON as a glanceable status // board. Lets the trader see if everything's working as designed // without curl-ing or staring at logs: // - System: deployed_commit, broker, anthropic // - Brain: total nodes, orphan %, persona count // - Cron: every cron-driven persona + when they fired today // - Cascade today: brief emailed?, persona memos count, last_run // - Alerts: which channels configured + recipient // - Auth: supabase configured + strict mode flag // - Issues: human-readable list of anything that's drifted // // Pulls /api/admin/health/audit on mount and on a 60s poll. Surfaces // as a modal-style panel that can be opened from the topbar's "Health" // button or by pressing Cmd+Shift+H. const { useState: hUseState, useEffect: hUseEffect, useCallback: hUseCallback } = React; function HealthPanel({ open, onClose }) { if (!open) return null; const [data, setData] = hUseState(null); const [briefStatus, setBriefStatus] = hUseState(null); const [loading, setLoading] = hUseState(true); const [err, setErr] = hUseState(''); const [lastFetch, setLastFetch] = hUseState(null); const [redelivering, setRedelivering] = hUseState(false); const [redeliverMsg, setRedeliverMsg] = hUseState(''); // Autonomy surface — populated when /api/events/* endpoints exist // (lights up after claude/autonomous-codebase-oDhbG merges to main). // Until then these stay null and the section renders an empty state. const [autonomy, setAutonomy] = hUseState(null); const load = hUseCallback(async () => { setLoading(true); try { const [auditR, statusR, snapR, personasR, recsR] = await Promise.all([ fetch('/api/admin/health/audit'), fetch('/api/brief/status'), // The autonomy endpoints below ship in the autonomous-triggers // branch — best-effort fetches; 404s are expected pre-merge. fetch('/api/events/snapshot').catch(() => null), fetch('/api/events/personas').catch(() => null), fetch('/api/trade-recs?status=pending&limit=50').catch(() => null), ]); if (!auditR.ok) throw new Error(`audit HTTP ${auditR.status}`); const j = await auditR.json(); setData(j); if (statusR.ok) { try { setBriefStatus(await statusR.json()); } catch (_) {} } // Compose autonomy state from whatever subset of endpoints answered const auto = {}; if (snapR && snapR.ok) { try { auto.snapshot = await snapR.json(); } catch (_) {} } if (personasR && personasR.ok) { try { auto.personas = await personasR.json(); } catch (_) {} } if (recsR && recsR.ok) { try { auto.recs = await recsR.json(); } catch (_) {} } // Only commit autonomy state when at least ONE endpoint answered — // otherwise the section renders the "not yet enabled" empty state. setAutonomy((auto.snapshot || auto.personas || auto.recs) ? auto : null); setErr(''); setLastFetch(new Date()); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }, []); // Hit /api/morning-brief/generate?force=true to redeliver. The endpoint // returns 202 + async-mode (Stage 4 only) when today's brief.md already // exists, so this is fast and safe even after a creds rotation. const redeliverBrief = hUseCallback(async () => { if (redelivering) return; setRedelivering(true); setRedeliverMsg(''); try { const r = await fetch('/api/morning-brief/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force: true }), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const j = await r.json(); setRedeliverMsg(j.skipped ? 'already shipped today' : j.async ? 'queued — re-checking in 8s…' : j.ok ? 'sent ✓' : 'unknown response'); // Re-poll status after a short delay setTimeout(load, 8000); } catch (e) { setRedeliverMsg(`failed: ${e.message || e}`); } finally { setRedelivering(false); } }, [redelivering, load]); hUseEffect(() => { if (!open) return; load(); const id = setInterval(load, 60_000); return () => clearInterval(id); }, [open, load]); const allClear = data && data.all_clear; const issues = data?.issues || []; const dotColor = (val) => val ? '#5fb37c' : '#d96a6a'; const Section = ({ title, children }) => (
{title}
{children}
); const Row = ({ k, v, color }) => (
{k} {v}
); return (
e.stopPropagation()} style={{ width: '100%', maxWidth: 760, maxHeight: 'calc(100vh - 120px)', overflowY: 'auto', background: 'var(--bg)', border: '1px solid var(--line-2)', borderRadius: 6, padding: 24, fontFamily: 'var(--font-body)', }}> {/* Header */}
2 ? '#d96a6a' : '#d9a85f'), boxShadow: allClear ? '0 0 8px #5fb37c' : 'none', }}/>

System Health

{allClear ? 'all clear' : `${issues.length} issue${issues.length === 1 ? '' : 's'}`} {lastFetch && ( {loading ? 'refreshing…' : `updated ${lastFetch.toLocaleTimeString()}`} )}
{err && (
Failed to load /api/admin/health/audit: {err}
)} {data && ( <> {/* Issues at the top — if any */} {issues.length > 0 && (
{issues.map((s, i) => (
⚠ {s}
))}
)} {/* System */}
{/* Brain */}
5 ? '#d9a85f' : 'var(--text)'} />
{/* Cascade today */}
{/* Per-stage breakdown — pulled from /api/brief/status */} {briefStatus && ( )} {Object.entries(data.cascade_today?.personas || {}).map(([role, p]) => ( ))}
{/* Cron */}
{(data.cron?.personas || []).map(p => ( ))} {data.cron?.scheduler_disabled && ( )}
{/* Autonomy — event-triggered personas + queued recs. Only renders when /api/events/* endpoints respond (i.e. the autonomous-triggers branch has merged). When pre-merge, renders an "off" hint so the surface stays predictable. */}
{autonomy ? ( ) : ( )} {/* Budget guard surface — propose_trade caps. Visible whether or not the autonomy endpoints are live, since the SQLite trade_recommendations table is always there. */} {data.autonomy_budget && !data.autonomy_budget.error && (
0 && data.autonomy_budget.open >= data.autonomy_budget.max_open * 0.7 ? '#d9a85f' : 'var(--text)'} /> 0 && data.autonomy_budget.today >= data.autonomy_budget.max_per_day * 0.7 ? '#d9a85f' : 'var(--text)'} />
)}
{/* Alerts */}
{Object.entries(data.alerts?.channels || {}).map(([ch, ok]) => ( ))} = 2 ? '#5fb37c' : (data.alerts?.channels_count ?? 0) === 1 ? '#d9a85f' : '#d96a6a'} />
{/* Auth */}
)}
); } // Per-stage timeline for today's brief. Reads /api/brief/status's // {stages, attempts} and renders a 5-column row (COLLECT → ENRICH → // SYNTHESIZE → COMPOSE → DELIVER) with each stage's tick / cross / // dash + the most recent error if the stage failed. Plus a redeliver // button so the user can retry the DELIVER stage after fixing creds // without leaving the panel. // // SYNTHESIZE is the heavy LLM stage (multi-persona team round) — the // most common failure point when Anthropic credits run out. Surfacing // it as a distinct cell makes the failure mode legible. const BRIEF_STAGES = ['COLLECT', 'ENRICH', 'SYNTHESIZE', 'COMPOSE', 'DELIVER']; function BriefStageTimeline({ status, redelivering, redeliverMsg, onRedeliver }) { // For each stage, find the LAST attempt's outcome to color the cell. // 'ok' = green, 'fail' = red, anything else (skipped, missing) = dim. const lastByStage = {}; for (const a of (status.attempts || [])) { if (a.stage) lastByStage[a.stage.toUpperCase()] = a; } // Most recent failure (if any) — surfaced under the timeline so the // user sees the error string without expanding the full attempts log. const recentFail = (status.attempts || []).slice().reverse() .find(a => a.status === 'fail'); const cellColor = (stageKey) => { const last = lastByStage[stageKey]; if (!last) return 'var(--text-dim)'; if (last.status === 'ok') return '#5fb37c'; if (last.status === 'fail') return '#d96a6a'; return 'var(--text-dim)'; }; const cellGlyph = (stageKey) => { const last = lastByStage[stageKey]; if (!last) return '·'; if (last.status === 'ok') return '✓'; if (last.status === 'fail') return '✗'; return '~'; }; return (
stages
{/* Timeline row: each cell has glyph above + label below */}
{BRIEF_STAGES.map((stage, i) => (
{cellGlyph(stage)}
{stage}
{/* Connector arrow between stages */} {i < BRIEF_STAGES.length - 1 && ( )}
))}
{recentFail && (
✗ {recentFail.stage || '?'} — {new Date(recentFail.ts).toLocaleTimeString()}
{recentFail.error || '(no error string)'}
)} {redeliverMsg && (
{redeliverMsg}
)}
); } // Autonomy section content. Surfaces three streams from the autonomous- // triggers branch (claude/autonomous-codebase-oDhbG) via best-effort // fetches; renders the subset that answered. // // /api/events/snapshot → live signal (vix, drawdown, bwd, ...) the // triggers are evaluated against // /api/events/personas → every persona with `trigger:` frontmatter, // its armed/latched state, last-fire ts // /api/trade-recs → propose_trade-filed recs awaiting review // // Why duplicate Today tab's AutonomyStrip here: Health is the single // "is everything OK" diagnostic surface — the trader checks it before // market open. Mixing autonomy state into the same surface saves a tab // hunt for "why didn't the hedge fire when VIX spiked?". function AutonomySnapshot({ autonomy }) { const snap = autonomy.snapshot || {}; const personas = (autonomy.personas?.personas || autonomy.personas?.entries || autonomy.personas || []); const personaList = Array.isArray(personas) ? personas : (personas.personas || []); const recs = (autonomy.recs?.recs || autonomy.recs || []); const recList = Array.isArray(recs) ? recs : (recs.recs || []); const Row = ({ k, v, color }) => (
{k} {v}
); // Snapshot pills — render the standard signals if present const snapFields = [ { key: 'vix', label: 'vix', fmt: (v) => v?.toFixed?.(2) ?? '—' }, { key: 'drawdown_pct', label: 'drawdown', fmt: (v) => v != null ? `${(v * 100).toFixed(2)}%` : '—' }, { key: 'bwd', label: 'BWD', fmt: (v) => v?.toFixed?.(2) ?? '—' }, { key: 'largest_concentration_pct', label: 'concentration', fmt: (v) => v != null ? `${(v * 100).toFixed(1)}%` : '—' }, { key: 'market_value', label: 'MV', fmt: (v) => v != null ? `$${(v / 1000).toFixed(1)}k` : '—' }, ]; return ( <> {/* Snapshot pills */} {Object.keys(snap).length > 0 && (
{snapFields.map(f => { const v = snap[f.key]; if (v == null) return null; return ( {f.label}{' '} {f.fmt(v)} ); })} {snap.ts && ( snapshot {new Date(snap.ts).toLocaleTimeString()} )}
)} {/* Trigger personas — one row per persona with state pill */} {personaList.length > 0 && (
wired triggers · {personaList.length}
{personaList.map((p, i) => { const state = p.state || (p.evaluates ? (p.armed ? 'hot' : 'latched') : 'waiting'); const stateColor = { hot: '#d96a6a', latched: '#d9a85f', waiting: '#5fb37c', unavailable: 'var(--text-dim)', }[state] || 'var(--text)'; const role = p.role || p.name || `persona-${i}`; const expr = p.trigger || p.expression || ''; const lastFire = p.last_fired || p.last_fire_ts; return (
{role} {expr} {lastFire && ( fired {new Date(lastFire).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit' })} )}
); })}
)} {/* Queued trade-recs counter */} {recList.length > 0 && ( )} {recList.length === 0 && personaList.length > 0 && ( )} ); } window.HealthPanel = HealthPanel;