Optimize dashboard overview loading

This commit is contained in:
2026-06-06 00:42:00 +02:00
parent a412998c96
commit b340489d96
14 changed files with 990 additions and 88 deletions

View File

@@ -89,11 +89,23 @@ export async function waitForVisible(ms) {
export async function apiFetch(path, options = {}) {
const url = path.startsWith("http") ? path : `${API}${path}`;
const timeout = options.timeout ?? FETCH_TIMEOUT;
const {timeout: _timeout, ...fetchOptions} = options;
const {timeout: _timeout, cache = "no-store", ...fetchOptions} = options;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeout);
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
ctrl.abort();
}, timeout);
try {
return await fetch(url, {cache: "no-store", ...fetchOptions, signal: ctrl.signal});
return await fetch(url, {cache, ...fetchOptions, signal: ctrl.signal});
} catch (error) {
if (timedOut || error?.name === "AbortError") {
const message = `Request timed out after ${Math.round(timeout / 1000)}s: ${url}`;
const timeoutError = new Error(message);
timeoutError.name = "TimeoutError";
throw timeoutError;
}
throw error;
} finally {
clearTimeout(timer);
}

View File

@@ -10,7 +10,10 @@ All dashboard pages poll the State Hub API automatically. No manual refresh is e
## Poll interval
Every page fetches fresh data from `http://127.0.0.1:8000` every **15 seconds** using an async generator loop. The previous data stays visible while the next request is in flight, so the UI never goes blank.
Most live pages fetch fresh data from `http://127.0.0.1:8000` every **15 seconds**
using an async generator loop. The overview page uses a heavier bounded read
model and refreshes every **60 seconds**. The previous data stays visible while
the next request is in flight, so the UI never goes blank.
---
@@ -21,6 +24,7 @@ The **●** dot in the top-right corner of each page shows the current connectio
| Indicator | Meaning |
|---|---|
| **● Live · updated HH:MM:SS** | Last poll succeeded — data is current as of that time |
| **● Stale · last successful update HH:MM:SS** | Last refresh failed, but cached page data is still visible |
| **● Offline — run: `make api`** | API is unreachable — the dot turns red |
The timestamp updates on every successful poll. If you see a time that is more than ~30 seconds in the past, the poll is stalled (browser tab backgrounded or network issue) — reloading the page resets the loop.
@@ -48,7 +52,7 @@ make api # db + migrate + uvicorn (restarts if already running)
| Page | Endpoints |
|---|---|
| Overview | `/state/summary` |
| Overview | `/state/overview`, `/decisions/?decision_type=pending` |
| Workplans | `/workplans/`, `/topics/`, `/state/summary` |
| Decisions | `/decisions/?limit=500`, `/topics/` |
| Progress | `/progress/?limit=500` |
@@ -57,4 +61,4 @@ All endpoints are read-only GET requests. The dashboard never writes to the API.
---
*Poll interval: 15 s. Data is refreshed in the background — the page never reloads itself.*
*Poll interval: 15 s for most pages, 60 s for Overview. Data is refreshed in the background — the page never reloads itself.*

View File

@@ -82,9 +82,13 @@ and summary.
## Data source
Polls `GET /state/summary` every **15 seconds**. The workstream chart also polls
`GET /workplans/`, `GET /tasks/?limit=2000`, `GET /topics/`, `GET /repos/`,
and `GET /workplans/index` for repository grouping, task counts, and
workplan filename tooltips. Blocking decisions are fetched separately via
`GET /decisions/?decision_type=pending` and only re-fetched after a successful
resolve action — this prevents the inline form from being wiped on every poll.
Polls `GET /state/overview` every **60 seconds**. This endpoint is a bounded
dashboard read model: it returns summary totals, recent activity, registration
milestones, SBOM totals, and chart-ready workplan rows with task counts already
aggregated server-side.
The page keeps the last successful overview response visible if a refresh times
out, and marks the view stale instead of clearing the dashboard. Blocking
decisions are fetched separately via `GET /decisions/?decision_type=pending`
and only re-fetched after a successful resolve action — this prevents the inline
form from being wiped on every poll.

View File

@@ -14,11 +14,15 @@ import {
```
```js
// Single polling loop — fetches all data in one Promise.all batch, backs off uniformly.
// Single polling loop — loads one bounded overview read model and keeps
// last-known-good data visible if a refresh times out.
const pageState = (async function*() {
let failures = 0;
let lastGood = null;
while (true) {
let summary = {}, snapshots = [], totalPkgs = 0, milestones = [], wsAll = [], ok = false;
let nextState = lastGood
? {...lastGood, ok: false, stale: true, error: null}
: {summary: {}, snapshots: [], snapshotCount: 0, totalPkgs: 0, milestones: [], wsAll: [], ok: false, stale: false, error: null, sources: {}, ts: new Date()};
try {
const loadJson = async (name, path, options = {}) => {
const response = await apiFetch(path, options);
@@ -26,67 +30,71 @@ const pageState = (async function*() {
return response.json();
};
const [
summaryData,
snapList,
allEvents,
wsList,
taskList,
topicList,
repoList,
workplanIndex,
] = await Promise.all([
loadJson("summary", "/state/summary", {timeout: 20_000}),
loadJson("sbom snapshots", "/sbom/snapshots/"),
loadJson("milestones", "/progress/?event_type=milestone&limit=500"),
loadJson("workplans", "/workplans/"),
loadJson("tasks", "/tasks/?limit=2000"),
loadJson("topics", "/topics/"),
loadJson("repos", "/repos/"),
loadJson("workplan index", "/workplans/index").catch(() => ({workplans: {}, workstreams: {}})),
]);
const overview = await loadJson("overview", "/state/overview", {timeout: 20_000, cache: "reload"});
ok = true;
summary = summaryData;
snapshots = snapList;
totalPkgs = snapshots.reduce((s, sn) => s + (sn.entry_count ?? 0), 0);
milestones = allEvents.filter(e => e.summary?.startsWith("Project registered with State Hub:"));
const workplanMap = workplanIndex.workstreams ?? {};
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
const counts = {};
for (const t of taskList) {
const wid = t.workstream_id;
if (!counts[wid]) counts[wid] = {done: 0, progress: 0, wait: 0, todo: 0, total: 0};
counts[wid].total++;
if (t.status === "done") counts[wid].done++;
else if (t.status === "progress") counts[wid].progress++;
else if (t.status === "wait") counts[wid].wait++;
else if (t.status === "todo") counts[wid].todo++;
}
wsAll = wsList.map(w => {
const repo = repoMap[w.repo_id];
const topic = topicMap[w.topic_id];
const workplan = workplanMap[w.id] ?? {};
return {
const summaryData = {
generated_at: overview.generated_at,
totals: overview.totals ?? {},
topics: overview.topics ?? [],
blocking_decisions: overview.blocking_decisions ?? [],
waiting_tasks: overview.waiting_tasks ?? [],
blocked_tasks: overview.blocked_tasks ?? overview.waiting_tasks ?? [],
recent_progress: overview.recent_progress ?? [],
next_steps: overview.next_steps ?? [],
contribution_counts: overview.contribution_counts ?? {},
licence_risk_count: overview.licence_risk_count ?? 0,
open_capability_requests: overview.open_capability_requests ?? 0,
};
nextState = {
summary: summaryData,
snapshots: [],
snapshotCount: overview.sbom_snapshot_count ?? 0,
totalPkgs: overview.sbom_package_total ?? 0,
milestones: overview.registration_milestones ?? [],
wsAll: (overview.workplan_rows ?? []).map(w => ({
...w,
status: normalizeWorkstreamStatus(w.status),
domain: repo?.domain_slug ?? topic?.domain_slug ?? "unknown",
repo_label: repo?.slug ?? workplan.repo_slug ?? "unassigned",
workplan_filename: workplan.filename ?? null,
workplan_relative_path: workplan.relative_path ?? null,
workplan_archived: workplan.archived ?? false,
health_labels: workplan.health_labels ?? [],
href: `./workstreams/${w.id}`,
...(counts[w.id] ?? {done: 0, progress: 0, wait: 0, todo: 0, total: 0}),
};
});
})),
ok: true,
stale: false,
error: null,
sources: overview.sources ?? {},
ts: new Date(),
};
lastGood = nextState;
} catch (e) {
summary = {error: `Dashboard data load failed: ${e?.message ?? String(e)}`};
const message = `Dashboard refresh failed: ${e?.message ?? String(e)}`;
if (lastGood) {
nextState = {
...lastGood,
ok: false,
stale: true,
error: `${message}; showing last successful data from ${lastGood.ts?.toLocaleTimeString?.() ?? "previous refresh"}`,
summary: {
...(lastGood.summary ?? {}),
error: `${message}; showing last successful data from ${lastGood.ts?.toLocaleTimeString?.() ?? "previous refresh"}`,
},
};
} else {
nextState = {
summary: {error: message},
snapshots: [],
snapshotCount: 0,
totalPkgs: 0,
milestones: [],
wsAll: [],
ok: false,
stale: false,
error: message,
sources: {},
ts: new Date(),
};
}
}
failures = ok ? 0 : failures + 1;
yield {summary, snapshots, totalPkgs, milestones, wsAll, ok, ts: new Date()};
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
failures = nextState.ok ? 0 : failures + 1;
yield nextState;
await waitForVisible(pollDelay({ok: nextState.ok, base: POLL_HEAVY, failures}));
}
})();
```
@@ -94,6 +102,7 @@ const pageState = (async function*() {
```js
const summary = pageState.summary ?? {};
const _ok = pageState.ok ?? false;
const _stale = pageState.stale ?? false;
const _ts = pageState.ts;
const totals = summary.totals ?? {};
const ws = totals.workstreams ?? {};
@@ -107,7 +116,7 @@ const wsAll = pageState.wsAll ?? [];
// Kept separate from the main poll so in-progress form inputs aren't wiped every 60 s.
const blockingDecisions = Mutable([]);
const refreshDecisions = async () => {
const r = await fetch(`${API}/decisions/?decision_type=pending`).catch(() => null);
const r = await apiFetch("/decisions/?decision_type=pending", {timeout: 12_000}).catch(() => null);
const all = r?.ok ? await r.json() : [];
blockingDecisions.value = all.filter(d => ["open", "escalated"].includes(d.status));
};
@@ -121,9 +130,11 @@ import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp} from "./components/doc-overlay.js";
const _liveEl = html`<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : _stale ? 'orange' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: _stale
? `Stale · last successful update ${_ts?.toLocaleTimeString()}`
: html`<span style="color:red">Offline — run: <code>cd ~/state-hub && make api</code></span>`}
</div>`;
withDocHelp(_liveEl, "/docs/live-data");
@@ -346,6 +357,7 @@ const licenceRisk = summary.licence_risk_count ?? 0;
const totalContribs = ["br","fr","ep","upr"].reduce((s, t) => s + (contribCounts[t] ?? 0), 0);
const needsFollowUp = (contribCounts["submitted"] ?? 0) + (contribCounts["acknowledged"] ?? 0);
const sbomSnaps = pageState.snapshots ?? [];
const sbomSnapCount = pageState.snapshotCount ?? sbomSnaps.length;
const totalPkgs = pageState.totalPkgs ?? 0;
display(html`<div class="grid grid-cols-3" style="gap:1rem;margin-bottom:1.5rem">
@@ -362,7 +374,7 @@ display(html`<div class="grid grid-cols-3" style="gap:1rem;margin-bottom:1.5rem"
<a class="card card-link ${licenceRisk > 0 ? 'warn' : ''}" href="./sbom">
<h3>SBOM</h3>
<p class="big-num">${totalPkgs.toLocaleString()}</p>
<small>${sbomSnaps.length} repo${sbomSnaps.length !== 1 ? "s" : ""} tracked · ${licenceRisk > 0 ? html`<span style="color:red">${licenceRisk} copyleft risks</span>` : html`<span style="color:green">✓ no copyleft</span>`}</small>
<small>${sbomSnapCount} snapshot${sbomSnapCount !== 1 ? "s" : ""} tracked · ${licenceRisk > 0 ? html`<span style="color:red">${licenceRisk} copyleft risks</span>` : html`<span style="color:green">✓ no copyleft</span>`}</small>
</a>
</div>`);
```