diff --git a/dashboard/src/capability-requests.md b/dashboard/src/capability-requests.md index 0786952..db7d043 100644 --- a/dashboard/src/capability-requests.md +++ b/dashboard/src/capability-requests.md @@ -3,7 +3,7 @@ title: Capability Requests --- ```js -import {API, apiFetch, pollDelay, sleep} from "./components/config.js"; +import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; const POLL = 30_000; ``` @@ -20,7 +20,7 @@ const reqState = (async function*() { } catch {} failures = ok ? 0 : failures + 1; yield {data, ok, ts: new Date()}; - await sleep(pollDelay({ok, base: POLL, failures})); + await waitForVisible(pollDelay({ok, base: POLL, failures})); } })(); ``` @@ -210,7 +210,7 @@ const catalogState = (async function*() { } catch {} failures = ok ? 0 : failures + 1; yield data; - await sleep(pollDelay({ok, base: POLL, failures})); + await waitForVisible(pollDelay({ok, base: POLL, failures})); } })(); ``` diff --git a/dashboard/src/components/config.js b/dashboard/src/components/config.js index 881baec..3499f94 100644 --- a/dashboard/src/components/config.js +++ b/dashboard/src/components/config.js @@ -1,19 +1,30 @@ export const API = "http://127.0.0.1:8000"; export const POLL = 15_000; export const POLL_HEAVY = 60_000; -export const POLL_HIDDEN = 120_000; export const FETCH_TIMEOUT = 12_000; export function pollDelay({ok = true, base = POLL, failures = 0} = {}) { - const hidden = typeof document !== "undefined" && document.visibilityState === "hidden"; - const failureDelay = ok ? base : Math.min(base * 2 ** Math.min(failures, 4), 300_000); - return hidden ? Math.max(failureDelay, POLL_HIDDEN) : failureDelay; + return ok ? base : Math.min(base * 2 ** Math.min(failures, 4), 300_000); } export function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } +// Waits `ms` if the tab is visible; pauses until the tab becomes visible if hidden, +// then returns immediately so the next poll fires as soon as the user returns. +export async function waitForVisible(ms) { + if (typeof document === "undefined") return sleep(ms); + if (document.visibilityState === "visible") return sleep(ms); + return new Promise(resolve => { + const handler = () => { + document.removeEventListener("visibilitychange", handler); + resolve(); + }; + document.addEventListener("visibilitychange", handler); + }); +} + export async function apiFetch(path, options = {}) { const url = path.startsWith("http") ? path : `${API}${path}`; const timeout = options.timeout ?? FETCH_TIMEOUT; diff --git a/dashboard/src/contributions.md b/dashboard/src/contributions.md index d5c43ff..3a676ea 100644 --- a/dashboard/src/contributions.md +++ b/dashboard/src/contributions.md @@ -3,7 +3,7 @@ title: Contributions --- ```js -import {API, apiFetch, pollDelay, sleep} from "./components/config.js"; +import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; const POLL = 30_000; ``` @@ -20,7 +20,7 @@ const contribState = (async function*() { } catch {} failures = ok ? 0 : failures + 1; yield {data, ok, ts: new Date()}; - await sleep(pollDelay({ok, base: POLL, failures})); + await waitForVisible(pollDelay({ok, base: POLL, failures})); } })(); ``` diff --git a/dashboard/src/decisions.md b/dashboard/src/decisions.md index fe9382f..efba13a 100644 --- a/dashboard/src/decisions.md +++ b/dashboard/src/decisions.md @@ -3,7 +3,7 @@ title: Decisions --- ```js -import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js"; +import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; ``` ```js @@ -46,7 +46,7 @@ const decState = (async function*() { } catch {} failures = ok ? 0 : failures + 1; yield {data, ok, ts: new Date()}; - await sleep(pollDelay({ok, base: POLL_HEAVY, failures})); + await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures})); } })(); ``` diff --git a/dashboard/src/dependencies.md b/dashboard/src/dependencies.md index bdee5c3..a4dbce9 100644 --- a/dashboard/src/dependencies.md +++ b/dashboard/src/dependencies.md @@ -3,26 +3,27 @@ title: Dependencies --- ```js -import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js"; +import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; ``` ```js -// Fetch workstreams + topics + summary (summary carries dep edges on open_workstreams) +// Fetch workstreams + topics + dep edges; /state/deps replaces the heavier +// /state/summary which was only used here to extract dependency edges. const depState = (async function*() { let failures = 0; while (true) { let wsMap = {}, edges = [], ok = false; try { - const [rw, rto, rr, rs] = await Promise.all([ + const [rw, rto, rr, rd] = await Promise.all([ apiFetch("/workstreams/"), apiFetch("/topics/"), apiFetch("/repos/"), - apiFetch("/state/summary", {timeout: 20_000}), + apiFetch("/state/deps"), ]); - ok = rw.ok && rto.ok && rr.ok && rs.ok; + ok = rw.ok && rto.ok && rr.ok && rd.ok; if (ok) { - const [wsList, topicList, repoList, summary] = await Promise.all([ - rw.json(), rto.json(), rr.json(), rs.json(), + const [wsList, topicList, repoList, depsList] = await Promise.all([ + rw.json(), rto.json(), rr.json(), rd.json(), ]); const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); const repoMap = Object.fromEntries(repoList.map(r => [r.id, r])); @@ -31,17 +32,16 @@ const depState = (async function*() { // Prefer repo→domain (GEMS primary); fall back to topic→domain domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown", }])); - // Build directed edge list from open_workstreams depends_on arrays - for (const ow of (summary.open_workstreams ?? [])) { - for (const depId of (ow.depends_on ?? [])) { - edges.push({from_id: ow.id, to_id: depId}); + for (const ow of depsList) { + for (const depStub of (ow.depends_on ?? [])) { + edges.push({from_id: ow.id, to_id: depStub}); } } } } catch {} failures = ok ? 0 : failures + 1; yield {wsMap, edges, ok, ts: new Date()}; - await sleep(pollDelay({ok, base: POLL_HEAVY, failures})); + await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures})); } })(); ``` diff --git a/dashboard/src/domains.md b/dashboard/src/domains.md index 5d7ecd4..4b8ff4b 100644 --- a/dashboard/src/domains.md +++ b/dashboard/src/domains.md @@ -3,7 +3,7 @@ title: Domains --- ```js -import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js"; +import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; ``` ```js @@ -23,7 +23,7 @@ const domainsState = (async function*() { } catch {} failures = ok ? 0 : failures + 1; yield {domains, repos, ok, ts: new Date()}; - await sleep(pollDelay({ok, base: POLL_HEAVY, failures})); + await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures})); } })(); ``` diff --git a/dashboard/src/extensions.md b/dashboard/src/extensions.md index 2002993..6fa5dce 100644 --- a/dashboard/src/extensions.md +++ b/dashboard/src/extensions.md @@ -3,7 +3,7 @@ title: Extension Points --- ```js -import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js"; +import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; ``` ```js @@ -39,7 +39,7 @@ const epState = (async function*() { } catch {} failures = ok ? 0 : failures + 1; yield {data, ok, ts: new Date()}; - await sleep(pollDelay({ok, base: POLL_HEAVY, failures})); + await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures})); } })(); ``` diff --git a/dashboard/src/goals.md b/dashboard/src/goals.md index abf44d4..8bf70b4 100644 --- a/dashboard/src/goals.md +++ b/dashboard/src/goals.md @@ -3,7 +3,7 @@ title: Goals --- ```js -import {API, apiFetch, pollDelay, sleep} from "./components/config.js"; +import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; const POLL = 20_000; ``` @@ -28,7 +28,7 @@ const goalsState = (async function*() { } catch {} failures = ok ? 0 : failures + 1; yield {domains, domainGoals, repoGoals, repos, ok, ts: new Date()}; - await sleep(pollDelay({ok, base: POLL, failures})); + await waitForVisible(pollDelay({ok, base: POLL, failures})); } })(); ``` diff --git a/dashboard/src/inbox.md b/dashboard/src/inbox.md index df6f448..b089015 100644 --- a/dashboard/src/inbox.md +++ b/dashboard/src/inbox.md @@ -3,7 +3,7 @@ title: Agent Inbox --- ```js -import {API, apiFetch, pollDelay, sleep} from "./components/config.js"; +import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; ``` ```js @@ -19,7 +19,7 @@ const inboxState = (async function*() { } catch {} failures = ok ? 0 : failures + 1; yield {messages, ok, ts: new Date()}; - await sleep(pollDelay({ok, failures})); + await waitForVisible(pollDelay({ok, failures})); } })(); ``` diff --git a/dashboard/src/index.md b/dashboard/src/index.md index 8f4a22b..749ab3f 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -3,117 +3,39 @@ title: Overview --- ```js -import {API, POLL, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js"; +import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; ``` ```js -// Live polling — yields {data, ok, ts}; backs off when the API is slow/offline. -const summaryState = (async function*() { +// Single polling loop — fetches all data in one Promise.all batch, backs off uniformly. +const pageState = (async function*() { let failures = 0; while (true) { - let data, ok = false; + let summary = {}, snapshots = [], totalPkgs = 0, milestones = [], wsAll = [], ok = false; try { - const r = await apiFetch("/state/summary", {timeout: 20_000}); - ok = r.ok; - data = ok ? await r.json() : {error: `HTTP ${r.status}`}; - } catch (e) { - data = {error: "API unreachable"}; - } - failures = ok ? 0 : failures + 1; - yield {data, ok, ts: new Date()}; - await sleep(pollDelay({ok, base: POLL_HEAVY, failures})); - } -})(); -``` - -```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 -// Blocking decisions — fetched once on load, refreshed only after a resolve action. -// Kept separate from the summary poll so in-progress form inputs aren't wiped every 15 s. -const blockingDecisions = Mutable([]); -const refreshDecisions = async () => { - const r = await fetch(`${API}/decisions/?decision_type=pending`).catch(() => null); - const all = r?.ok ? await r.json() : []; - blockingDecisions.value = all.filter(d => ["open", "escalated"].includes(d.status)); -}; -refreshDecisions(); -``` - -```js -// SBOM snapshots — repo coverage and total package count -const sbomSnapState = (async function*() { - let failures = 0; - while (true) { - let snapshots = [], totalPkgs = 0, ok = false; - try { - const r = await apiFetch("/sbom/snapshots/"); - ok = r.ok; - if (r.ok) { - snapshots = await r.json(); - totalPkgs = snapshots.reduce((s, sn) => s + (sn.entry_count ?? 0), 0); - } - } catch {} - failures = ok ? 0 : failures + 1; - yield {snapshots, totalPkgs}; - await sleep(pollDelay({ok, base: POLL_HEAVY, failures})); - } -})(); -``` - -```js -// Registered projects — milestone events tagged with registration -const regsState = (async function*() { - let failures = 0; - while (true) { - let rows = [], ok = false; - try { - const r = await apiFetch("/progress/?event_type=milestone&limit=500"); - ok = r.ok; - if (r.ok) { - const all = await r.json(); - rows = all.filter(e => e.summary?.startsWith("Project registered with State Hub:")); - } - } catch {} - failures = ok ? 0 : failures + 1; - yield rows; - await sleep(pollDelay({ok, base: POLL_HEAVY, failures})); - } -})(); -``` - -```js -// All-workstreams + all-tasks poll — drives the multi-mode chart -const wsChartState = (async function*() { - let failures = 0; - while (true) { - let wsAll = [], ok = false; - try { - const [rw, rt, rto, rr, rwi] = await Promise.all([ + const [rSum, rSnap, rRegs, rw, rt, rto, rr, rwi] = await Promise.all([ + apiFetch("/state/summary", {timeout: 20_000}), + apiFetch("/sbom/snapshots/"), + apiFetch("/progress/?event_type=milestone&limit=500"), apiFetch("/workstreams/"), apiFetch("/tasks/?limit=2000"), apiFetch("/topics/"), apiFetch("/repos/"), apiFetch("/workstreams/workplan-index"), ]); - ok = rw.ok && rt.ok && rto.ok && rr.ok; + ok = rSum.ok && rSnap.ok && rRegs.ok && rw.ok && rt.ok && rto.ok && rr.ok; if (ok) { - const [wsList, taskList, topicList, repoList] = await Promise.all([ - rw.json(), rt.json(), rto.json(), rr.json(), + const [summaryData, snapList, allEvents, wsList, taskList, topicList, repoList] = await Promise.all([ + rSum.json(), rSnap.json(), rRegs.json(), rw.json(), rt.json(), rto.json(), rr.json(), ]); + 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 workplanIndex = rwi.ok ? await rwi.json() : {workstreams: {}}; const workplanMap = workplanIndex.workstreams ?? {}; const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); const repoMap = Object.fromEntries(repoList.map(r => [r.id, r])); - // Aggregate task counts per workstream const counts = {}; for (const t of taskList) { const wid = t.workstream_id; @@ -139,17 +61,40 @@ const wsChartState = (async function*() { ...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}), }; }); + } else { + summary = {error: "API unreachable"}; } - } catch {} + } catch (e) { + summary = {error: "API unreachable"}; + } failures = ok ? 0 : failures + 1; - yield {wsAll, ok}; - await sleep(pollDelay({ok, base: POLL_HEAVY, failures})); + yield {summary, snapshots, totalPkgs, milestones, wsAll, ok, ts: new Date()}; + await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures})); } })(); ``` ```js -const wsAll = wsChartState.wsAll ?? []; +const summary = pageState.summary ?? {}; +const _ok = pageState.ok ?? false; +const _ts = pageState.ts; +const totals = summary.totals ?? {}; +const ws = totals.workstreams ?? {}; +const tasks = totals.tasks ?? {}; +const decisions = totals.decisions ?? {}; +const wsAll = pageState.wsAll ?? []; +``` + +```js +// Blocking decisions — fetched once on load, refreshed only after a resolve action. +// 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 all = r?.ok ? await r.json() : []; + blockingDecisions.value = all.filter(d => ["open", "escalated"].includes(d.status)); +}; +refreshDecisions(); ``` # Custodian State Hub @@ -390,8 +335,8 @@ const contribCounts = summary.contribution_counts ?? {}; 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 = sbomSnapState.snapshots ?? []; -const totalPkgs = sbomSnapState.totalPkgs ?? 0; +const sbomSnaps = pageState.snapshots ?? []; +const totalPkgs = pageState.totalPkgs ?? 0; display(html`