Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03ae55786 | ||
|
|
9786a9a519 | ||
|
|
2460d7fec7 |
3 changed files with 105 additions and 55 deletions
|
|
@ -1,62 +1,11 @@
|
|||
// 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.
|
||||
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 } from './ui/status-chip/StatusChip.js';
|
||||
import { Table, type TableColumn } from './ui/table/Table.js';
|
||||
|
||||
interface Hive {
|
||||
name: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumn<Hive>[] = [
|
||||
{ key: 'name', header: 'name', render: (h) => h.name },
|
||||
{
|
||||
key: 'domain',
|
||||
header: 'domain',
|
||||
render: (h) => (
|
||||
<a href={`https://${h.domain}/`} target="_blank" rel="noreferrer">
|
||||
{h.domain}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
// 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: () => <StatusChip label="configured" /> },
|
||||
];
|
||||
|
||||
function Home() {
|
||||
const [hives, setHives] = useState<Hive[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/hives')
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`http ${r.status}`);
|
||||
return r.json() as Promise<Hive[]>;
|
||||
})
|
||||
.then(setHives)
|
||||
.catch((e: unknown) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Panel title="overview">
|
||||
{error ? <p>failed to load the hive roster: {error}</p> : null}
|
||||
{!error && hives === null ? <p>loading…</p> : null}
|
||||
{hives ? <Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> : null}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
|
|
@ -70,7 +19,7 @@ export function App() {
|
|||
return (
|
||||
<Shell>
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/" component={OverviewPage} />
|
||||
<Route path="/jobs" component={JobsPage} />
|
||||
<Route path="/components" component={ComponentsPage} />
|
||||
<Route component={NotFound} />
|
||||
|
|
|
|||
82
frontend/packages/swarm-ui/src/pages/OverviewPage.tsx
Normal file
82
frontend/packages/swarm-ui/src/pages/OverviewPage.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// <OverviewPage> — 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<Freshness, { tone: ChipTone; label: string }> = {
|
||||
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<HiveStatus>[] = [
|
||||
{ key: 'name', header: 'name', render: (h) => h.name },
|
||||
{
|
||||
key: 'domain',
|
||||
header: 'domain',
|
||||
render: (h) =>
|
||||
h.domain ? (
|
||||
<a href={`https://${h.domain}/`} target="_blank" rel="noreferrer">
|
||||
{h.domain}
|
||||
</a>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'status',
|
||||
render: (h) => {
|
||||
const { tone, label } = FRESHNESS[h.freshness];
|
||||
const age = h.age_seconds !== null ? ` (${fmtAgo(h.age_seconds)})` : '';
|
||||
return <StatusChip tone={tone} label={label + age} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function OverviewPage() {
|
||||
const [hives, setHives] = useState<HiveStatus[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/hives/status')
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`http ${r.status}`);
|
||||
return r.json() as Promise<HiveStatus[]>;
|
||||
})
|
||||
.then(setHives)
|
||||
.catch((e: unknown) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Panel title="overview">
|
||||
{error ? <p>failed to load the hive roster: {error}</p> : null}
|
||||
{!error && hives === null ? <p>loading…</p> : null}
|
||||
{hives ? <Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> : null}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
19
frontend/packages/swarm-ui/src/util.ts
Normal file
19
frontend/packages/swarm-ui/src/util.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Small pure render helpers shared across swarm-ui pages. Not folded
|
||||
// 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' });
|
||||
|
||||
export function fmtAgo(ageSeconds: number): string {
|
||||
const age = Math.max(0, Math.floor(ageSeconds));
|
||||
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');
|
||||
}
|
||||
Loading…
Reference in a new issue