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>`;
}