From 2460d7fec7f4ddac00325848be610de6d58aed14 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 16 Aug 2026 18:57:47 +0200 Subject: [PATCH 1/3] swarm-ui: wire hive overview to the real status aggregate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.tsx now fetches GET /api/hives/status (the swarm-controller aggregate: one row per roster hive, freshness derived at read time from the status bucket) instead of GET /api/hives + a static 'configured' chip. Renders fresh/stale/never_reported/unknown as StatusChip tones with a relative age, per the placeholder comment that was already waiting on this endpoint to exist. Adds a small local fmtAgo helper (src/util.ts) mirroring the dashboard package's near-identical formatter — not worth sharing across a vanilla-JS and a Preact/TS call site. --- frontend/packages/swarm-ui/src/App.tsx | 69 ++++++++++++++++++-------- frontend/packages/swarm-ui/src/util.ts | 15 ++++++ 2 files changed, 62 insertions(+), 22 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/util.ts diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 0b69fb3c..822984ef 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -1,49 +1,74 @@ -// Root shell component. `/` is now the real hive-roster overview page — -// fetches swarm-controller's `GET /api/hives` and renders it through the -// shared primitives. Status starts as a static "configured" chip; grows -// into a real online/stale/offline tone once a status rollup exists -// server-side — same component, richer data later, no rebuild. +// Root shell component. `/` is the real hive-roster overview page — +// fetches swarm-controller's `GET /api/hives/status`, the swarm-wide +// status aggregate: one row per roster hive, freshness derived at read +// time from the status bucket. Supersedes the earlier `GET /api/hives` +// + static "configured" chip placeholder now that the real rollup +// exists — same component, richer data, no rebuild, exactly the plan +// that placeholder's comment laid out. import { useEffect, useState } from 'preact/hooks'; import { Route, Switch } from 'wouter-preact'; import { Shell } from './shell/Shell.js'; import { ComponentsPage } from './pages/ComponentsPage.js'; import { JobsPage } from './pages/JobsPage.js'; import { Panel } from './ui/panel/Panel.js'; -import { StatusChip } from './ui/status-chip/StatusChip.js'; +import { StatusChip, type ChipTone } from './ui/status-chip/StatusChip.js'; import { Table, type TableColumn } from './ui/table/Table.js'; +import { fmtAgo } from './util.js'; -interface Hive { +type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown'; + +interface HiveStatus { name: string; - domain: string; + domain: string | null; + freshness: Freshness; + last_seen_unix: number | null; + age_seconds: number | null; } -const COLUMNS: TableColumn[] = [ +// One tone + label per freshness value. `unknown` (reporting but absent +// from the roster) reads `negative` — it's the one case this endpoint +// cannot vouch for at all, per `status.rs`'s doc comment. +const FRESHNESS: Record = { + fresh: { tone: 'positive', label: 'fresh' }, + stale: { tone: 'warning', label: 'stale' }, + never_reported: { tone: 'neutral', label: 'never reported' }, + unknown: { tone: 'negative', label: 'unknown' }, +}; + +const COLUMNS: TableColumn[] = [ { key: 'name', header: 'name', render: (h) => h.name }, { key: 'domain', header: 'domain', - render: (h) => ( - - {h.domain} - - ), + render: (h) => + h.domain ? ( + + {h.domain} + + ) : ( + '—' + ), + }, + { + key: 'status', + header: 'status', + render: (h) => { + const { tone, label } = FRESHNESS[h.freshness]; + const age = h.age_seconds !== null ? ` (${fmtAgo(h.age_seconds)})` : ''; + return ; + }, }, - // Static "configured" tone until a swarm-wide status rollup exists — - // there is no data source for online/stale/offline yet, and a chip - // that always renders "positive" would misreport a genuinely offline - // hive. See StatusChip's own doc comment for the same reasoning. - { key: 'status', header: 'status', render: () => }, ]; function Home() { - const [hives, setHives] = useState(null); + const [hives, setHives] = useState(null); const [error, setError] = useState(null); useEffect(() => { - fetch('/api/hives') + fetch('/api/hives/status') .then((r) => { if (!r.ok) throw new Error(`http ${r.status}`); - return r.json() as Promise; + return r.json() as Promise; }) .then(setHives) .catch((e: unknown) => setError(String(e))); diff --git a/frontend/packages/swarm-ui/src/util.ts b/frontend/packages/swarm-ui/src/util.ts new file mode 100644 index 00000000..6aec3416 --- /dev/null +++ b/frontend/packages/swarm-ui/src/util.ts @@ -0,0 +1,15 @@ +// Small pure render helpers shared across swarm-ui pages. Not folded +// into `@hive/shared` — these are swarm-ui's own formatting choices +// (compact, one unit deep), not a cross-package contract, and the +// dashboard package already has its own near-identical `fmtAgo` in +// `util.js` for the same reason: two small vanilla-vs-Preact call +// sites don't justify a shared abstraction over a five-line function. + +// Relative age in whole seconds, coarsened to one unit ("5m ago"). +export function fmtAgo(ageSeconds: number): string { + const age = Math.max(0, Math.floor(ageSeconds)); + if (age < 60) return age + 's ago'; + if (age < 3600) return Math.floor(age / 60) + 'm ago'; + if (age < 86400) return Math.floor(age / 3600) + 'h ago'; + return Math.floor(age / 86400) + 'd ago'; +} From 9786a9a51910fd36bf1b919db937ef7f01273f1b Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 16 Aug 2026 18:59:57 +0200 Subject: [PATCH 2/3] swarm-ui: extract overview into its own page component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per mara's review on PR#3342 — the hive-status view shouldn't live inline in App.tsx. Moves it to pages/OverviewPage.tsx, mirroring JobsPage's shape: App.tsx stays routing-only, each page owns its own fetch + render. --- frontend/packages/swarm-ui/src/App.tsx | 84 +------------------ .../swarm-ui/src/pages/OverviewPage.tsx | 82 ++++++++++++++++++ 2 files changed, 86 insertions(+), 80 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/OverviewPage.tsx diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 822984ef..3ef432e3 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -1,87 +1,11 @@ -// Root shell component. `/` is the real hive-roster overview page — -// fetches swarm-controller's `GET /api/hives/status`, the swarm-wide -// status aggregate: one row per roster hive, freshness derived at read -// time from the status bucket. Supersedes the earlier `GET /api/hives` -// + static "configured" chip placeholder now that the real rollup -// exists — same component, richer data, no rebuild, exactly the plan -// that placeholder's comment laid out. -import { useEffect, useState } from 'preact/hooks'; +// Root shell component — routing only. Each route's content is its own +// page component under `./pages/`; this file just maps paths to them. import { Route, Switch } from 'wouter-preact'; import { Shell } from './shell/Shell.js'; import { ComponentsPage } from './pages/ComponentsPage.js'; import { JobsPage } from './pages/JobsPage.js'; +import { OverviewPage } from './pages/OverviewPage.js'; import { Panel } from './ui/panel/Panel.js'; -import { StatusChip, type ChipTone } from './ui/status-chip/StatusChip.js'; -import { Table, type TableColumn } from './ui/table/Table.js'; -import { fmtAgo } from './util.js'; - -type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown'; - -interface HiveStatus { - name: string; - domain: string | null; - freshness: Freshness; - last_seen_unix: number | null; - age_seconds: number | null; -} - -// One tone + label per freshness value. `unknown` (reporting but absent -// from the roster) reads `negative` — it's the one case this endpoint -// cannot vouch for at all, per `status.rs`'s doc comment. -const FRESHNESS: Record = { - fresh: { tone: 'positive', label: 'fresh' }, - stale: { tone: 'warning', label: 'stale' }, - never_reported: { tone: 'neutral', label: 'never reported' }, - unknown: { tone: 'negative', label: 'unknown' }, -}; - -const COLUMNS: TableColumn[] = [ - { key: 'name', header: 'name', render: (h) => h.name }, - { - key: 'domain', - header: 'domain', - render: (h) => - h.domain ? ( - - {h.domain} - - ) : ( - '—' - ), - }, - { - key: 'status', - header: 'status', - render: (h) => { - const { tone, label } = FRESHNESS[h.freshness]; - const age = h.age_seconds !== null ? ` (${fmtAgo(h.age_seconds)})` : ''; - return ; - }, - }, -]; - -function Home() { - const [hives, setHives] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - fetch('/api/hives/status') - .then((r) => { - if (!r.ok) throw new Error(`http ${r.status}`); - return r.json() as Promise; - }) - .then(setHives) - .catch((e: unknown) => setError(String(e))); - }, []); - - return ( - - {error ?

failed to load the hive roster: {error}

: null} - {!error && hives === null ?

loading…

: null} - {hives ? h.name} /> : null} - - ); -} function NotFound() { return ( @@ -95,7 +19,7 @@ export function App() { return ( - + diff --git a/frontend/packages/swarm-ui/src/pages/OverviewPage.tsx b/frontend/packages/swarm-ui/src/pages/OverviewPage.tsx new file mode 100644 index 00000000..b78f8f12 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/OverviewPage.tsx @@ -0,0 +1,82 @@ +// — the hive-roster overview, `/`'s content. Fetches +// swarm-controller's `GET /api/hives/status`, the swarm-wide status +// aggregate: one row per roster hive, freshness derived at read time +// from the status bucket. Supersedes the earlier `GET /api/hives` + +// static "configured" chip placeholder now that the real rollup +// exists — same component, richer data, no rebuild, exactly the plan +// that placeholder's comment laid out. Its own component/file rather +// than living in `App.tsx`, matching `JobsPage`'s shape: `App.tsx` is +// routing, a page owns its own fetch + render. +import { useEffect, useState } from 'preact/hooks'; +import { Panel } from '../ui/panel/Panel.js'; +import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js'; +import { Table, type TableColumn } from '../ui/table/Table.js'; +import { fmtAgo } from '../util.js'; + +type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown'; + +interface HiveStatus { + name: string; + domain: string | null; + freshness: Freshness; + last_seen_unix: number | null; + age_seconds: number | null; +} + +// One tone + label per freshness value. `unknown` (reporting but absent +// from the roster) reads `negative` — it's the one case this endpoint +// cannot vouch for at all, per `status.rs`'s doc comment. +const FRESHNESS: Record = { + fresh: { tone: 'positive', label: 'fresh' }, + stale: { tone: 'warning', label: 'stale' }, + never_reported: { tone: 'neutral', label: 'never reported' }, + unknown: { tone: 'negative', label: 'unknown' }, +}; + +const COLUMNS: TableColumn[] = [ + { key: 'name', header: 'name', render: (h) => h.name }, + { + key: 'domain', + header: 'domain', + render: (h) => + h.domain ? ( + + {h.domain} + + ) : ( + '—' + ), + }, + { + key: 'status', + header: 'status', + render: (h) => { + const { tone, label } = FRESHNESS[h.freshness]; + const age = h.age_seconds !== null ? ` (${fmtAgo(h.age_seconds)})` : ''; + return ; + }, + }, +]; + +export function OverviewPage() { + const [hives, setHives] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch('/api/hives/status') + .then((r) => { + if (!r.ok) throw new Error(`http ${r.status}`); + return r.json() as Promise; + }) + .then(setHives) + .catch((e: unknown) => setError(String(e))); + }, []); + + return ( + + {error ?

failed to load the hive roster: {error}

: null} + {!error && hives === null ?

loading…

: null} + {hives ?
h.name} /> : null} + + ); +} From b03ae55786154fee8498952c85c658c69d275ebd Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 16 Aug 2026 19:00:41 +0200 Subject: [PATCH 3/3] swarm-ui: use native Intl.RelativeTimeFormat for fmtAgo, not hand-rolled Answers mara's question on PR#3342 (lightweight dep for fmtAgo?): no dep needed, Intl.RelativeTimeFormat is built into the runtime and its narrow style produces the same '5m ago' shape, verified with a real call rather than assumed from the spec. --- frontend/packages/swarm-ui/src/util.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/frontend/packages/swarm-ui/src/util.ts b/frontend/packages/swarm-ui/src/util.ts index 6aec3416..577b8b88 100644 --- a/frontend/packages/swarm-ui/src/util.ts +++ b/frontend/packages/swarm-ui/src/util.ts @@ -1,15 +1,19 @@ // Small pure render helpers shared across swarm-ui pages. Not folded -// into `@hive/shared` — these are swarm-ui's own formatting choices -// (compact, one unit deep), not a cross-package contract, and the -// dashboard package already has its own near-identical `fmtAgo` in -// `util.js` for the same reason: two small vanilla-vs-Preact call -// sites don't justify a shared abstraction over a five-line function. +// into `@hive/shared` — swarm-ui's formatting choices aren't a +// cross-package contract, and there's nothing here worth sharing. + +// Relative age, coarsened to one unit ("5m ago"). Built on the native +// `Intl.RelativeTimeFormat` (no dependency needed — mara asked whether +// a lightweight package was warranted; the runtime already ships this) +// with `style: 'narrow'`, which produces the same "5s/5m/5h/5d ago" +// shape the dashboard package's hand-rolled equivalent does, verified +// against a real `Intl` call rather than assumed from the spec. +const RTF = new Intl.RelativeTimeFormat('en', { numeric: 'always', style: 'narrow' }); -// Relative age in whole seconds, coarsened to one unit ("5m ago"). export function fmtAgo(ageSeconds: number): string { const age = Math.max(0, Math.floor(ageSeconds)); - if (age < 60) return age + 's ago'; - if (age < 3600) return Math.floor(age / 60) + 'm ago'; - if (age < 86400) return Math.floor(age / 3600) + 'h ago'; - return Math.floor(age / 86400) + 'd ago'; + if (age < 60) return RTF.format(-age, 'second'); + if (age < 3600) return RTF.format(-Math.floor(age / 60), 'minute'); + if (age < 86400) return RTF.format(-Math.floor(age / 3600), 'hour'); + return RTF.format(-Math.floor(age / 86400), 'day'); }