feat: new ops console for the admin surface

The old admin was five separate html pages, so every navigation was a full
reload and the operational picture was scattered across all of them. Worth
saying: the api was never the problem, every endpoint answers in under 200ms.
It felt slow because of the architecture, not the backend.

This is a single page console. React and htm from a cdn, no bundler and no
babel-in-the-browser, because a runtime transpiler on every load is exactly the
slowness we are trying to get rid of. Hash routing, polling that keeps the last
good payload on screen instead of flashing a spinner, and stale responses are
dropped so a slow request cannot overwrite a newer one.

/admin/api/ops/overview answers the whole dashboard in one call rather than
making the browser fan out and stitch. It carries the things that actually
matter and were not visible anywhere before: when live evidence matures, why a
cohort does or does not clear the trade gate, which stage of the pipeline has
gone quiet, and what is sitting in dead letters.

Controls, all of which change production and all of which ask twice:
- requeue dead letters, which only ever moves dead_letter back to pending
- execution mode and a kill switch, now read from autonomy_settings on every
  poll instead of only from AUTONOMY_EXECUTION_MODE, so halting no longer needs
  a redeploy first
- run the reaction analysis and read its output

The d3 graph is framed rather than ported. It works, and rewriting it would risk
something valuable for nothing the operator can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnNxwxfXSbeNtjvtz5gayb
This commit is contained in:
ImBenji
2026-09-01 20:17:36 +01:00
co-authored by Claude Opus 5
parent 2d92759ae9
commit 0a5b30ab63
14 changed files with 1492 additions and 8 deletions
+154
View File
@@ -0,0 +1,154 @@
import { html, usePoll, api, useState, num } from './core.js';
import { Card, Dot, Pill, Confirm, Empty, Skeleton } from './ui.js';
export function Controls({ notify }) {
const { data, error, loading, refresh } = usePoll('/admin/api/ops/overview', 8000);
const [busy, setBusy] = useState(null);
const [analysis, setAnalysis] = useState(null);
const act = async (key, run, describe) => {
setBusy(key);
try {
const result = await run();
notify({ tone: 'ok', message: describe(result) });
await refresh();
return result;
} catch (err) {
console.error('[ops] control failed:', key, err.message);
notify({ tone: 'bad', message: `${key} failed: ${err.message}`, sticky: true });
return null;
} finally {
setBusy(null);
}
};
if (loading && !data) return html`<section class="card"><${Skeleton} rows=${4} /></section>`;
const controls = (data && data.controls) || {};
const dead = (data && data.deadLetters) || [];
const deadTotal = dead.reduce((sum, row) => sum + Number(row.n || 0), 0);
const killed = Boolean(controls.killSwitch);
return html`
<div class="grid" style="gap:14px">
${error && html`<div class="banner warn"><${Dot} tone="warn" /><span>${error}</span></div>`}
<${Card} title="Execution"
right=${html`<${Pill} tone=${killed ? 'bad' : (controls.mode === 'paper' ? 'warn' : 'ok')}>
${killed ? 'halted' : (controls.mode || 'unknown')}
<//>`}>
<div class="grid" style="gap:14px">
<div class="row spread" style="align-items:flex-start">
<div style="max-width:52ch">
<div class="row" style="gap:8px">
<${Dot} tone=${killed ? 'bad' : 'ok'} />
<strong>Kill switch ${killed ? 'engaged' : 'released'}</strong>
</div>
<div class="muted small" style="margin-top:5px">
While engaged no order intents are created. Broker reconciliation keeps
running so the account stays visible. Takes effect within one poll,
no redeploy.
</div>
</div>
<${Confirm}
label=${killed ? 'release' : 'engage kill switch'}
confirmLabel=${killed ? 'yes, release' : 'yes, halt trading'}
danger=${!killed}
busy=${busy === 'kill'}
onConfirm=${() => act('kill',
() => api('/admin/api/ops/settings', { method: 'POST', body: { killSwitch: !killed } }),
(r) => `Kill switch ${r.controls.killSwitch ? 'engaged' : 'released'}`)} />
</div>
<div class="row spread" style="align-items:flex-start; border-top:1px solid var(--line); padding-top:14px">
<div style="max-width:52ch">
<strong>Mode</strong>
<div class="muted small" style="margin-top:5px">
<span class="mono">shadow</span> records intents without touching the broker.
<span class="mono">paper</span> submits them to Alpaca paper.
Currently from ${controls.modeSource === 'settings' ? 'this dashboard' : 'the environment'}.
</div>
</div>
<${Confirm}
label=${controls.mode === 'paper' ? 'switch to shadow' : 'switch to paper'}
confirmLabel=${controls.mode === 'paper' ? 'yes, shadow' : 'yes, submit to broker'}
danger=${controls.mode !== 'paper'}
busy=${busy === 'mode'}
onConfirm=${() => act('mode',
() => api('/admin/api/ops/settings', {
method: 'POST',
body: { mode: controls.mode === 'paper' ? 'shadow' : 'paper' },
}),
(r) => `Execution mode is now ${r.controls.mode}`)} />
</div>
</div>
<//>
<${Card} title="Dead letters"
right=${deadTotal > 0 && html`<${Pill} tone="bad">${num(deadTotal)} stuck<//>`}>
${!deadTotal
? html`<${Empty}>Nothing dead-lettered. Good.<//>`
: html`
<div class="grid" style="gap:12px">
<div class="muted small">
Requeue moves rows from <span class="mono">dead_letter</span> back to
<span class="mono">pending</span>. It never deletes and never edits a payload,
so the worst case is repeated work.
</div>
<div class="table-scroll">
<table>
<thead><tr><th>job</th><th>lane</th><th class="num">n</th><th>last error</th><th></th></tr></thead>
<tbody>
${dead.map((row) => html`
<tr key=${`${row.job_type}:${row.lane}`}>
<td class="mono">${row.job_type}</td>
<td><${Pill}>${row.lane}<//></td>
<td class="num">${num(row.n)}</td>
<td class="muted small" style="max-width:40ch">${row.sample_error || '—'}</td>
<td>
<${Confirm} label="requeue"
busy=${busy === `dl:${row.job_type}:${row.lane}`}
confirmLabel=${`requeue ${num(row.n)}`}
onConfirm=${() => act(`dl:${row.job_type}:${row.lane}`,
() => api('/admin/api/ops/dead-letters/requeue', {
method: 'POST', body: { jobType: row.job_type, lane: row.lane },
}),
(r) => `Requeued ${num(r.requeued)} ${row.job_type} jobs`)} />
</td>
</tr>`)}
</tbody>
</table>
</div>
</div>`}
<//>
<${Card} title="Analyses">
<div class="grid" style="gap:12px">
<div class="row spread">
<div style="max-width:52ch">
<strong>Reaction conditioning</strong>
<div class="muted small" style="margin-top:5px">
Tests whether the initial market reaction predicts anything, on every
matured outcome. Read only, takes a few minutes, caches price history.
</div>
</div>
<button class="sm" disabled=${busy === 'analysis'}
onClick=${async () => {
setAnalysis(null);
const result = await act('analysis',
() => api('/admin/api/ops/analysis/reaction', { method: 'POST' }),
(r) => (r.ok ? 'Analysis finished' : 'Analysis exited with an error'));
if (result) setAnalysis(result);
}}>
${busy === 'analysis' ? 'running…' : 'run'}
</button>
</div>
${analysis && html`
<div class="grid" style="gap:8px">
${analysis.error && html`<div class="muted small" style="color:var(--bad)">${analysis.error}</div>`}
<pre>${analysis.output || '(no output)'}</pre>
</div>`}
</div>
<//>
</div>`;
}
+111
View File
@@ -0,0 +1,111 @@
// Shared plumbing: htm binding, hash router, polling fetch, formatters.
const { createElement, useState, useEffect, useRef, useCallback } = React;
export const html = htm.bind(createElement);
export { useState, useEffect, useRef, useCallback };
export async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
body: options.body ? JSON.stringify(options.body) : undefined,
});
const text = await response.text();
let parsed = null;
try { parsed = text ? JSON.parse(text) : null; } catch (error) {
// A non JSON body from an API that always speaks JSON means something upstream
// failed, so surface the raw text rather than a parse error nobody can act on.
console.error('[ops] non-JSON response from', path, error.message);
throw new Error(text.slice(0, 200) || `HTTP ${response.status}`);
}
if (!response.ok) throw new Error((parsed && parsed.error) || `HTTP ${response.status}`);
return parsed;
}
// Poll without the spinner flash: keep showing the previous payload while the next
// one is in flight, and never let a slow response overwrite a newer one.
export function usePoll(path, intervalMs = 5000) {
const [state, setState] = useState({ data: null, error: null, loading: true, at: null });
const seq = useRef(0);
const alive = useRef(true);
const refresh = useCallback(async () => {
const mine = ++seq.current;
try {
const data = await api(path);
if (!alive.current || mine !== seq.current) return;
setState({ data, error: null, loading: false, at: Date.now() });
} catch (error) {
if (!alive.current || mine !== seq.current) return;
console.error('[ops] poll failed for', path, error.message);
setState((prev) => ({ ...prev, error: error.message, loading: false }));
}
}, [path]);
useEffect(() => {
alive.current = true;
refresh();
const timer = setInterval(refresh, intervalMs);
const onVisible = () => { if (!document.hidden) refresh(); };
document.addEventListener('visibilitychange', onVisible);
return () => {
alive.current = false;
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [refresh, intervalMs]);
return { ...state, refresh };
}
export function useHashRoute(fallback) {
const read = () => (location.hash || '').replace(/^#\/?/, '') || fallback;
const [route, setRoute] = useState(read);
useEffect(() => {
const onHash = () => setRoute(read());
addEventListener('hashchange', onHash);
return () => removeEventListener('hashchange', onHash);
}, []);
return route;
}
/* ---------- formatting ---------- */
export const num = (value) => (value === null || value === undefined || Number.isNaN(Number(value))
? '—' : Number(value).toLocaleString());
export function compact(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
if (Math.abs(n) >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(n);
}
export const pct = (value, digits = 1) => (Number.isFinite(Number(value))
? `${(Number(value) * 100).toFixed(digits)}%` : '—');
export function ago(minutes) {
if (minutes === null || minutes === undefined || !Number.isFinite(Number(minutes))) return 'unknown';
const m = Number(minutes);
if (m < 1) return 'just now';
if (m < 60) return `${Math.round(m)}m ago`;
if (m < 60 * 24) return `${(m / 60).toFixed(1)}h ago`;
return `${(m / 1440).toFixed(1)}d ago`;
}
export function whenDate(value) {
if (!value) return '—';
const stamp = String(value).slice(0, 10);
const days = Math.round((Date.parse(`${stamp}T00:00:00Z`) - Date.now()) / 86400000);
if (!Number.isFinite(days)) return stamp;
if (days === 0) return `${stamp} (today)`;
return days > 0 ? `${stamp} (in ${days}d)` : `${stamp} (${-days}d ago)`;
}
export function health(minutes, threshold) {
if (minutes === null || minutes === undefined) return 'idle';
if (!Number.isFinite(Number(threshold))) return 'ok';
const m = Number(minutes);
if (m <= threshold) return 'ok';
return m <= threshold * 3 ? 'warn' : 'bad';
}
+156
View File
@@ -0,0 +1,156 @@
import { html, usePoll, useState, num, compact, ago } from './core.js';
import { Card, Stat, Pill, Skeleton, Empty } from './ui.js';
const PAGE = 50;
function shortUrl(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); }
catch (error) { return String(url || '').slice(0, 40); }
}
function statusTone(status) {
if (status === 'ready') return 'ok';
if (status === 'failed') return 'bad';
if (status === 'pending') return 'warn';
return '';
}
// One list component behind both Articles and Events. They differ only in columns
// and endpoint, and having two near-identical files is how they drift apart.
function ListView({ title, path, columns, searchable }) {
const [offset, setOffset] = useState(0);
const [query, setQuery] = useState('');
const [applied, setApplied] = useState('');
const url = `${path}?limit=${PAGE}&offset=${offset}${applied ? `&q=${encodeURIComponent(applied)}` : ''}`;
const { data, error, loading } = usePoll(url, 20000);
const rows = (data && data.rows) || [];
const total = data && data.total;
return html`
<div class="grid" style="gap:14px">
<${Card} title=${title}
right=${html`<span class="muted small">${total === undefined ? '' : `${num(total)} total`}</span>`}>
${searchable && html`
<form class="row" style="gap:8px; margin-bottom:12px"
onSubmit=${(e) => { e.preventDefault(); setOffset(0); setApplied(query.trim()); }}>
<input style="flex:1" placeholder="search title…" value=${query}
onInput=${(e) => setQuery(e.target.value)} />
<button class="sm" type="submit">search</button>
${applied && html`<button class="sm" type="button"
onClick=${() => { setQuery(''); setApplied(''); setOffset(0); }}>clear</button>`}
</form>`}
${error && html`<div class="muted small" style="color:var(--bad); margin-bottom:10px">${error}</div>`}
${loading && !data
? html`<${Skeleton} rows=${6} />`
: !rows.length
? html`<${Empty}>Nothing here.<//>`
: html`
<div class="table-scroll">
<table>
<thead><tr>${columns.map((c) => html`
<th key=${c.key} class=${c.num ? 'num' : ''}>${c.label}</th>`)}</tr></thead>
<tbody>
${rows.map((row) => html`
<tr key=${row.id}>
${columns.map((c) => html`
<td key=${c.key} class=${c.num ? 'num' : ''}>${c.render(row)}</td>`)}
</tr>`)}
</tbody>
</table>
</div>`}
<div class="row spread" style="margin-top:12px">
<span class="muted small mono">
${rows.length ? `${num(offset + 1)}–${num(offset + rows.length)}` : '—'}
</span>
<span class="row" style="gap:8px">
<button class="sm" disabled=${offset === 0}
onClick=${() => setOffset(Math.max(0, offset - PAGE))}>prev</button>
<button class="sm" disabled=${!(data && data.hasMore)}
onClick=${() => setOffset(offset + PAGE)}>next</button>
</span>
</div>
<//>
</div>`;
}
export const Articles = () => html`<${ListView}
title="Articles" path="/admin/api/articles" searchable
columns=${[
{ key: 'title', label: 'title', render: (r) => html`
<a href=${r.url} target="_blank" rel="noopener noreferrer"
style="color:var(--ink); text-decoration:none">${r.title || '(untitled)'}</a>
<div class="muted small mono" style="margin-top:3px">${shortUrl(r.url)}</div>` },
{ key: 'source', label: 'source', render: (r) => html`<span class="small">${r.source}</span>` },
{ key: 'status', label: 'content', render: (r) => html`
<${Pill} tone=${statusTone(r.content_status)}>${r.content_status || 'unfetched'}<//>` },
{ key: 'pub', label: 'published', render: (r) => html`
<span class="mono small nowrap">${String(r.pub_date_effective || r.pub_date || '').slice(0, 16)}</span>` },
]} />`;
export const Events = () => html`<${ListView}
title="Events" path="/admin/api/events"
columns=${[
{ key: 'title', label: 'title', render: (r) => r.title || '(untitled)' },
{ key: 'articles', label: 'articles', num: true, render: (r) => num(r.article_count) },
{ key: 'created', label: 'created', render: (r) => html`
<span class="mono small nowrap">${String(r.created_at || '').slice(0, 16)}</span>` },
]} />`;
export function Intelligence() {
const summary = usePoll('/admin/api/stats/summary', 15000);
const intel = usePoll('/admin/api/intelligence/stats', 15000);
const s = summary.data || {};
const i = intel.data || {};
return html`
<div class="grid" style="gap:14px">
<div class="grid cols-3">
<${Stat} label="Articles" value=${compact(s.total)}
sub=${`${compact(s.withContent)} with content · ${compact(s.withEmbedding)} embedded`} />
<${Stat} label="Events" value=${compact(s.eventCount)} sub="clustered" />
<${Stat} label="Knowledge rows" value=${compact(i.knowledge)}
sub=${`${compact(i.companies)} companies tracked`} />
</div>
<div class="grid cols-2">
<${Card} title="Worker rates" right=${html`<span class="muted small">rows written</span>`}>
${!(i.workerRates || []).length
? html`<${Empty}>No worker activity recorded.<//>`
: html`
<table>
<thead><tr><th>worker</th><th class="num">last 1m</th><th class="num">last 5m</th></tr></thead>
<tbody>
${i.workerRates.map((w) => html`
<tr key=${w.worker}>
<td class="mono">${w.worker}</td>
<td class="num">${num(w.n1m)}</td>
<td class="num">${num(w.n5m)}</td>
</tr>`)}
</tbody>
</table>`}
<//>
<${Card} title="Article queue">
${!(i.queue || []).length
? html`<${Empty}>Queue is empty.<//>`
: html`
<table>
<thead><tr><th>status</th><th class="num">n</th></tr></thead>
<tbody>
${i.queue.map((q) => html`
<tr key=${q.status}>
<td><${Pill} tone=${statusTone(q.status)}>${q.status}<//></td>
<td class="num">${num(q.n)}</td>
</tr>`)}
</tbody>
</table>`}
<//>
</div>
</div>`;
}
+87
View File
@@ -0,0 +1,87 @@
import { html, useHashRoute, useState, usePoll, num, ago } from './core.js';
import { Toast, Dot } from './ui.js';
import { Overview } from './overview.js';
import { Controls } from './controls.js';
import { Articles, Events, Intelligence } from './explore.js';
import { Sql } from './sql.js';
// The d3 force graph is a big specialised visualisation that already works. It is
// mounted in a frame rather than rewritten, because porting it would risk breaking
// something valuable to gain nothing the operator can see.
const Graph = () => html`
<section class="card" style="padding:0; overflow:hidden">
<iframe src="/admin/intelligence/graph" title="Intelligence graph"
style="width:100%; height:calc(100vh - 150px); border:0; display:block"></iframe>
</section>`;
const ROUTES = [
{ id: 'overview', label: 'Overview', view: Overview },
{ id: 'controls', label: 'Controls', view: Controls },
{ id: 'articles', label: 'Articles', view: Articles },
{ id: 'events', label: 'Events', view: Events },
{ id: 'intelligence', label: 'Intelligence', view: Intelligence },
{ id: 'graph', label: 'Graph', view: Graph },
{ id: 'sql', label: 'SQL', view: Sql },
];
function Nav({ route, health }) {
return html`
<nav class="side">
<div class="brand">DURIIN</div>
${ROUTES.map((r) => html`
<a key=${r.id} class=${`nav-item ${route === r.id ? 'active' : ''}`} href=${`#/${r.id}`}>
<span>${r.label}</span>
${r.id === 'controls' && health.deadTotal > 0
&& html`<span class="pill bad">${num(health.deadTotal)}</span>`}
${r.id === 'overview' && health.halted && html`<${Dot} tone="bad" />`}
</a>`)}
<div class="nav-spacer"></div>
<div class="muted small mono" style="padding:8px 10px; line-height:1.7">
<div class="row" style="gap:7px">
<${Dot} tone=${health.tone} live />
<span>${health.label}</span>
</div>
<div>ingest ${ago(health.ingestMinutes)}</div>
</div>
</nav>`;
}
function App() {
const route = useHashRoute('overview');
const [toast, setToast] = useState(null);
// A single cheap poll drives the sidebar so every view does not need its own.
const { data } = usePoll('/admin/api/ops/overview', 10000);
const controls = (data && data.controls) || {};
const deadTotal = ((data && data.deadLetters) || [])
.reduce((sum, row) => sum + Number(row.n || 0), 0);
const ingestMinutes = data && data.freshness ? data.freshness.ingestMinutes : null;
const halted = Boolean(controls.killSwitch);
const health = {
deadTotal,
halted,
ingestMinutes,
tone: halted ? 'bad' : (deadTotal > 0 ? 'warn' : 'ok'),
label: halted ? 'trading halted' : (controls.mode ? `${controls.mode} mode` : 'connecting…'),
};
const active = ROUTES.find((r) => r.id === route) || ROUTES[0];
const View = active.view;
return html`
<div class="shell">
<${Nav} route=${active.id} health=${health} />
<main class="main">
<header class="topbar">
<h1>${active.label}</h1>
<span class="grow"></span>
${halted && html`<span class="pill bad">kill switch engaged</span>`}
</header>
<${View} notify=${setToast} />
</main>
<${Toast} toast=${toast} />
</div>`;
}
ReactDOM.createRoot(document.getElementById('root')).render(html`<${App} />`);
+216
View File
@@ -0,0 +1,216 @@
import { html, usePoll, num, compact, pct, ago, whenDate, health } from './core.js';
import { Card, Stat, Dot, Pill, Bar, Skeleton, Empty } from './ui.js';
const GATE = { minSample: 30, minInstruments: 5, maxConcentration: 0.5 };
const ORIGIN_NOTE = {
live: 'genuine real-time work, the only thing that can authorise an order',
historical: 'coordinator backfill, training evidence only',
replay: 'walk-forward replay, training evidence only',
};
function PipelineRow({ label, tone, detail, note }) {
return html`
<div class="row spread">
<span class="row" style="gap:9px">
<${Dot} tone=${tone} live />
<span>${label}</span>
</span>
<span class="row" style="gap:10px">
${note && html`<span class="muted small nowrap">${note}</span>`}
<span class="mono small nowrap">${detail}</span>
</span>
</div>`;
}
function LiveEvidence({ maturity, liveOpen, liveResolved }) {
const total = (maturity || []).reduce((sum, row) => sum + Number(row.n || 0), 0);
const next = (maturity || [])
.map((row) => row.first_matures).filter(Boolean).sort()[0];
if (!total && !liveResolved) {
return html`<${Empty}>No live predictions yet.<//>`;
}
return html`
<div class="grid" style="gap:12px">
<div class="row spread">
<span class="stat">${num(liveResolved)}</span>
<span class="muted small">matured of ${num(liveOpen + liveResolved)} live</span>
</div>
<${Bar} value=${liveResolved} max=${liveOpen + liveResolved}
tone=${liveResolved > 0 ? 'ok' : 'warn'} />
<div class="muted small">
${liveResolved > 0
? 'Live evidence is accumulating.'
: html`Nothing has matured yet. First outcome ${html`<strong>${whenDate(next)}</strong>`}.`}
</div>
<div class="table-scroll">
<table>
<thead><tr><th>horizon</th><th class="num">open</th><th>first matures</th></tr></thead>
<tbody>
${(maturity || []).map((row) => html`
<tr key=${row.horizon_days}>
<td class="mono">${row.horizon_days}d</td>
<td class="num">${num(row.n)}</td>
<td class="mono small">${whenDate(row.first_matures)}</td>
</tr>`)}
</tbody>
</table>
</div>
</div>`;
}
function GateCard({ cohorts }) {
const live = (cohorts || []).filter((c) => c.source === 'live');
const best = live.slice().sort((a, b) => Number(b.sample_size) - Number(a.sample_size))[0];
const qualifying = live.filter((c) => Number(c.sample_size) >= GATE.minSample
&& Number(c.distinct_instruments) >= GATE.minInstruments
&& Number(c.top_instrument_share) <= GATE.maxConcentration);
const rows = [
{ label: 'samples', value: best ? Number(best.sample_size) : 0, need: GATE.minSample,
fmt: (v) => num(v) },
{ label: 'distinct tickers', value: best ? Number(best.distinct_instruments) : 0, need: GATE.minInstruments,
fmt: (v) => num(v) },
{ label: 'top ticker share', value: best ? Number(best.top_instrument_share) : 1,
need: GATE.maxConcentration, invert: true, fmt: (v) => pct(v, 0) },
];
return html`
<div class="grid" style="gap:14px">
<div class="row spread">
<span class="stat" style=${qualifying.length ? 'color:var(--ok)' : null}>${qualifying.length}</span>
<span class="muted small">live cohorts clear the gate</span>
</div>
${!live.length
? html`<${Empty}>No live calibration exists yet, so every decision abstains. Offline cohorts cannot authorise orders.<//>`
: html`
<div class="gate">
${rows.map((row) => {
const passed = row.invert ? row.value <= row.need : row.value >= row.need;
return html`
<div class="gate-row" key=${row.label}>
<span class="gate-label">${row.label}</span>
<span class="mono small" style=${`color:var(--${passed ? 'ok' : 'ink-dim'})`}>
${row.fmt(row.value)} ${passed ? '✓' : `/ ${row.fmt(row.need)}`}
</span>
<div style="grid-column:1/-1">
<${Bar} value=${row.invert ? Math.max(0, 1 - row.value) : row.value}
max=${row.invert ? 1 : row.need} tone=${passed ? 'ok' : 'warn'} />
</div>
</div>`;
})}
</div>
<div class="muted small">Best-populated live cohort shown.</div>`}
</div>`;
}
export function Overview({ notify }) {
const { data, error, loading, at } = usePoll('/admin/api/ops/overview', 5000);
if (loading && !data) {
return html`<div class="grid cols-3">
${[0, 1, 2].map((i) => html`<section class="card" key=${i}><${Skeleton} rows=${2} /></section>`)}
</div>`;
}
if (error && !data) return html`<${Card} title="Overview"><div class="muted">${error}</div><//>`;
const p = data.predictions || {};
const f = data.freshness || {};
const thresholds = f.thresholds || {};
const deadTotal = (data.deadLetters || []).reduce((s, r) => s + Number(r.n || 0), 0);
const abstain = (data.decisions || []).find((d) => d.action === 'ABSTAIN');
const acting = (data.decisions || []).filter((d) => d.action === 'BUY' || d.action === 'SELL')
.reduce((s, d) => s + Number(d.n || 0), 0);
return html`
<div class="grid" style="gap:14px">
${error && html`<div class="banner warn">
<${Dot} tone="warn" /><span>Live updates interrupted: ${error}. Showing the last good snapshot.</span>
</div>`}
${deadTotal > 0 && html`
<div class="banner">
<${Dot} tone="bad" />
<span><strong>${num(deadTotal)}</strong> dead-lettered jobs are not being retried.</span>
<span class="grow"></span>
<a class="nav-item" href="#/controls" style="padding:4px 10px">Go to controls →</a>
</div>`}
<div class="grid cols-3">
<${Stat} label="Live predictions open" value=${num(p.live_open)}
sub=${`${num(p.live_resolved)} matured · ${num(p.total)} total`} />
<${Stat} label="Order intents" value=${num(data.orderIntents)}
tone=${Number(data.orderIntents) > 0 ? 'warn' : null}
sub=${acting ? `${num(acting)} actionable decisions` : 'every decision has abstained'} />
<${Stat} label="Archive" value=${compact(data.archive && data.archive.max_id)}
sub=${`last ingest ${ago(f.ingestMinutes)}`} />
</div>
<div class="grid cols-2">
<${Card} title="Pipeline">
<div class="grid" style="gap:9px">
<${PipelineRow} label="Ingest" note="articles"
tone=${health(f.ingestMinutes, thresholds.ingest)}
detail=${ago(f.ingestMinutes)} />
<${PipelineRow} label="Coordinator" note="predictions"
tone=${health(f.predictionMinutes, thresholds.prediction)}
detail=${ago(f.predictionMinutes)} />
<${PipelineRow} label="Outcomes" note="scored"
tone=${health(f.outcomeMinutes, thresholds.outcome)}
detail=${ago(f.outcomeMinutes)} />
<${PipelineRow} label="Execution"
note=${data.controls ? (data.controls.killSwitch ? 'kill switch on' : data.controls.mode) : ''}
tone=${data.controls && data.controls.killSwitch ? 'bad' : 'ok'}
detail=${`${num(data.orderIntents)} intents`} />
</div>
<//>
<${Card} title="Can it trade yet?">
<${GateCard} cohorts=${data.cohorts} />
<//>
<${Card} title="Live evidence">
<${LiveEvidence} maturity=${data.maturity}
liveOpen=${Number(p.live_open || 0)} liveResolved=${Number(p.live_resolved || 0)} />
<//>
<${Card} title="Accuracy by origin"
right=${html`<span class="muted small">only live counts as a track record</span>`}>
${!(data.byOrigin || []).length
? html`<${Empty}>No scored outcomes yet.<//>`
: html`
<div class="table-scroll">
<table>
<thead><tr><th>origin</th><th class="num">n</th><th class="num">accuracy</th><th class="num">mean excess</th></tr></thead>
<tbody>
${data.byOrigin.map((row) => {
const acc = Number(row.total) ? Number(row.correct) / Number(row.total) : null;
return html`
<tr key=${row.origin}>
<td>
<div class="row" style="gap:7px">
<${Pill} tone=${row.origin === 'live' ? 'ok' : ''}>${row.origin}<//>
</div>
<div class="muted small" style="margin-top:3px">${ORIGIN_NOTE[row.origin] || ''}</div>
</td>
<td class="num">${num(row.total)}</td>
<td class="num">${pct(acc)}</td>
<td class="num" style=${`color:var(--${Number(row.mean_excess) >= 0 ? 'ok' : 'bad'})`}>
${pct(row.mean_excess, 2)}
</td>
</tr>`;
})}
</tbody>
</table>
</div>`}
<//>
</div>
<div class="muted small mono">
${abstain ? `${num(abstain.n)} decisions, all ABSTAIN · ` : ''}
updated ${at ? new Date(at).toLocaleTimeString() : '—'}
</div>
</div>`;
}
+106
View File
@@ -0,0 +1,106 @@
import { html, api, useState, num } from './core.js';
import { Card, Pill, Empty } from './ui.js';
const SAMPLES = [
{ label: 'live prediction maturity', db: 'intelligence', sql:
`SELECT horizon_days, COUNT(*) AS n,
MIN(date(information_cutoff, '+' || horizon_days || ' days')) AS first_matures
FROM autonomy_predictions
WHERE origin='live' AND status='open'
GROUP BY horizon_days ORDER BY horizon_days` },
{ label: 'accuracy by origin', db: 'intelligence', sql:
`SELECT p.origin, COUNT(*) AS n,
ROUND(100.0*AVG(o.direction_correct),1) AS acc_pct,
ROUND(100.0*AVG(o.excess_return),2) AS mean_excess_pct
FROM autonomy_predictions p JOIN autonomy_outcomes o ON o.prediction_id=p.id
GROUP BY p.origin` },
{ label: 'dead letters', db: 'intelligence', sql:
`SELECT job_type, lane, COUNT(*) AS n, substr(MAX(last_error),1,80) AS err
FROM autonomy_jobs WHERE status='dead_letter' GROUP BY job_type, lane` },
{ label: 'content backlog', db: 'archive', sql:
`SELECT COALESCE(content_status,'unfetched') AS status, COUNT(*) AS n
FROM articles GROUP BY status ORDER BY n DESC` },
];
export function Sql({ notify }) {
const [sql, setSql] = useState(SAMPLES[0].sql);
const [database, setDatabase] = useState('intelligence');
const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false);
const run = async () => {
setBusy(true);
const started = performance.now();
try {
const response = await api('/admin/api/sql', { method: 'POST', body: { sql, database } });
setResult({ ...response, clientMs: Math.round(performance.now() - started) });
} catch (error) {
console.error('[ops] sql failed:', error.message);
notify({ tone: 'bad', message: error.message, sticky: true });
setResult(null);
} finally {
setBusy(false);
}
};
const first = result && result.results && result.results[0];
const rows = (first && first.rows) || [];
const columns = rows.length ? Object.keys(rows[0]) : [];
return html`
<div class="grid" style="gap:14px">
<${Card} title="Query"
right=${html`
<div class="row" style="gap:8px">
<select value=${database} onChange=${(e) => setDatabase(e.target.value)}>
<option value="intelligence">intelligence</option>
<option value="archive">archive</option>
</select>
<button class="primary sm" disabled=${busy} onClick=${run}>
${busy ? 'running…' : 'run'}
</button>
</div>`}>
<textarea rows="9" spellcheck="false" value=${sql}
onInput=${(e) => setSql(e.target.value)}
onKeyDown=${(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') run(); }}></textarea>
<div class="row" style="gap:8px; margin-top:10px; flex-wrap:wrap">
<span class="muted small">samples:</span>
${SAMPLES.map((s) => html`
<button class="sm" key=${s.label}
onClick=${() => { setSql(s.sql); setDatabase(s.db); }}>${s.label}</button>`)}
<span class="grow"></span>
<span class="muted small mono">⌘/ctrl + enter</span>
</div>
<//>
${result && html`
<${Card} title="Result"
right=${html`<span class="row" style="gap:8px">
<${Pill}>${num(rows.length)} rows<//>
<${Pill}>${num(result.elapsed)}ms server<//>
<${Pill}>${num(result.clientMs)}ms total<//>
</span>`}>
${first && first.error
? html`<div class="muted small" style="color:var(--bad)">${first.error}</div>`
: !rows.length
? html`<${Empty}>No rows.<//>`
: html`
<div class="table-scroll">
<table>
<thead><tr>${columns.map((c) => html`<th key=${c}>${c}</th>`)}</tr></thead>
<tbody>
${rows.slice(0, 500).map((row, i) => html`
<tr key=${i}>
${columns.map((c) => html`
<td key=${c} class="mono small">${row[c] === null ? '—' : String(row[c])}</td>`)}
</tr>`)}
</tbody>
</table>
</div>
${rows.length > 500 && html`
<div class="muted small" style="margin-top:10px">
Showing the first 500 of ${num(rows.length)} rows.
</div>`}`}
<//>`}
</div>`;
}
+71
View File
@@ -0,0 +1,71 @@
import { html, useState, useEffect } from './core.js';
export const Card = ({ title, right, children }) => html`
<section class="card">
${title && html`
<div class="row spread" style="margin-bottom:12px">
<h2 style="margin:0">${title}</h2>
${right}
</div>`}
${children}
</section>`;
export const Stat = ({ label, value, sub, tone }) => html`
<section class="card">
<h2>${label}</h2>
<div class="stat" style=${tone ? `color:var(--${tone})` : null}>${value}</div>
${sub && html`<div class="stat-sub">${sub}</div>`}
</section>`;
export const Dot = ({ tone = 'idle', live }) => html`
<span class=${`dot ${tone}${live && tone === 'ok' ? ' live' : ''}`}></span>`;
export const Pill = ({ tone, children }) => html`<span class=${`pill ${tone || ''}`}>${children}</span>`;
export const Bar = ({ value, max, tone }) => {
const width = max > 0 ? Math.min(100, (Number(value) / Number(max)) * 100) : 0;
return html`<div class=${`bar ${tone || ''}`}><span style=${`width:${width}%`}></span></div>`;
};
export const Skeleton = ({ rows = 3 }) => html`
<div class="grid" style="gap:8px">
${Array.from({ length: rows }, (_, i) => html`
<div key=${i} class="skel" style=${`width:${90 - i * 12}%`}></div>`)}
</div>`;
export const Empty = ({ children }) => html`<div class="empty">${children}</div>`;
export function Toast({ toast }) {
const [shown, setShown] = useState(toast);
useEffect(() => {
setShown(toast);
if (!toast) return undefined;
const timer = setTimeout(() => setShown(null), toast.sticky ? 12000 : 4500);
return () => clearTimeout(timer);
}, [toast]);
if (!shown) return null;
return html`<div class=${`toast ${shown.tone || ''}`}>${shown.message}</div>`;
}
// Anything that changes production asks twice. The second click is a different
// button label so muscle memory cannot carry you through both.
export function Confirm({ label, confirmLabel, onConfirm, danger, disabled, busy }) {
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!armed) return undefined;
const timer = setTimeout(() => setArmed(false), 5000);
return () => clearTimeout(timer);
}, [armed]);
if (busy) return html`<button class="sm" disabled>working…</button>`;
if (!armed) {
return html`<button class=${`sm ${danger ? 'danger' : ''}`} disabled=${disabled}
onClick=${() => setArmed(true)}>${label}</button>`;
}
return html`
<span class="row" style="gap:6px">
<button class=${`sm ${danger ? 'danger' : 'primary'}`}
onClick=${() => { setArmed(false); onConfirm(); }}>${confirmLabel || 'confirm'}</button>
<button class="sm" onClick=${() => setArmed(false)}>cancel</button>
</span>`;
}