From cc0f10b031209173c26ad95c33f924177eff41f7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 23:19:26 +0100 Subject: [PATCH] Live dashboard: replace data loaders with client-side polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- state-hub/api/main.py | 8 ++ state-hub/dashboard/src/decisions.md | 118 ++++++++++++-------- state-hub/dashboard/src/index.md | 142 ++++++++++++++++++------- state-hub/dashboard/src/progress.md | 116 ++++++++++++-------- state-hub/dashboard/src/workstreams.md | 106 +++++++++++------- 5 files changed, 329 insertions(+), 161 deletions(-) diff --git a/state-hub/api/main.py b/state-hub/api/main.py index 04f57bd..e776a1c 100644 --- a/state-hub/api/main.py +++ b/state-hub/api/main.py @@ -1,6 +1,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from api.database import engine from api.routers import decisions, progress, state, tasks, topics, workstreams @@ -19,6 +20,13 @@ app = FastAPI( 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(workstreams.router) app.include_router(tasks.router) diff --git a/state-hub/dashboard/src/decisions.md b/state-hub/dashboard/src/decisions.md index 7885a6e..82b462d 100644 --- a/state-hub/dashboard/src/decisions.md +++ b/state-hub/dashboard/src/decisions.md @@ -2,30 +2,70 @@ title: Decisions --- -# Decisions - ```js -const decisions = await FileAttachment("data/decisions.json").json(); -const data = Array.isArray(decisions) ? decisions : []; -const pending = data.filter(d => d.decision_type === "pending"); -const made = data.filter(d => d.decision_type === "made"); +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; ``` ```js -const tab = view(Inputs.select(["Pending", "Made"], { label: "View" })); +const decState = (async function*() { + 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 made = data.filter(d => d.decision_type === "made"); +``` + +# Decisions + +```js +display(html`
+ + ${_ok + ? `Live · updated ${_ts?.toLocaleTimeString()}` + : `Offline — run: make api`} +
`); +``` + +```js +const tab = view(Inputs.select(["Pending", "Made"], {label: "View"})); ``` ```js const shown = tab === "Pending" ? pending : made; display(Inputs.table(shown.map(d => ({ - Title: d.title, - Status: d.status + (d.escalation_note ? " ⚠️" : ""), - Decided_by: d.decided_by ?? "—", - Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—", - Rationale: (d.rationale ?? "").slice(0, 80), - Updated: new Date(d.updated_at).toLocaleDateString(), -})), { rows: 30 })); + Title: d.title, + Status: d.status + (d.escalation_note ? " ⚠️" : ""), + Decided_by: d.decided_by ?? "—", + Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—", + Rationale: (d.rationale ?? "").slice(0, 80), + Updated: new Date(d.updated_at).toLocaleDateString(), +})), {rows: 30})); +``` + +```js +if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) { + display(html`
+ ⚠️ Escalated decisions require human approval before any action is taken (constitution §4). +
    ${pending.filter(d => d.escalation_note).map(d => + html`
  • ${d.title}: ${d.escalation_note}
  • `)}
+
`); +} ``` ## Resolution Velocity @@ -34,37 +74,31 @@ display(Inputs.table(shown.map(d => ({ import * as Plot from "npm:@observablehq/plot"; const resolved = made.filter(d => d.decided_at); -const byMonth = resolved.reduce((acc, d) => { - const m = d.decided_at.slice(0, 7); - acc[m] = (acc[m] ?? 0) + 1; - return acc; -}, {}); +const byMonth = Object.entries( + resolved.reduce((acc, d) => { + const m = d.decided_at.slice(0, 7); + acc[m] = (acc[m] ?? 0) + 1; + return acc; + }, {}) +).map(([month, count]) => ({month, count})); -display(Plot.plot({ - title: "Decisions Resolved per Month", - x: { label: "Month", tickRotate: -30 }, - y: { label: "Count", grid: true }, - marks: [ - Plot.barY( - Object.entries(byMonth).map(([month, count]) => ({ month, count })), - { x: "month", y: "count", fill: "steelblue", tip: true } - ), - Plot.ruleY([0]), - ], - marginBottom: 60, - width: 700, -})); -``` - -```js -if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) { - display(html`
- ⚠️ Escalated decisions require human approval before any action is taken (constitution §4). -
    ${pending.filter(d => d.escalation_note).map(d => html`
  • ${d.title}: ${d.escalation_note}
  • `)}
-
`); -} +display(byMonth.length === 0 + ? html`

No resolved decisions yet.

` + : Plot.plot({ + title: "Decisions resolved per month", + x: {label: "Month", tickRotate: -30}, + y: {label: "Count", grid: true}, + marks: [ + Plot.barY(byMonth, {x: "month", y: "count", fill: "steelblue", tip: true}), + Plot.ruleY([0]), + ], + marginBottom: 60, + width: 700, + }) +); ``` diff --git a/state-hub/dashboard/src/index.md b/state-hub/dashboard/src/index.md index e3e45af..f9d3998 100644 --- a/state-hub/dashboard/src/index.md +++ b/state-hub/dashboard/src/index.md @@ -2,67 +2,131 @@ title: Overview --- -# Custodian State Hub - ```js -const summary = await FileAttachment("data/summary.json").json(); -const totals = summary.totals ?? {}; -const ws = totals.workstreams ?? {}; -const tasks = totals.tasks ?? {}; -const decisions = totals.decisions ?? {}; -const topics = totals.topics ?? {}; +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; ``` ```js -if (summary.error) display(html`
⚠️ API unreachable: ${summary.error}. Run make api.
`); +// 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 ws = totals.workstreams ?? {}; +const tasks = totals.tasks ?? {}; +const decisions = totals.decisions ?? {}; +``` + +```js +// 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`
+ + ${_ok + ? `Live · updated ${_ts?.toLocaleTimeString()}` + : `Offline — run: cd ~/the-custodian/state-hub && make api`} +
`); +``` + +```js +if (summary.error) display(html`
⚠️ ${summary.error}
`); ``` ## Status ```js -display(html`
+display(html`

Active Workstreams

-

${ws.active ?? 0}

+

${ws.active ?? 0}

${ws.blocked ?? 0} blocked

Blocking Decisions

-

${(decisions.open ?? 0) + (decisions.escalated ?? 0)}

+

${(decisions.open ?? 0) + (decisions.escalated ?? 0)}

${decisions.escalated ?? 0} escalated

Blocked Tasks

-

${tasks.blocked ?? 0}

+

${tasks.blocked ?? 0}

of ${tasks.total ?? 0} total
-

Progress Events Today

-

${(summary.recent_progress ?? []).filter(e => e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}

+

Events Today

+

${(summary.recent_progress ?? []).filter(e => + e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}

last 20 shown below
`); ``` -## Tasks by Domain +## Registered Projects + +```js +const regs = regsState ?? []; +if (regs.length === 0) { + display(html`

No projects registered yet. Run custodian register-project inside a repo.

`); +} 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 import * as Plot from "npm:@observablehq/plot"; -const tasksByDomain = []; -for (const topic of (summary.topics ?? [])) { - for (const ws of (topic.workstreams ?? [])) { - // 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 }); -} +const wsData = (summary.topics ?? []).map(t => ({ + domain: t.domain, + count: (t.workstreams ?? []).length, +})); display(Plot.plot({ - title: "Open Workstreams by Domain", - x: { label: "Domain" }, - y: { label: "Count", grid: true }, + x: {label: "Domain"}, + y: {label: "Open workstreams", grid: true}, 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]), ], marginBottom: 80, @@ -78,9 +142,9 @@ if (blocking.length === 0) { display(html`

✓ No blocking decisions.

`); } else { display(Inputs.table(blocking.map(d => ({ - Title: d.title, - Status: d.status, - Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—", + Title: d.title, + Status: d.status, + Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—", Escalated: d.escalation_note ? "⚠️" : "", })))); } @@ -89,16 +153,15 @@ if (blocking.length === 0) { ## Decisions Due Within 7 Days ```js -const now = new Date(); -const in7 = new Date(now.getTime() + 7*24*60*60*1000); +const in7 = new Date(Date.now() + 7*24*60*60*1000); const due = (summary.blocking_decisions ?? []).filter(d => d.deadline && new Date(d.deadline) <= in7); if (due.length === 0) { display(html`

No decisions due in next 7 days.

`); } else { display(Inputs.table(due.map(d => ({ - Title: d.title, + Title: d.title, Deadline: new Date(d.deadline).toLocaleString(), - Status: d.status, + Status: d.status, })))); } ``` @@ -107,16 +170,17 @@ if (due.length === 0) { ```js display(Inputs.table((summary.recent_progress ?? []).map(e => ({ - Time: new Date(e.created_at).toLocaleString(), - Type: e.event_type, - Author: e.author ?? "—", + Time: new Date(e.created_at).toLocaleString(), + Type: e.event_type, + Author: e.author ?? "—", Summary: e.summary, -})), { maxWidth: 900 })); +})), {maxWidth: 900})); ``` diff --git a/state-hub/dashboard/src/progress.md b/state-hub/dashboard/src/progress.md index 3b9f660..1726cbe 100644 --- a/state-hub/dashboard/src/progress.md +++ b/state-hub/dashboard/src/progress.md @@ -2,42 +2,69 @@ 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 *Append-only per constitution §5 — no deletions.* ```js -const events = await FileAttachment("data/progress.json").json(); -const data = Array.isArray(events) ? events : []; +display(html`
+ + ${_ok + ? `Live · updated ${_ts?.toLocaleTimeString()} · ${data.length} events total` + : `Offline — run: make api`} +
`); ``` ```js -const authorFilter = view(Inputs.select( - ["(all)", ...new Set(data.map(e => e.author ?? "unknown"))], - { label: "Author" } -)); -const typeFilter = view(Inputs.select( - ["(all)", ...new Set(data.map(e => e.event_type))], - { label: "Event type" } -)); -const sinceFilter = view(Inputs.date({ label: "Since" })); +const authorOpts = ["(all)", ...new Set(data.map(e => e.author ?? "unknown"))].sort(); +const typeOpts = ["(all)", ...new Set(data.map(e => e.event_type))].sort(); + +const authorFilter = view(Inputs.select(authorOpts, {label: "Author"})); +const typeFilter = view(Inputs.select(typeOpts, {label: "Event type"})); +const sinceFilter = view(Inputs.date({label: "Since"})); ``` ```js const filtered = data.filter(e => (authorFilter === "(all)" || (e.author ?? "unknown") === authorFilter) && - (typeFilter === "(all)" || e.event_type === typeFilter) && + (typeFilter === "(all)" || e.event_type === typeFilter) && (!sinceFilter || new Date(e.created_at) >= sinceFilter) ); display(html`

${filtered.length} events shown (append-only, no deletions).

`); display(Inputs.table(filtered.map(e => ({ - Time: new Date(e.created_at).toLocaleString(), - Type: e.event_type, - Author: e.author ?? "—", + Time: new Date(e.created_at).toLocaleString(), + Type: e.event_type, + Author: e.author ?? "—", Summary: e.summary, -})), { rows: 50 })); +})), {rows: 50})); ``` ## Event Volume (Last 30 Days) @@ -45,33 +72,34 @@ display(Inputs.table(filtered.map(e => ({ ```js import * as Plot from "npm:@observablehq/plot"; -const cutoff = new Date(); -cutoff.setDate(cutoff.getDate() - 30); +const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); +const byDay = Object.entries( + data + .filter(e => new Date(e.created_at) >= cutoff) + .reduce((acc, e) => { + const day = e.created_at.slice(0, 10); + acc[day] = (acc[day] ?? 0) + 1; + return acc; + }, {}) +).sort().map(([day, count]) => ({day, count})); -const byDay = data - .filter(e => new Date(e.created_at) >= cutoff) - .reduce((acc, e) => { - const day = e.created_at.slice(0, 10); - acc[day] = (acc[day] ?? 0) + 1; - return acc; - }, {}); - -display(Plot.plot({ - title: "Progress Events per Day (30-day window)", - x: { label: "Date", tickRotate: -30 }, - y: { label: "Events", grid: true }, - marks: [ - Plot.areaY( - Object.entries(byDay).sort().map(([day, count]) => ({ day, count })), - { 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]), - ], - marginBottom: 60, - width: 750, -})); +display(byDay.length === 0 + ? html`

No events in the last 30 days.

` + : Plot.plot({ + title: "Progress events per day (30-day window)", + x: {label: "Date", tickRotate: -30}, + y: {label: "Events", grid: true}, + marks: [ + Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3}), + Plot.lineY(byDay, {x: "day", y: "count", stroke: "steelblue"}), + Plot.ruleY([0]), + ], + marginBottom: 60, + width: 750, + }) +); ``` + + diff --git a/state-hub/dashboard/src/workstreams.md b/state-hub/dashboard/src/workstreams.md index d30335a..9b62e8c 100644 --- a/state-hub/dashboard/src/workstreams.md +++ b/state-hub/dashboard/src/workstreams.md @@ -2,23 +2,62 @@ title: Workstreams --- -# Workstreams - ```js -const workstreams = await FileAttachment("data/workstreams.json").json(); -const data = Array.isArray(workstreams) ? workstreams : []; +const API = "http://127.0.0.1:8000"; +const POLL = 15_000; ``` ```js -const domainFilter = view(Inputs.select( - ["(all)", ...new Set(data.map(w => w.domain ?? "unknown"))], - { label: "Domain" } -)); -const statusFilter = view(Inputs.select( - ["(all)", "active", "blocked", "completed", "archived"], - { label: "Status" } -)); -const ownerFilter = view(Inputs.text({ label: "Owner contains" })); +// Fetch workstreams + topics in parallel, join on topic_id → domain/title +const wsState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const [rw, rt] = await Promise.all([ + fetch(`${API}/workstreams/`), + fetch(`${API}/topics/`), + ]); + 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`
+ + ${_ok + ? `Live · updated ${_ts?.toLocaleTimeString()}` + : `Offline — run: make api`} +
`); +``` + +```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 @@ -28,40 +67,35 @@ const filtered = data.filter(w => (!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase())) ); -const STATUS_COLOR = { - active: "green", - blocked: "orange", - completed: "blue", - archived: "gray", -}; - display(Inputs.table(filtered.map(w => ({ - Title: w.title, - Domain: w.domain, - Status: w.status, - Owner: w.owner ?? "—", - "Due": w.due_date ?? "—", - "Updated": new Date(w.updated_at).toLocaleDateString(), -})), { - rows: 20, -})); + Title: w.title, + Domain: w.domain, + Status: w.status, + Owner: w.owner ?? "—", + Due: w.due_date ?? "—", + Updated: new Date(w.updated_at).toLocaleDateString(), +})), {rows: 20})); ``` +## Status Distribution + ```js import * as Plot from "npm:@observablehq/plot"; +const byStatus = Object.entries( + filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {}) +).map(([status, count]) => ({status, count})); + display(Plot.plot({ - title: "Workstream Status Distribution", marks: [ - Plot.barX( - Object.entries( - filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {}) - ).map(([status, count]) => ({ status, count })), - { y: "status", x: "count", fill: "status", tip: true } - ), + Plot.barX(byStatus, {y: "status", x: "count", fill: "status", tip: true}), Plot.ruleX([0]), ], marginLeft: 80, width: 500, })); ``` + +