swarm-ui: a swarm tab after agents holding the hives card and the beta notification router; /notifications redirects to it
This commit is contained in:
parent
3cc4fdb54c
commit
0302582d1c
10 changed files with 94 additions and 49 deletions
|
|
@ -9,7 +9,7 @@ import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
|
|||
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
||||
import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js";
|
||||
import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
|
||||
import { NotificationsPage } from "./pages/notifications/NotificationsPage.js";
|
||||
import { SwarmPage } from "./pages/swarm/SwarmPage.js";
|
||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||
import { JobsPage } from "./pages/JobsPage.js";
|
||||
import { ReportsPage } from "./pages/reports/ReportsPage.js";
|
||||
|
|
@ -50,7 +50,7 @@ export function App() {
|
|||
/>
|
||||
<Route path="/jobs" component={JobsPage} />
|
||||
<Route path="/messages" component={MessagesPage} />
|
||||
<Route path="/notifications" component={NotificationsPage} />
|
||||
<Route path="/swarm" component={SwarmPage} />
|
||||
<Route path={NEW_FLOW_PATH}>
|
||||
<FlowEditorPage params={{ flowId: "" }} isNew />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
/* The folded hives card over the agents. */
|
||||
.ui-agents-hives {
|
||||
margin-block-end: 0.5em;
|
||||
}
|
||||
/* The roster's refresh picker, at the top right of the open card. */
|
||||
.ui-hives-refresh {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-block-end: 0.5em;
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
// status panel has anything to render against without a roster to embed
|
||||
// in — so all three ended up as one page.
|
||||
//
|
||||
// One fetch, on a refresh-interval cadence like HivesCard: `GET
|
||||
// One fetch, on a refresh-interval cadence like the hives card: `GET
|
||||
// /api/agents/status` — one row per roster agent (identity store is the
|
||||
// roster, so a never-reported agent still gets a row), each already
|
||||
// carrying its freshness/snapshot/config PR/wanted state. Used to be
|
||||
|
|
@ -36,7 +36,6 @@ import { FRESHNESS, type AgentRow } from "../../agent/AgentTypes.js";
|
|||
import { Button } from "../../ui/button/Button.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
||||
import { HivesCard } from "./HivesCard.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
type RefreshIntervalMs,
|
||||
|
|
@ -44,9 +43,8 @@ import {
|
|||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||
import { type FilterColumn } from "../../ui/table/Table.js";
|
||||
import { CreateAgentForm } from "../CreateAgentForm.js";
|
||||
import "./AgentsPage.css";
|
||||
|
||||
// Same 30s default + same reasoning as HivesCard: no inputs on this page
|
||||
// Same 30s default + same reasoning as the hives card: no inputs on this page
|
||||
// for a refresh to clobber, so the out-of-the-box behaviour should just
|
||||
// solve staleness rather than require an opt-in every visit.
|
||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||
|
|
@ -139,9 +137,6 @@ export function AgentsPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div class="ui-agents-hives">
|
||||
<HivesCard />
|
||||
</div>
|
||||
{/* Until the roster loads (or when it fails) there are no filters, but the
|
||||
buttons stay, so an agent can still be created. */}
|
||||
{rows === null ? (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// <NotificationsPage> — the notification router's flows. Backed by
|
||||
// swarm-controller's `GET/PUT /api/notify/flows`; the controller is the only
|
||||
// writer of the stored document and the router reads it every few seconds.
|
||||
// <NotificationRouter> — the notification router's flows, the body of its card on
|
||||
// the swarm page. Backed by swarm-controller's `GET/PUT /api/notify/flows`; the
|
||||
// controller is the only writer of the stored document and the router reads it
|
||||
// every few seconds.
|
||||
//
|
||||
// A flow draws as its graph, read-only. A built-in flow has a switch and can
|
||||
// be duplicated; a custom flow is edited on its own page (<FlowEditorPage>).
|
||||
|
|
@ -13,7 +14,6 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
|||
import { Button } from "../../ui/button/Button.js";
|
||||
import { Card } from "../../ui/card/Card.js";
|
||||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
type RefreshIntervalMs,
|
||||
|
|
@ -35,7 +35,7 @@ import "./NotificationsPage.css";
|
|||
|
||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||
|
||||
export function NotificationsPage() {
|
||||
export function NotificationRouter() {
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const {
|
||||
|
|
@ -96,22 +96,17 @@ export function NotificationsPage() {
|
|||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="notifications"
|
||||
icon="🔔"
|
||||
actions={
|
||||
<div class="notify-panel-actions">
|
||||
<RefreshIntervalPicker
|
||||
id="notifications-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
/>
|
||||
<Button variant="primary" onClick={() => draftFrom()}>
|
||||
create flow
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div class="notify-panel-actions">
|
||||
<RefreshIntervalPicker
|
||||
id="notifications-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
/>
|
||||
<Button variant="primary" onClick={() => draftFrom()}>
|
||||
create flow
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<ApiErrorPanel context="failed to load the flows" problem={error} />
|
||||
) : null}
|
||||
|
|
@ -192,6 +187,6 @@ export function NotificationsPage() {
|
|||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
162
frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx
Normal file
162
frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// <HivesCard> — the hive roster, a card on the agents page that starts folded.
|
||||
// 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.
|
||||
//
|
||||
// Polls on a `RefreshIntervalPicker` cadence rather than fetching once
|
||||
// at mount — no inputs on this page, so a re-fetch clobbering an
|
||||
// in-progress edit (the hook's stated caller obligation) isn't a live
|
||||
// concern here.
|
||||
import { useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
|
||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
type RefreshIntervalMs,
|
||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||
import { Table, type TableColumn } from "../../ui/table/Table.js";
|
||||
import { domainCell } from "../hive-domain.js";
|
||||
|
||||
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
|
||||
|
||||
interface HiveStatus {
|
||||
name: string;
|
||||
domain: string | null;
|
||||
dashboard: string | null;
|
||||
freshness: Freshness;
|
||||
last_seen_unix: number | null;
|
||||
// `age_seconds` (a once-computed-at-fetch age) is part of the wire
|
||||
// shape but deliberately unused here — rendering it directly is the
|
||||
// exact bug `RelativeTime` exists to fix, so this page recomputes the
|
||||
// age client-side from `last_seen_unix` instead.
|
||||
}
|
||||
|
||||
// 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: BadgeTone; 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,
|
||||
sortBy: (h) => h.name,
|
||||
filterValue: (h) => h.name,
|
||||
},
|
||||
{
|
||||
key: "domain",
|
||||
header: "domain",
|
||||
render: (h) => {
|
||||
const { text, href } = domainCell(h);
|
||||
if (!text) return "—";
|
||||
return href ? (
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
text
|
||||
);
|
||||
},
|
||||
sortBy: (h) => domainCell(h).text,
|
||||
filterValue: (h) => domainCell(h).text,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "status",
|
||||
render: (h) => {
|
||||
const { tone, label } = FRESHNESS[h.freshness];
|
||||
return (
|
||||
<Badge
|
||||
tone={tone}
|
||||
value={
|
||||
h.last_seen_unix !== null ? (
|
||||
<>
|
||||
{label} (<RelativeTime epochMs={h.last_seen_unix * 1000} />)
|
||||
</>
|
||||
) : (
|
||||
label
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
sortBy: (h) => FRESHNESS[h.freshness].label,
|
||||
// Multiselect (not the single-value `"select"` mode) so the operator
|
||||
// can filter for e.g. "stale or offline" in one pass rather than one
|
||||
// freshness state at a time.
|
||||
filterValues: (h) => [FRESHNESS[h.freshness].label],
|
||||
filterMode: "multiselect",
|
||||
},
|
||||
];
|
||||
|
||||
// 30s default: this card has no inputs to interrupt, and the whole
|
||||
// point of a refresh-interval control is "no manual reload needed" — an
|
||||
// operator who wants it off still can, but the out-of-the-box behaviour
|
||||
// should actually solve the staleness problem rather than require an
|
||||
// opt-in every visit.
|
||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||
|
||||
// The roster is read on the first open and followed after, so an agents page
|
||||
// where the card is never opened does not poll it.
|
||||
function HiveRoster() {
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const { data: hives, error } = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<HiveStatus[]>("/api/hives/status", signal),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="ui-hives-refresh">
|
||||
<RefreshIntervalPicker
|
||||
id="hives-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<ApiErrorPanel
|
||||
context="failed to load the hive roster"
|
||||
problem={error}
|
||||
/>
|
||||
) : null}
|
||||
{!error && hives === null ? <p>loading…</p> : null}
|
||||
{hives ? (
|
||||
<Table
|
||||
columns={COLUMNS}
|
||||
rows={hives}
|
||||
rowKey={(h) => h.name}
|
||||
emptyMessage="no hives in the roster yet"
|
||||
storageKey="swarm-ui:hives:table-filters"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function HivesCard() {
|
||||
return (
|
||||
<LazyDisclosure
|
||||
title="hives"
|
||||
icon="🐝"
|
||||
hint="the swarm's hives and when each last reported"
|
||||
>
|
||||
<HiveRoster />
|
||||
</LazyDisclosure>
|
||||
);
|
||||
}
|
||||
19
frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css
Normal file
19
frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/* The cards on the swarm page: hives, then the notification router. */
|
||||
.ui-swarm-card {
|
||||
margin-block-end: 0.5em;
|
||||
}
|
||||
/* The roster's refresh picker, at the top right of the open card. */
|
||||
.ui-hives-refresh {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-block-end: 0.5em;
|
||||
}
|
||||
/* The warning at the top of the beta notification router card. */
|
||||
.ui-swarm-beta {
|
||||
margin: 0 0 0.75em;
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid var(--yellow);
|
||||
border-left-width: 4px;
|
||||
border-radius: 0.4em;
|
||||
background: color-mix(in srgb, var(--yellow) 12%, transparent);
|
||||
}
|
||||
40
frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.tsx
Normal file
40
frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// <SwarmPage> — what belongs to the swarm as a whole rather than to one agent:
|
||||
// the hive roster and the notification router. Each is a card that starts
|
||||
// folded and reads nothing until it is opened.
|
||||
//
|
||||
// The notification router is marked beta: its card says so beside the title and
|
||||
// carries a warning banner inside.
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
|
||||
import { NotificationRouter } from "../notifications/NotificationsPage.js";
|
||||
import { HivesCard } from "./HivesCard.js";
|
||||
import "./SwarmPage.css";
|
||||
|
||||
export function SwarmPage() {
|
||||
return (
|
||||
<>
|
||||
<div class="ui-swarm-card">
|
||||
<HivesCard />
|
||||
</div>
|
||||
<div class="ui-swarm-card">
|
||||
<LazyDisclosure
|
||||
title="notification router"
|
||||
icon="🔔"
|
||||
hint={
|
||||
<>
|
||||
<Badge tone="warning" value="beta" /> forge events routed to
|
||||
agents, as flows
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p class="ui-swarm-beta" role="note">
|
||||
<strong>beta:</strong> the notification router is still changing.
|
||||
Flows, their wording and what they match may change, and a flow you
|
||||
wrote may need rewriting after an update.
|
||||
</p>
|
||||
<NotificationRouter />
|
||||
</LazyDisclosure>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -57,10 +57,10 @@ const SOLID_KEY = "swarm-ui:solid-backgrounds";
|
|||
|
||||
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
||||
{ href: "/agents", label: "agents", accent: "var(--green)" },
|
||||
{ href: "/swarm", label: "swarm", accent: "var(--yellow)" },
|
||||
{ href: "/reports", label: "reports", accent: "var(--red)" },
|
||||
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
||||
{ href: "/messages", label: "messages", accent: "var(--purple)" },
|
||||
{ href: "/notifications", label: "notifications", accent: "var(--yellow)" },
|
||||
{ href: "/components", label: "components", accent: "var(--blue)" },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export const MOVED_ROUTES: Readonly<Record<string, string>> = {
|
|||
"/builds": "/jobs",
|
||||
"/review": "/jobs",
|
||||
"/schedules": "/messages",
|
||||
"/notifications": "/swarm",
|
||||
};
|
||||
|
||||
/** Where an old address goes, keeping its query; `null` for any other path. */
|
||||
|
|
|
|||
|
|
@ -69,3 +69,8 @@ test("no old address leads to another old address", () => {
|
|||
assert.equal(to in MOVED_ROUTES, false, to);
|
||||
}
|
||||
});
|
||||
|
||||
test("the old notifications list goes to the swarm tab, the editor pages stay", () => {
|
||||
assert.equal(movedHref("/notifications", ""), "/swarm");
|
||||
assert.equal(movedHref("/notifications/some-flow", ""), null);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue