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:
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Duriin Ops</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/ops.css?v=20260901-1">
|
||||||
|
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root">
|
||||||
|
<div class="boot">
|
||||||
|
<div class="boot-mark">DURIIN</div>
|
||||||
|
<div class="boot-note">loading console…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UMD builds, no bundler and no in-browser transpiler. htm gives us JSX-ish
|
||||||
|
tagged templates for ~700 bytes, which is the whole reason we can skip Babel
|
||||||
|
standalone -- that thing compiles on every page load and is exactly the kind
|
||||||
|
of slow we are trying to get away from. -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js" crossorigin></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js" crossorigin></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/htm/3.1.1/htm.js" crossorigin></script>
|
||||||
|
<script type="module" src="/admin/assets/js/ops/main.js?v=20260901-1"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/* Ops console. Dark by default because this is a thing you stare at during an
|
||||||
|
incident, but the tokens are defined so a light theme is a one line change. */
|
||||||
|
:root {
|
||||||
|
--bg: #0b0d10;
|
||||||
|
--panel: #12161b;
|
||||||
|
--panel-2: #171c22;
|
||||||
|
--line: #232a33;
|
||||||
|
--ink: #e6edf3;
|
||||||
|
--ink-dim: #9aa7b4;
|
||||||
|
--ink-faint: #6b7885;
|
||||||
|
--accent: #4c8dff;
|
||||||
|
--ok: #3fb950;
|
||||||
|
--warn: #d29922;
|
||||||
|
--bad: #f85149;
|
||||||
|
--radius: 10px;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 14px/1.5 var(--sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot { display: grid; place-content: center; gap: 8px; height: 100vh; text-align: center; }
|
||||||
|
.boot-mark { font: 600 20px var(--mono); letter-spacing: .3em; }
|
||||||
|
.boot-note { color: var(--ink-faint); font-size: 13px; }
|
||||||
|
|
||||||
|
/* ---------- shell ---------- */
|
||||||
|
.shell { display: grid; grid-template-columns: 208px 1fr; min-height: 100vh; }
|
||||||
|
|
||||||
|
.side {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 18px 12px;
|
||||||
|
position: sticky; top: 0; height: 100vh;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.brand { font: 600 14px var(--mono); letter-spacing: .28em; padding: 6px 10px 16px; }
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
padding: 8px 10px; border-radius: 8px; cursor: pointer;
|
||||||
|
color: var(--ink-dim); text-decoration: none; font-size: 13.5px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: var(--panel-2); color: var(--ink); }
|
||||||
|
.nav-item.active { background: var(--panel-2); color: var(--ink); border-color: var(--line); }
|
||||||
|
.nav-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.main { padding: 20px 24px 60px; min-width: 0; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.topbar h1 { font-size: 17px; margin: 0; font-weight: 600; }
|
||||||
|
.topbar .grow { flex: 1; }
|
||||||
|
|
||||||
|
/* ---------- primitives ---------- */
|
||||||
|
.grid { display: grid; gap: 14px; }
|
||||||
|
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); }
|
||||||
|
.cols-3 { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
font-size: 11px; text-transform: uppercase; letter-spacing: .12em;
|
||||||
|
color: var(--ink-faint); margin: 0 0 12px; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat { font: 600 24px/1.15 var(--mono); }
|
||||||
|
.stat-sub { color: var(--ink-faint); font-size: 12px; margin-top: 2px; }
|
||||||
|
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: none; }
|
||||||
|
.dot.ok { background: var(--ok); }
|
||||||
|
.dot.warn { background: var(--warn); }
|
||||||
|
.dot.bad { background: var(--bad); }
|
||||||
|
.dot.idle { background: var(--ink-faint); }
|
||||||
|
.dot.live { box-shadow: 0 0 0 0 rgba(63,185,80,.6); animation: pulse 2.4s infinite; }
|
||||||
|
@keyframes pulse {
|
||||||
|
70% { box-shadow: 0 0 0 7px rgba(63,185,80,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(63,185,80,0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.row + .row { margin-top: 8px; }
|
||||||
|
.spread { justify-content: space-between; }
|
||||||
|
.muted { color: var(--ink-faint); }
|
||||||
|
.mono { font-family: var(--mono); }
|
||||||
|
.small { font-size: 12px; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th {
|
||||||
|
text-align: left; font-weight: 500; color: var(--ink-faint);
|
||||||
|
font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
|
||||||
|
padding: 0 10px 8px 0; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
td { padding: 8px 10px 8px 0; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||||
|
tr:last-child td { border-bottom: 0; }
|
||||||
|
.num { text-align: right; font-family: var(--mono); }
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 2px 8px; border-radius: 999px; font-size: 11.5px;
|
||||||
|
border: 1px solid var(--line); background: var(--panel-2); color: var(--ink-dim);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
.pill.ok { color: var(--ok); border-color: rgba(63,185,80,.35); }
|
||||||
|
.pill.warn { color: var(--warn); border-color: rgba(210,153,34,.35); }
|
||||||
|
.pill.bad { color: var(--bad); border-color: rgba(248,81,73,.35); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit; color: var(--ink); background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
padding: 7px 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { border-color: var(--accent); }
|
||||||
|
button:disabled { opacity: .45; cursor: default; }
|
||||||
|
button.primary { background: var(--accent); border-color: var(--accent); color: #06101f; font-weight: 600; }
|
||||||
|
button.danger { color: var(--bad); border-color: rgba(248,81,73,.4); }
|
||||||
|
button.danger:hover:not(:disabled) { background: rgba(248,81,73,.12); border-color: var(--bad); }
|
||||||
|
button.sm { padding: 4px 9px; font-size: 12px; }
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
font: inherit; color: var(--ink); background: var(--bg);
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px;
|
||||||
|
}
|
||||||
|
textarea { font-family: var(--mono); font-size: 12.5px; width: 100%; resize: vertical; }
|
||||||
|
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin: 0; padding: 12px; background: var(--bg);
|
||||||
|
border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
overflow: auto; max-height: 460px;
|
||||||
|
font: 12px/1.5 var(--mono); color: var(--ink-dim);
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- bars ---------- */
|
||||||
|
.bar { height: 6px; border-radius: 3px; background: var(--panel-2); overflow: hidden; }
|
||||||
|
.bar > span { display: block; height: 100%; background: var(--accent); }
|
||||||
|
.bar.ok > span { background: var(--ok); }
|
||||||
|
.bar.warn > span { background: var(--warn); }
|
||||||
|
.bar.bad > span { background: var(--bad); }
|
||||||
|
|
||||||
|
.gate { display: grid; gap: 9px; }
|
||||||
|
.gate-row { display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; align-items: center; }
|
||||||
|
.gate-label { font-size: 12.5px; color: var(--ink-dim); }
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
|
border: 1px solid rgba(248,81,73,.4); background: rgba(248,81,73,.08);
|
||||||
|
border-radius: var(--radius); padding: 12px 14px; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.banner.warn { border-color: rgba(210,153,34,.4); background: rgba(210,153,34,.08); }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed; right: 18px; bottom: 18px; z-index: 50;
|
||||||
|
background: var(--panel); border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; padding: 11px 14px; max-width: 380px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
.toast.bad { border-color: rgba(248,81,73,.5); }
|
||||||
|
.toast.ok { border-color: rgba(63,185,80,.45); }
|
||||||
|
|
||||||
|
.empty { color: var(--ink-faint); font-size: 13px; padding: 8px 0; }
|
||||||
|
|
||||||
|
.skel {
|
||||||
|
background: linear-gradient(90deg, var(--panel-2) 25%, #1d232b 37%, var(--panel-2) 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: shimmer 1.3s ease infinite;
|
||||||
|
border-radius: 6px; height: 14px;
|
||||||
|
}
|
||||||
|
@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
.side {
|
||||||
|
position: static; height: auto; flex-direction: row; overflow-x: auto;
|
||||||
|
border-right: 0; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.brand { padding: 6px 10px; }
|
||||||
|
.nav-spacer { display: none; }
|
||||||
|
.main { padding: 16px; }
|
||||||
|
}
|
||||||
+154
@@ -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>`;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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>`;
|
||||||
|
}
|
||||||
@@ -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} />`);
|
||||||
@@ -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>`;
|
||||||
|
}
|
||||||
@@ -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>`;
|
||||||
|
}
|
||||||
@@ -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>`;
|
||||||
|
}
|
||||||
@@ -221,6 +221,16 @@ function initAutonomySchema(db) {
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Runtime knobs the operator can change without a redeploy. Execution mode used
|
||||||
|
-- to live only in AUTONOMY_EXECUTION_MODE, which means flipping it needed a
|
||||||
|
-- container restart, which is the last thing you want during an incident.
|
||||||
|
CREATE TABLE IF NOT EXISTS autonomy_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO autonomy_schema(version) VALUES (${AUTONOMY_SCHEMA_VERSION});
|
INSERT OR IGNORE INTO autonomy_schema(version) VALUES (${AUTONOMY_SCHEMA_VERSION});
|
||||||
`);
|
`);
|
||||||
for (const statement of [
|
for (const statement of [
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Runtime settings for the autonomy stack. The env vars stay the default, the
|
||||||
|
// table is the override, so nothing changes behaviour until somebody deliberately
|
||||||
|
// writes a row. Kept deliberately tiny -- this is a control plane, not a config
|
||||||
|
// system, and every key here can move real money or stop the pipeline.
|
||||||
|
|
||||||
|
const EXECUTION_MODES = ['shadow', 'paper'];
|
||||||
|
|
||||||
|
const KEYS = {
|
||||||
|
executionMode: 'execution_mode',
|
||||||
|
killSwitch: 'execution_kill_switch',
|
||||||
|
};
|
||||||
|
|
||||||
|
function readSetting(db, key) {
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT value FROM autonomy_settings WHERE key = ?').get(key);
|
||||||
|
return row ? row.value : null;
|
||||||
|
} catch (error) {
|
||||||
|
// A missing table means an older schema, which should behave like "no override"
|
||||||
|
// rather than taking the worker down with it.
|
||||||
|
console.error(`[settings] could not read ${key}:`, error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSetting(db, key, value, updatedBy = 'admin') {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO autonomy_settings(key, value, updated_by) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
`).run(key, String(value), updatedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truthy strings people actually type, rather than only accepting 'true'
|
||||||
|
function isOn(value) {
|
||||||
|
return ['1', 'true', 'on', 'yes', 'engaged'].includes(String(value || '').trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExecutionControls(db, env = process.env) {
|
||||||
|
const stored = readSetting(db, KEYS.executionMode);
|
||||||
|
const fallback = env.AUTONOMY_EXECUTION_MODE || 'shadow';
|
||||||
|
const mode = EXECUTION_MODES.includes(String(stored)) ? String(stored) : fallback;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: EXECUTION_MODES.includes(mode) ? mode : 'shadow',
|
||||||
|
modeSource: EXECUTION_MODES.includes(String(stored)) ? 'settings' : 'env',
|
||||||
|
killSwitch: isOn(readSetting(db, KEYS.killSwitch)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExecutionMode(db, mode, updatedBy) {
|
||||||
|
if (!EXECUTION_MODES.includes(mode)) {
|
||||||
|
throw new Error(`unsupported execution mode: ${mode} (expected ${EXECUTION_MODES.join(' or ')})`);
|
||||||
|
}
|
||||||
|
writeSetting(db, KEYS.executionMode, mode, updatedBy);
|
||||||
|
return getExecutionControls(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKillSwitch(db, engaged, updatedBy) {
|
||||||
|
writeSetting(db, KEYS.killSwitch, engaged ? 'true' : 'false', updatedBy);
|
||||||
|
return getExecutionControls(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
EXECUTION_MODES, KEYS, isOn,
|
||||||
|
readSetting, writeSetting,
|
||||||
|
getExecutionControls, setExecutionMode, setKillSwitch,
|
||||||
|
};
|
||||||
+10
-2
@@ -7,6 +7,7 @@ const config = require('../config');
|
|||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const { openRuntimeDb, isPostgresEnabled } = require('../db/runtime');
|
const { openRuntimeDb, isPostgresEnabled } = require('../db/runtime');
|
||||||
const pg = require('../db/pgAsync');
|
const pg = require('../db/pgAsync');
|
||||||
|
const opsRoutes = require('./ops');
|
||||||
|
|
||||||
let idb = null;
|
let idb = null;
|
||||||
let adb = null;
|
let adb = null;
|
||||||
@@ -265,6 +266,7 @@ const pagesDir = path.join(publicDir, 'pages');
|
|||||||
// map pretty url → page html file. keep these close to the routes so its
|
// map pretty url → page html file. keep these close to the routes so its
|
||||||
// obvious when a page gets added or renamed.
|
// obvious when a page gets added or renamed.
|
||||||
const pageMap = {
|
const pageMap = {
|
||||||
|
'/admin/console': path.join(publicDir, 'app.html'),
|
||||||
'/admin/autonomy': path.join(pagesDir, 'autonomy.html'),
|
'/admin/autonomy': path.join(pagesDir, 'autonomy.html'),
|
||||||
'/admin/ingest/articles': path.join(pagesDir, 'ingest', 'articles.html'),
|
'/admin/ingest/articles': path.join(pagesDir, 'ingest', 'articles.html'),
|
||||||
'/admin/ingest/events': path.join(pagesDir, 'ingest', 'events.html'),
|
'/admin/ingest/events': path.join(pagesDir, 'ingest', 'events.html'),
|
||||||
@@ -283,6 +285,10 @@ function sendPage(reply, filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function adminRoutes(fastify) {
|
async function adminRoutes(fastify) {
|
||||||
|
// Control plane for the ops dashboard. Lives in its own file, shares this one's
|
||||||
|
// auth and db handles so there is exactly one of each.
|
||||||
|
fastify.register(opsRoutes, { checkAuth, getIntelligenceDb, getArchiveDb });
|
||||||
|
|
||||||
|
|
||||||
// gate every request under /admin/* behind basic auth (covers pages, api, and assets)
|
// gate every request under /admin/* behind basic auth (covers pages, api, and assets)
|
||||||
fastify.addHook('onRequest', async (request, reply) => {
|
fastify.addHook('onRequest', async (request, reply) => {
|
||||||
@@ -303,9 +309,11 @@ async function adminRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// top-level entry — the autonomy control room is the primary product surface
|
// top-level entry — the ops console is the primary surface now. The older
|
||||||
|
// per-page admin is still served underneath, the console frames the d3 graph
|
||||||
|
// from it rather than duplicating that visualisation.
|
||||||
fastify.get('/admin', async (request, reply) => {
|
fastify.get('/admin', async (request, reply) => {
|
||||||
reply.redirect('/admin/autonomy');
|
reply.redirect('/admin/console');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ingest root — redirect to the articles subsection
|
// ingest root — redirect to the articles subsection
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// Control plane for the operations dashboard.
|
||||||
|
//
|
||||||
|
// Kept apart from admin.js on purpose: everything in here either changes what the
|
||||||
|
// autonomy stack does or is read by an operator while something is on fire, so it
|
||||||
|
// wants to stay small enough to audit in one sitting.
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const { getExecutionControls, setExecutionMode, setKillSwitch, EXECUTION_MODES } = require('../autonomy/settings');
|
||||||
|
|
||||||
|
// Which of the pipeline stages we consider "recent enough to be alive". These are
|
||||||
|
// generous, they exist to catch a stall not to police a few seconds of jitter.
|
||||||
|
const STALE_AFTER_MINUTES = { ingest: 90, prediction: 180, outcome: 24 * 60 };
|
||||||
|
|
||||||
|
const ANALYSES = {
|
||||||
|
reaction: {
|
||||||
|
label: 'Reaction conditioning',
|
||||||
|
script: 'scripts/analyze-reaction-conditioning.js',
|
||||||
|
detail: 'Does the initial market reaction predict anything. Read only, a few minutes.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function minutesSince(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const stamp = String(value).includes('T') ? String(value) : `${String(value).replace(' ', 'T')}Z`;
|
||||||
|
const then = Date.parse(stamp);
|
||||||
|
if (!Number.isFinite(then)) return null;
|
||||||
|
return Math.max(0, (Date.now() - then) / 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function one(db, sql, params = []) {
|
||||||
|
try {
|
||||||
|
return db.prepare(sql).get(...params) || {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ops] query failed:', sql.trim().slice(0, 80), error.message);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function many(db, sql, params = []) {
|
||||||
|
try {
|
||||||
|
return db.prepare(sql).all(...params) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ops] query failed:', sql.trim().slice(0, 80), error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One request for the whole dashboard. The old admin made the browser fire a
|
||||||
|
// handful of sequential calls and stitch them together, which is most of why it
|
||||||
|
// felt sluggish even though every individual endpoint was fast.
|
||||||
|
function buildOverview(intel) {
|
||||||
|
const predictions = one(intel, `
|
||||||
|
SELECT COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN origin='live' AND status='open' THEN 1 ELSE 0 END) AS live_open,
|
||||||
|
SUM(CASE WHEN origin='live' AND status='resolved' THEN 1 ELSE 0 END) AS live_resolved,
|
||||||
|
SUM(CASE WHEN origin IN ('historical','replay') AND status='resolved' THEN 1 ELSE 0 END) AS offline_resolved,
|
||||||
|
SUM(CASE WHEN status='unresolvable' THEN 1 ELSE 0 END) AS unresolvable,
|
||||||
|
MAX(created_at) AS latest
|
||||||
|
FROM autonomy_predictions
|
||||||
|
`);
|
||||||
|
|
||||||
|
const byOrigin = many(intel, `
|
||||||
|
SELECT p.origin, COUNT(*) AS total,
|
||||||
|
SUM(o.direction_correct) AS correct,
|
||||||
|
AVG(o.excess_return) AS mean_excess
|
||||||
|
FROM autonomy_predictions p JOIN autonomy_outcomes o ON o.prediction_id = p.id
|
||||||
|
GROUP BY p.origin
|
||||||
|
`);
|
||||||
|
|
||||||
|
// When live evidence actually arrives. This is the number that decides whether
|
||||||
|
// anything can ever qualify, and nothing in the old UI showed it.
|
||||||
|
const maturity = many(intel, `
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
|
||||||
|
const decisions = many(intel, "SELECT action, COUNT(*) AS n FROM autonomy_decisions GROUP BY action");
|
||||||
|
const intents = one(intel, 'SELECT COUNT(*) AS n FROM autonomy_order_intents');
|
||||||
|
|
||||||
|
const jobs = many(intel, `
|
||||||
|
SELECT job_type, lane, status, COUNT(*) AS n, MAX(created_at) AS newest
|
||||||
|
FROM autonomy_jobs GROUP BY job_type, lane, status
|
||||||
|
`);
|
||||||
|
const deadLetters = many(intel, `
|
||||||
|
SELECT job_type, lane, COUNT(*) AS n, substr(MAX(last_error), 1, 160) AS sample_error
|
||||||
|
FROM autonomy_jobs WHERE status='dead_letter' GROUP BY job_type, lane
|
||||||
|
`);
|
||||||
|
|
||||||
|
let cohorts = [];
|
||||||
|
try {
|
||||||
|
cohorts = many(intel, `
|
||||||
|
SELECT cohort_key, source, sample_size, distinct_instruments, top_instrument_share,
|
||||||
|
directional_probability, expected_excess_return
|
||||||
|
FROM autonomy_calibration_snapshots
|
||||||
|
WHERE cohort_key LIKE 'v2|%'
|
||||||
|
GROUP BY cohort_key, source
|
||||||
|
HAVING MAX(created_at) = created_at
|
||||||
|
ORDER BY sample_size DESC LIMIT 40
|
||||||
|
`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ops] cohort snapshot read failed:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcomes = one(intel, 'SELECT COUNT(*) AS n, MAX(evaluated_at) AS latest FROM autonomy_outcomes');
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
predictions,
|
||||||
|
byOrigin,
|
||||||
|
maturity,
|
||||||
|
decisions,
|
||||||
|
orderIntents: intents.n || 0,
|
||||||
|
outcomes,
|
||||||
|
jobs,
|
||||||
|
deadLetters,
|
||||||
|
cohorts,
|
||||||
|
freshness: {
|
||||||
|
predictionMinutes: minutesSince(predictions.latest),
|
||||||
|
outcomeMinutes: minutesSince(outcomes.latest),
|
||||||
|
thresholds: STALE_AFTER_MINUTES,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function opsRoutes(fastify, options) {
|
||||||
|
const { checkAuth, getIntelligenceDb, getArchiveDb } = options;
|
||||||
|
|
||||||
|
const withIntel = (reply) => {
|
||||||
|
const intel = getIntelligenceDb();
|
||||||
|
if (!intel) {
|
||||||
|
reply.code(503).send({ error: 'intelligence database unavailable' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return intel;
|
||||||
|
};
|
||||||
|
|
||||||
|
fastify.get('/admin/api/ops/overview', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
const intel = withIntel(reply);
|
||||||
|
if (!intel) return;
|
||||||
|
|
||||||
|
const payload = buildOverview(intel);
|
||||||
|
try {
|
||||||
|
payload.controls = getExecutionControls(intel);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ops] could not read execution controls:', error.message);
|
||||||
|
payload.controls = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive counts are the one genuinely expensive thing here, so they are
|
||||||
|
// cheap approximations rather than COUNT(*) over 2.2M rows on every poll.
|
||||||
|
try {
|
||||||
|
const archive = getArchiveDb ? getArchiveDb() : null;
|
||||||
|
if (archive) {
|
||||||
|
payload.archive = one(archive, `
|
||||||
|
SELECT MAX(id) AS max_id, MAX(ingested_at) AS latest_ingest FROM articles
|
||||||
|
`);
|
||||||
|
payload.freshness.ingestMinutes = minutesSince(payload.archive.latest_ingest);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ops] archive probe failed:', error.message);
|
||||||
|
payload.archive = null;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/admin/api/ops/settings', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
const intel = withIntel(reply);
|
||||||
|
if (!intel) return;
|
||||||
|
return { controls: getExecutionControls(intel), modes: EXECUTION_MODES, analyses: ANALYSES };
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/admin/api/ops/settings', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
const intel = withIntel(reply);
|
||||||
|
if (!intel) return;
|
||||||
|
const { mode, killSwitch } = request.body || {};
|
||||||
|
try {
|
||||||
|
if (mode !== undefined) setExecutionMode(intel, String(mode), 'admin-ui');
|
||||||
|
if (killSwitch !== undefined) setKillSwitch(intel, Boolean(killSwitch), 'admin-ui');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ops] settings write rejected:', error.message);
|
||||||
|
reply.code(400).send({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controls = getExecutionControls(intel);
|
||||||
|
console.log(`[ops] execution controls now mode=${controls.mode} kill=${controls.killSwitch}`);
|
||||||
|
return { controls };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Requeue is deliberately narrow: it only ever moves dead_letter back to pending,
|
||||||
|
// it never deletes and never edits payloads, so the worst case is repeated work.
|
||||||
|
fastify.post('/admin/api/ops/dead-letters/requeue', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
const intel = withIntel(reply);
|
||||||
|
if (!intel) return;
|
||||||
|
const { jobType, lane } = request.body || {};
|
||||||
|
|
||||||
|
const filters = ["status = 'dead_letter'"];
|
||||||
|
const params = [];
|
||||||
|
if (jobType) { filters.push('job_type = ?'); params.push(String(jobType)); }
|
||||||
|
if (lane) { filters.push('lane = ?'); params.push(String(lane)); }
|
||||||
|
const where = filters.join(' AND ');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const before = one(intel, `SELECT COUNT(*) AS n FROM autonomy_jobs WHERE ${where}`, params).n || 0;
|
||||||
|
if (!before) return { requeued: 0, remaining: 0 };
|
||||||
|
const result = intel.prepare(`
|
||||||
|
UPDATE autonomy_jobs
|
||||||
|
SET status='pending', attempts=0, available_at=datetime('now'),
|
||||||
|
leased_by=NULL, lease_expires_at=NULL,
|
||||||
|
last_error='requeued from the ops dashboard'
|
||||||
|
WHERE ${where}
|
||||||
|
`).run(...params);
|
||||||
|
const remaining = one(intel, `SELECT COUNT(*) AS n FROM autonomy_jobs WHERE ${where}`, params).n || 0;
|
||||||
|
console.log(`[ops] requeued ${result.changes} dead letters (jobType=${jobType || 'any'} lane=${lane || 'any'})`);
|
||||||
|
return { requeued: result.changes, before, remaining };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ops] requeue failed:', error.message, error.stack);
|
||||||
|
reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/admin/api/ops/analysis/:name', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
const spec = ANALYSES[request.params.name];
|
||||||
|
if (!spec) {
|
||||||
|
reply.code(404).send({ error: `unknown analysis: ${request.params.name}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = path.resolve(__dirname, '..', '..', spec.script);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile('node', [script], { timeout: 15 * 60 * 1000, maxBuffer: 8 * 1024 * 1024 },
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) console.error(`[ops] analysis ${request.params.name} failed:`, error.message);
|
||||||
|
resolve({
|
||||||
|
analysis: request.params.name,
|
||||||
|
label: spec.label,
|
||||||
|
ok: !error,
|
||||||
|
output: String(stdout || '').slice(-20000),
|
||||||
|
error: error ? String(stderr || error.message).slice(-4000) : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = opsRoutes;
|
||||||
|
module.exports.buildOverview = buildOverview;
|
||||||
|
module.exports.ANALYSES = ANALYSES;
|
||||||
@@ -3,6 +3,7 @@ const { openRuntimeDb } = require('../src/db/runtime');
|
|||||||
const { initAutonomySchema } = require('../src/autonomy/schema');
|
const { initAutonomySchema } = require('../src/autonomy/schema');
|
||||||
const { createOrderIntent } = require('../src/autonomy/orderIntents');
|
const { createOrderIntent } = require('../src/autonomy/orderIntents');
|
||||||
const { createAlpacaPaperClient } = require('../src/brokers/alpacaPaper');
|
const { createAlpacaPaperClient } = require('../src/brokers/alpacaPaper');
|
||||||
|
const { getExecutionControls } = require('../src/autonomy/settings');
|
||||||
|
|
||||||
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
||||||
|
|
||||||
@@ -12,10 +13,27 @@ async function runExecutionWorker({ intelligencePath, pollMs = 10000, mode = 'sh
|
|||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
db.pragma('busy_timeout = 5000');
|
db.pragma('busy_timeout = 5000');
|
||||||
initAutonomySchema(db);
|
initAutonomySchema(db);
|
||||||
const paperClient = mode === 'paper'
|
|
||||||
? createAlpacaPaperClient({ keyId: process.env.ALPACA_PAPER_KEY_ID, secretKey: process.env.ALPACA_PAPER_SECRET_KEY })
|
// mode is re-read every poll now. the env var is only the default, so the kill
|
||||||
: null;
|
// switch actually works during an incident instead of needing a redeploy first.
|
||||||
|
let paperClient = null;
|
||||||
|
let lastMode = null;
|
||||||
|
let lastKill = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
const controls = getExecutionControls(db, { AUTONOMY_EXECUTION_MODE: mode });
|
||||||
|
if (controls.mode !== lastMode) {
|
||||||
|
console.log(`[${workerId}] execution mode = ${controls.mode} (from ${controls.modeSource})`);
|
||||||
|
lastMode = controls.mode;
|
||||||
|
}
|
||||||
|
if (controls.killSwitch !== lastKill) {
|
||||||
|
console.log(`[${workerId}] kill switch ${controls.killSwitch ? 'ENGAGED, no orders will be placed' : 'released'}`);
|
||||||
|
lastKill = controls.killSwitch;
|
||||||
|
}
|
||||||
|
if (controls.mode === 'paper' && !paperClient) {
|
||||||
|
paperClient = createAlpacaPaperClient({ keyId: process.env.ALPACA_PAPER_KEY_ID, secretKey: process.env.ALPACA_PAPER_SECRET_KEY });
|
||||||
|
}
|
||||||
|
if (controls.mode !== 'paper') paperClient = null;
|
||||||
if (paperClient) {
|
if (paperClient) {
|
||||||
try {
|
try {
|
||||||
const [account, positions, orders] = await Promise.all([
|
const [account, positions, orders] = await Promise.all([
|
||||||
@@ -49,7 +67,9 @@ async function runExecutionWorker({ intelligencePath, pollMs = 10000, mode = 'sh
|
|||||||
console.error(`[${workerId}] broker reconciliation:`, error.message);
|
console.error(`[${workerId}] broker reconciliation:`, error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const decisions = db.prepare(`
|
// Broker reconciliation above still runs while the switch is engaged, we want
|
||||||
|
// to keep seeing the account. It is order creation specifically that stops.
|
||||||
|
const decisions = controls.killSwitch ? [] : db.prepare(`
|
||||||
SELECT d.id FROM autonomy_decisions d
|
SELECT d.id FROM autonomy_decisions d
|
||||||
JOIN autonomy_predictions p ON p.id = d.prediction_id
|
JOIN autonomy_predictions p ON p.id = d.prediction_id
|
||||||
LEFT JOIN autonomy_order_intents oi ON oi.decision_id = d.id
|
LEFT JOIN autonomy_order_intents oi ON oi.decision_id = d.id
|
||||||
@@ -59,8 +79,8 @@ async function runExecutionWorker({ intelligencePath, pollMs = 10000, mode = 'sh
|
|||||||
for (const decision of decisions) {
|
for (const decision of decisions) {
|
||||||
try {
|
try {
|
||||||
const intent = createOrderIntent(db, decision.id, notional, { tradable: true, maxNotional: notional });
|
const intent = createOrderIntent(db, decision.id, notional, { tradable: true, maxNotional: notional });
|
||||||
if (mode === 'paper') db.prepare("UPDATE autonomy_order_intents SET status='pending', updated_at=datetime('now') WHERE client_order_id=?").run(intent.clientOrderId);
|
if (controls.mode === 'paper') db.prepare("UPDATE autonomy_order_intents SET status='pending', updated_at=datetime('now') WHERE client_order_id=?").run(intent.clientOrderId);
|
||||||
console.log(`[${workerId}] ${mode} intent ${intent.clientOrderId}`);
|
console.log(`[${workerId}] ${controls.mode} intent ${intent.clientOrderId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[${workerId}] decision ${decision.id}:`, error.message);
|
console.error(`[${workerId}] decision ${decision.id}:`, error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user