Live dashboard: replace data loaders with client-side polling

CORS: add CORSMiddleware to FastAPI for localhost:3000 so browser fetch
works across ports without errors.

All four pages now use async generator cells that call the API directly
and re-yield every 15 s — no data loader cache, no manual cache clearing.

Each page shows a live status bar (● green/red · last updated time).
Offline state shows the `make api` hint inline.

index.md: add "Registered Projects" section — polls
  /progress/?event_type=milestone&limit=500 and filters for
  "Project registered with State Hub:" events; shows project name,
  domain, path, and registration timestamp.

workstreams.md: fix broken domain column — now fetches /workstreams/
  and /topics/ in parallel and joins on topic_id client-side.
  Previously the domain column showed "unknown" for all rows because
  WorkstreamRead schema doesn't include domain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 23:19:26 +01:00
parent 935d8a6b83
commit 34b1114a01
5 changed files with 329 additions and 161 deletions

View File

@@ -1,6 +1,7 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from api.database import engine from api.database import engine
from api.routers import decisions, progress, state, tasks, topics, workstreams from api.routers import decisions, progress, state, tasks, topics, workstreams
@@ -19,6 +20,13 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_methods=["GET", "POST", "PATCH"],
allow_headers=["Content-Type"],
)
app.include_router(topics.router) app.include_router(topics.router)
app.include_router(workstreams.router) app.include_router(workstreams.router)
app.include_router(tasks.router) app.include_router(tasks.router)

View File

@@ -2,17 +2,47 @@
title: Decisions title: Decisions
--- ---
# Decisions ```js
const API = "http://127.0.0.1:8000";
const POLL = 15_000;
```
```js ```js
const decisions = await FileAttachment("data/decisions.json").json(); const decState = (async function*() {
const data = Array.isArray(decisions) ? decisions : []; while (true) {
let data = [], ok = false;
try {
const r = await fetch(`${API}/decisions/?limit=500`);
ok = r.ok;
data = ok ? await r.json() : [];
} catch {}
yield {data, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const data = decState.data ?? [];
const _ok = decState.ok ?? false;
const _ts = decState.ts;
const pending = data.filter(d => d.decision_type === "pending"); const pending = data.filter(d => d.decision_type === "pending");
const made = data.filter(d => d.decision_type === "made"); const made = data.filter(d => d.decision_type === "made");
``` ```
# Decisions
```js ```js
const tab = view(Inputs.select(["Pending", "Made"], { label: "View" })); display(html`<div class="live-bar">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: `<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`);
```
```js
const tab = view(Inputs.select(["Pending", "Made"], {label: "View"}));
``` ```
```js ```js
@@ -25,7 +55,17 @@ display(Inputs.table(shown.map(d => ({
Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—", Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—",
Rationale: (d.rationale ?? "").slice(0, 80), Rationale: (d.rationale ?? "").slice(0, 80),
Updated: new Date(d.updated_at).toLocaleDateString(), Updated: new Date(d.updated_at).toLocaleDateString(),
})), { rows: 30 })); })), {rows: 30}));
```
```js
if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) {
display(html`<div class="escalation-box">
<strong>⚠️ Escalated decisions require human approval before any action is taken (constitution §4).</strong>
<ul>${pending.filter(d => d.escalation_note).map(d =>
html`<li><b>${d.title}</b>: ${d.escalation_note}</li>`)}</ul>
</div>`);
}
``` ```
## Resolution Velocity ## Resolution Velocity
@@ -34,37 +74,31 @@ display(Inputs.table(shown.map(d => ({
import * as Plot from "npm:@observablehq/plot"; import * as Plot from "npm:@observablehq/plot";
const resolved = made.filter(d => d.decided_at); const resolved = made.filter(d => d.decided_at);
const byMonth = resolved.reduce((acc, d) => { const byMonth = Object.entries(
resolved.reduce((acc, d) => {
const m = d.decided_at.slice(0, 7); const m = d.decided_at.slice(0, 7);
acc[m] = (acc[m] ?? 0) + 1; acc[m] = (acc[m] ?? 0) + 1;
return acc; return acc;
}, {}); }, {})
).map(([month, count]) => ({month, count}));
display(Plot.plot({ display(byMonth.length === 0
title: "Decisions Resolved per Month", ? html`<p style="color:gray">No resolved decisions yet.</p>`
x: { label: "Month", tickRotate: -30 }, : Plot.plot({
y: { label: "Count", grid: true }, title: "Decisions resolved per month",
x: {label: "Month", tickRotate: -30},
y: {label: "Count", grid: true},
marks: [ marks: [
Plot.barY( Plot.barY(byMonth, {x: "month", y: "count", fill: "steelblue", tip: true}),
Object.entries(byMonth).map(([month, count]) => ({ month, count })),
{ x: "month", y: "count", fill: "steelblue", tip: true }
),
Plot.ruleY([0]), Plot.ruleY([0]),
], ],
marginBottom: 60, marginBottom: 60,
width: 700, width: 700,
})); })
``` );
```js
if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) {
display(html`<div class="escalation-box">
<strong>⚠️ Escalated decisions require human approval before any action is taken (constitution §4).</strong>
<ul>${pending.filter(d => d.escalation_note).map(d => html`<li><b>${d.title}</b>: ${d.escalation_note}</li>`)}</ul>
</div>`);
}
``` ```
<style> <style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
.escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; } .escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
</style> </style>

View File

@@ -2,67 +2,131 @@
title: Overview title: Overview
--- ---
# Custodian State Hub ```js
const API = "http://127.0.0.1:8000";
const POLL = 15_000;
```
```js ```js
const summary = await FileAttachment("data/summary.json").json(); // Live polling — yields {data, ok, ts} every POLL ms
const summaryState = (async function*() {
while (true) {
let data, ok = false;
try {
const r = await fetch(`${API}/state/summary`);
ok = r.ok;
data = ok ? await r.json() : {error: `HTTP ${r.status}`};
} catch (e) {
data = {error: "API unreachable"};
}
yield {data, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const summary = summaryState.data ?? {};
const _ok = summaryState.ok ?? false;
const _ts = summaryState.ts;
const totals = summary.totals ?? {}; const totals = summary.totals ?? {};
const ws = totals.workstreams ?? {}; const ws = totals.workstreams ?? {};
const tasks = totals.tasks ?? {}; const tasks = totals.tasks ?? {};
const decisions = totals.decisions ?? {}; const decisions = totals.decisions ?? {};
const topics = totals.topics ?? {};
``` ```
```js ```js
if (summary.error) display(html`<div class="warning">⚠️ API unreachable: ${summary.error}. Run <code>make api</code>.</div>`); // Registered projects — milestone events tagged with registration
const regsState = (async function*() {
while (true) {
let rows = [];
try {
const r = await fetch(`${API}/progress/?event_type=milestone&limit=500`);
if (r.ok) {
const all = await r.json();
rows = all.filter(e => e.summary?.startsWith("Project registered with State Hub:"));
}
} catch {}
yield rows;
await new Promise(res => setTimeout(res, POLL));
}
})();
```
# Custodian State Hub
```js
display(html`<div class="live-bar">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: `<span style="color:red">Offline — run: <code>cd ~/the-custodian/state-hub && make api</code></span>`}
</div>`);
```
```js
if (summary.error) display(html`<div class="warning">⚠️ ${summary.error}</div>`);
``` ```
## Status ## Status
```js ```js
display(html`<div class="grid grid-cols-4" style="gap:1rem; margin-bottom:1.5rem;"> display(html`<div class="grid grid-cols-4" style="gap:1rem;margin-bottom:1.5rem">
<div class="card"> <div class="card">
<h3>Active Workstreams</h3> <h3>Active Workstreams</h3>
<p class="big-number">${ws.active ?? 0}</p> <p class="big-num">${ws.active ?? 0}</p>
<small>${ws.blocked ?? 0} blocked</small> <small>${ws.blocked ?? 0} blocked</small>
</div> </div>
<div class="card ${(decisions.open + decisions.escalated) > 0 ? 'warn' : ''}"> <div class="card ${(decisions.open + decisions.escalated) > 0 ? 'warn' : ''}">
<h3>Blocking Decisions</h3> <h3>Blocking Decisions</h3>
<p class="big-number">${(decisions.open ?? 0) + (decisions.escalated ?? 0)}</p> <p class="big-num">${(decisions.open ?? 0) + (decisions.escalated ?? 0)}</p>
<small>${decisions.escalated ?? 0} escalated</small> <small>${decisions.escalated ?? 0} escalated</small>
</div> </div>
<div class="card ${(tasks.blocked ?? 0) > 0 ? 'warn' : ''}"> <div class="card ${(tasks.blocked ?? 0) > 0 ? 'warn' : ''}">
<h3>Blocked Tasks</h3> <h3>Blocked Tasks</h3>
<p class="big-number">${tasks.blocked ?? 0}</p> <p class="big-num">${tasks.blocked ?? 0}</p>
<small>of ${tasks.total ?? 0} total</small> <small>of ${tasks.total ?? 0} total</small>
</div> </div>
<div class="card"> <div class="card">
<h3>Progress Events Today</h3> <h3>Events Today</h3>
<p class="big-number">${(summary.recent_progress ?? []).filter(e => e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}</p> <p class="big-num">${(summary.recent_progress ?? []).filter(e =>
e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}</p>
<small>last 20 shown below</small> <small>last 20 shown below</small>
</div> </div>
</div>`); </div>`);
``` ```
## Tasks by Domain ## Registered Projects
```js
const regs = regsState ?? [];
if (regs.length === 0) {
display(html`<p style="color:gray">No projects registered yet. Run <code>custodian register-project</code> inside a repo.</p>`);
} else {
display(Inputs.table(regs.map(e => ({
Project: e.detail?.project_path?.split("/").at(-1) ?? "—",
Domain: e.detail?.domain ?? "—",
Path: e.detail?.project_path ?? "—",
Registered: new Date(e.created_at).toLocaleString(),
})), {maxWidth: 900}));
}
```
## Open Workstreams by Domain
```js ```js
import * as Plot from "npm:@observablehq/plot"; import * as Plot from "npm:@observablehq/plot";
const tasksByDomain = []; const wsData = (summary.topics ?? []).map(t => ({
for (const topic of (summary.topics ?? [])) { domain: t.domain,
for (const ws of (topic.workstreams ?? [])) { count: (t.workstreams ?? []).length,
// workstream stubs don't include tasks in summary — show per-topic WS count as proxy }));
}
tasksByDomain.push({ domain: topic.domain, status: topic.status, count: (topic.workstreams ?? []).length });
}
display(Plot.plot({ display(Plot.plot({
title: "Open Workstreams by Domain", x: {label: "Domain"},
x: { label: "Domain" }, y: {label: "Open workstreams", grid: true},
y: { label: "Count", grid: true },
marks: [ marks: [
Plot.barY(tasksByDomain, { x: "domain", y: "count", fill: "domain", tip: true }), Plot.barY(wsData, {x: "domain", y: "count", fill: "domain", tip: true}),
Plot.ruleY([0]), Plot.ruleY([0]),
], ],
marginBottom: 80, marginBottom: 80,
@@ -89,8 +153,7 @@ if (blocking.length === 0) {
## Decisions Due Within 7 Days ## Decisions Due Within 7 Days
```js ```js
const now = new Date(); const in7 = new Date(Date.now() + 7*24*60*60*1000);
const in7 = new Date(now.getTime() + 7*24*60*60*1000);
const due = (summary.blocking_decisions ?? []).filter(d => d.deadline && new Date(d.deadline) <= in7); const due = (summary.blocking_decisions ?? []).filter(d => d.deadline && new Date(d.deadline) <= in7);
if (due.length === 0) { if (due.length === 0) {
display(html`<p>No decisions due in next 7 days.</p>`); display(html`<p>No decisions due in next 7 days.</p>`);
@@ -111,12 +174,13 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({
Type: e.event_type, Type: e.event_type,
Author: e.author ?? "—", Author: e.author ?? "—",
Summary: e.summary, Summary: e.summary,
})), { maxWidth: 900 })); })), {maxWidth: 900}));
``` ```
<style> <style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; } .card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
.card.warn { border: 2px solid orange; } .card.warn { border: 2px solid orange; }
.big-number { font-size: 2.5rem; font-weight: bold; margin: 0.25rem 0; } .big-num { font-size: 2.5rem; font-weight: bold; margin: 0.25rem 0; }
.warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; } .warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; }
</style> </style>

View File

@@ -2,25 +2,52 @@
title: Progress title: Progress
--- ---
```js
const API = "http://127.0.0.1:8000";
const POLL = 15_000;
```
```js
const progState = (async function*() {
while (true) {
let data = [], ok = false;
try {
const r = await fetch(`${API}/progress/?limit=500`);
ok = r.ok;
data = ok ? await r.json() : [];
} catch {}
yield {data, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const data = progState.data ?? [];
const _ok = progState.ok ?? false;
const _ts = progState.ts;
```
# Progress Log # Progress Log
*Append-only per constitution §5 — no deletions.* *Append-only per constitution §5 — no deletions.*
```js ```js
const events = await FileAttachment("data/progress.json").json(); display(html`<div class="live-bar">
const data = Array.isArray(events) ? events : []; <span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()} · ${data.length} events total`
: `<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`);
``` ```
```js ```js
const authorFilter = view(Inputs.select( const authorOpts = ["(all)", ...new Set(data.map(e => e.author ?? "unknown"))].sort();
["(all)", ...new Set(data.map(e => e.author ?? "unknown"))], const typeOpts = ["(all)", ...new Set(data.map(e => e.event_type))].sort();
{ label: "Author" }
)); const authorFilter = view(Inputs.select(authorOpts, {label: "Author"}));
const typeFilter = view(Inputs.select( const typeFilter = view(Inputs.select(typeOpts, {label: "Event type"}));
["(all)", ...new Set(data.map(e => e.event_type))], const sinceFilter = view(Inputs.date({label: "Since"}));
{ label: "Event type" }
));
const sinceFilter = view(Inputs.date({ label: "Since" }));
``` ```
```js ```js
@@ -37,7 +64,7 @@ display(Inputs.table(filtered.map(e => ({
Type: e.event_type, Type: e.event_type,
Author: e.author ?? "—", Author: e.author ?? "—",
Summary: e.summary, Summary: e.summary,
})), { rows: 50 })); })), {rows: 50}));
``` ```
## Event Volume (Last 30 Days) ## Event Volume (Last 30 Days)
@@ -45,33 +72,34 @@ display(Inputs.table(filtered.map(e => ({
```js ```js
import * as Plot from "npm:@observablehq/plot"; import * as Plot from "npm:@observablehq/plot";
const cutoff = new Date(); const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
cutoff.setDate(cutoff.getDate() - 30); const byDay = Object.entries(
data
const byDay = data
.filter(e => new Date(e.created_at) >= cutoff) .filter(e => new Date(e.created_at) >= cutoff)
.reduce((acc, e) => { .reduce((acc, e) => {
const day = e.created_at.slice(0, 10); const day = e.created_at.slice(0, 10);
acc[day] = (acc[day] ?? 0) + 1; acc[day] = (acc[day] ?? 0) + 1;
return acc; return acc;
}, {}); }, {})
).sort().map(([day, count]) => ({day, count}));
display(Plot.plot({ display(byDay.length === 0
title: "Progress Events per Day (30-day window)", ? html`<p style="color:gray">No events in the last 30 days.</p>`
x: { label: "Date", tickRotate: -30 }, : Plot.plot({
y: { label: "Events", grid: true }, title: "Progress events per day (30-day window)",
x: {label: "Date", tickRotate: -30},
y: {label: "Events", grid: true},
marks: [ marks: [
Plot.areaY( Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3}),
Object.entries(byDay).sort().map(([day, count]) => ({ day, count })), Plot.lineY(byDay, {x: "day", y: "count", stroke: "steelblue"}),
{ x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3 }
),
Plot.lineY(
Object.entries(byDay).sort().map(([day, count]) => ({ day, count })),
{ x: "day", y: "count", stroke: "steelblue" }
),
Plot.ruleY([0]), Plot.ruleY([0]),
], ],
marginBottom: 60, marginBottom: 60,
width: 750, width: 750,
})); })
);
``` ```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
</style>

View File

@@ -2,23 +2,62 @@
title: Workstreams title: Workstreams
--- ---
# Workstreams
```js ```js
const workstreams = await FileAttachment("data/workstreams.json").json(); const API = "http://127.0.0.1:8000";
const data = Array.isArray(workstreams) ? workstreams : []; const POLL = 15_000;
``` ```
```js ```js
const domainFilter = view(Inputs.select( // Fetch workstreams + topics in parallel, join on topic_id → domain/title
["(all)", ...new Set(data.map(w => w.domain ?? "unknown"))], const wsState = (async function*() {
{ label: "Domain" } while (true) {
)); let data = [], ok = false;
const statusFilter = view(Inputs.select( try {
["(all)", "active", "blocked", "completed", "archived"], const [rw, rt] = await Promise.all([
{ label: "Status" } fetch(`${API}/workstreams/`),
)); fetch(`${API}/topics/`),
const ownerFilter = view(Inputs.text({ label: "Owner contains" })); ]);
ok = rw.ok && rt.ok;
if (ok) {
const [wsList, topicList] = await Promise.all([rw.json(), rt.json()]);
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
data = wsList.map(w => ({
...w,
domain: topicMap[w.topic_id]?.domain ?? "unknown",
topic_title: topicMap[w.topic_id]?.title ?? "—",
}));
}
} catch {}
yield {data, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const data = wsState.data ?? [];
const _ok = wsState.ok ?? false;
const _ts = wsState.ts;
```
# Workstreams
```js
display(html`<div class="live-bar">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: `<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`);
```
```js
const domainOpts = ["(all)", ...new Set(data.map(w => w.domain))].sort();
const statusOpts = ["(all)", "active", "blocked", "completed", "archived"];
const domainFilter = view(Inputs.select(domainOpts, {label: "Domain"}));
const statusFilter = view(Inputs.select(statusOpts, {label: "Status"}));
const ownerFilter = view(Inputs.text({label: "Owner contains"}));
``` ```
```js ```js
@@ -28,40 +67,35 @@ const filtered = data.filter(w =>
(!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase())) (!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase()))
); );
const STATUS_COLOR = {
active: "green",
blocked: "orange",
completed: "blue",
archived: "gray",
};
display(Inputs.table(filtered.map(w => ({ display(Inputs.table(filtered.map(w => ({
Title: w.title, Title: w.title,
Domain: w.domain, Domain: w.domain,
Status: w.status, Status: w.status,
Owner: w.owner ?? "—", Owner: w.owner ?? "—",
"Due": w.due_date ?? "—", Due: w.due_date ?? "—",
"Updated": new Date(w.updated_at).toLocaleDateString(), Updated: new Date(w.updated_at).toLocaleDateString(),
})), { })), {rows: 20}));
rows: 20,
}));
``` ```
## Status Distribution
```js ```js
import * as Plot from "npm:@observablehq/plot"; import * as Plot from "npm:@observablehq/plot";
display(Plot.plot({ const byStatus = Object.entries(
title: "Workstream Status Distribution",
marks: [
Plot.barX(
Object.entries(
filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {}) filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {})
).map(([status, count]) => ({ status, count })), ).map(([status, count]) => ({status, count}));
{ y: "status", x: "count", fill: "status", tip: true }
), display(Plot.plot({
marks: [
Plot.barX(byStatus, {y: "status", x: "count", fill: "status", tip: true}),
Plot.ruleX([0]), Plot.ruleX([0]),
], ],
marginLeft: 80, marginLeft: 80,
width: 500, width: 500,
})); }));
``` ```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
</style>