generated from coulomb/repo-seed
Optimize dashboard overview loading
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>`);
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user