swarm-ui: repoint the HivesPage comments at HivesCard and pin the nav filter and lazy roster
This commit is contained in:
parent
70917b7dd0
commit
209a286d7c
13 changed files with 63 additions and 18 deletions
|
|
@ -32,7 +32,7 @@ export interface AgentRow {
|
||||||
name: string;
|
name: string;
|
||||||
hive: string | null;
|
hive: string | null;
|
||||||
freshness: Freshness;
|
freshness: Freshness;
|
||||||
// `age_seconds` deliberately unused here, same reason as HivesPage:
|
// `age_seconds` deliberately unused here, same reason as HivesCard:
|
||||||
// `RelativeTime` recomputes age client-side from `last_seen_unix`
|
// `RelativeTime` recomputes age client-side from `last_seen_unix`
|
||||||
// rather than rendering a once-computed-at-fetch value.
|
// rather than rendering a once-computed-at-fetch value.
|
||||||
last_seen_unix: number | null;
|
last_seen_unix: number | null;
|
||||||
|
|
@ -50,11 +50,11 @@ export function showsLoginNeeded(row: Pick<AgentRow, "needs_login">): boolean {
|
||||||
return row.needs_login === true;
|
return row.needs_login === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same tone pairing as HivesPage — one freshness enum shared by both
|
// Same tone pairing as HivesCard — one freshness enum shared by both
|
||||||
// endpoints, so the same tone rule applies to both pages — but
|
// endpoints, so the same tone rule applies to both pages — but
|
||||||
// `unknown`'s label diverges deliberately: an agent outside the
|
// `unknown`'s label diverges deliberately: an agent outside the
|
||||||
// swarm-identity roster has a concrete next step (register it), while
|
// swarm-identity roster has a concrete next step (register it), while
|
||||||
// an unrecognized *hive* on HivesPage doesn't carry the same
|
// an unrecognized *hive* on HivesCard doesn't carry the same
|
||||||
// actionable meaning.
|
// actionable meaning.
|
||||||
export const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> =
|
export const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> =
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// <AlertsPage> — `/alerts`' content: the alerts the swarm's Grafana holds
|
// <AlertsPage> — `/alerts`' content: the alerts the swarm's Grafana holds
|
||||||
// now, from swarm-controller's `GET /api/alerts`. Only the firing ones show at
|
// now, from swarm-controller's `GET /api/alerts`. Only the firing ones show at
|
||||||
// first; a button in the header adds the pending, no-data and errored ones.
|
// first; a button in the header adds the pending, no-data and errored ones.
|
||||||
// Same shape as `HivesPage`: one fetch on a `RefreshIntervalPicker` cadence,
|
// Same shape as `HivesCard`: one fetch on a `RefreshIntervalPicker` cadence,
|
||||||
// rendered as a `Table`.
|
// rendered as a `Table`.
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ const TABLE_COLUMNS: TableColumn<Row>[] = [
|
||||||
render: (r) => r.detail,
|
render: (r) => r.detail,
|
||||||
// One filterable column, multiselect mode — the checkbox-list popover
|
// One filterable column, multiselect mode — the checkbox-list popover
|
||||||
// + reset-filters button are both real `Table` behavior an operator
|
// + reset-filters button are both real `Table` behavior an operator
|
||||||
// hits daily (`AgentsPage`/`HivesPage`), so the demo shows them rather
|
// hits daily (`AgentsPage`/`HivesCard`), so the demo shows them rather
|
||||||
// than a plain unfilterable grid.
|
// than a plain unfilterable grid.
|
||||||
filterValues: (r) => [r.detail],
|
filterValues: (r) => [r.detail],
|
||||||
filterMode: "multiselect",
|
filterMode: "multiselect",
|
||||||
|
|
@ -408,7 +408,7 @@ export function ComponentsPage() {
|
||||||
panel body content
|
panel body content
|
||||||
</Panel>
|
</Panel>
|
||||||
</Sample>
|
</Sample>
|
||||||
<Sample label="with actions (e.g. HivesPage's refresh picker)">
|
<Sample label="with actions (e.g. HivesCard's refresh picker)">
|
||||||
<Panel
|
<Panel
|
||||||
title="example title"
|
title="example title"
|
||||||
actions={
|
actions={
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ import {
|
||||||
} from "./CreateAgentSteps.js";
|
} from "./CreateAgentSteps.js";
|
||||||
import "./CreateAgentForm.css";
|
import "./CreateAgentForm.css";
|
||||||
|
|
||||||
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
|
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesCard`
|
||||||
// consumes off `/api/hives/status`, but this page hits the plain
|
// consumes off `/api/hives/status`, but this page hits the plain
|
||||||
// `/api/hives` roster (no status/freshness needed, just "what hives
|
// `/api/hives` roster (no status/freshness needed, just "what hives
|
||||||
// exist to spawn into").
|
// exist to spawn into").
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
// the dashboard's 4G3NTS tab shows — no `queueHref`, since a "view
|
// the dashboard's 4G3NTS tab shows — no `queueHref`, since a "view
|
||||||
// queue →" link back to this same page would be noise.
|
// queue →" link back to this same page would be noise.
|
||||||
//
|
//
|
||||||
// Auto-refresh reuses `HivesPage`'s exact pattern (`RefreshIntervalPicker`
|
// Auto-refresh reuses `HivesCard`'s exact pattern (`RefreshIntervalPicker`
|
||||||
// in the `Panel`'s `actions` slot, `useRefreshInterval` driving the
|
// in the `Panel`'s `actions` slot, `useRefreshInterval` driving the
|
||||||
// re-fetch), but ticks a `refreshToken` bump instead of doing its own
|
// re-fetch), but ticks a `refreshToken` bump instead of doing its own
|
||||||
// fetch — both `JobqGraph`/`JobqRollup` already accept that prop
|
// fetch — both `JobqGraph`/`JobqRollup` already accept that prop
|
||||||
|
|
@ -29,7 +29,7 @@ import {
|
||||||
} from "../ui/refresh-interval/RefreshInterval.js";
|
} from "../ui/refresh-interval/RefreshInterval.js";
|
||||||
import "./JobsPage.css";
|
import "./JobsPage.css";
|
||||||
|
|
||||||
// Same 30s default + same reasoning as `HivesPage`: no inputs on this
|
// Same 30s default + same reasoning as `HivesCard`: no inputs on this
|
||||||
// page for a refresh to clobber, so the out-of-the-box behaviour should
|
// 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.
|
// just solve staleness rather than require an opt-in every visit.
|
||||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// status panel has anything to render against without a roster to embed
|
// status panel has anything to render against without a roster to embed
|
||||||
// in — so all three ended up as one page.
|
// in — so all three ended up as one page.
|
||||||
//
|
//
|
||||||
// One fetch, on a refresh-interval cadence like HivesPage: `GET
|
// One fetch, on a refresh-interval cadence like HivesCard: `GET
|
||||||
// /api/agents/status` — one row per roster agent (identity store is the
|
// /api/agents/status` — one row per roster agent (identity store is the
|
||||||
// roster, so a never-reported agent still gets a row), each already
|
// roster, so a never-reported agent still gets a row), each already
|
||||||
// carrying its freshness/snapshot/config PR/wanted state. Used to be
|
// carrying its freshness/snapshot/config PR/wanted state. Used to be
|
||||||
|
|
@ -51,7 +51,7 @@ import { CreateAgentForm } from "../CreateAgentForm.js";
|
||||||
import { WantedMenu } from "../../agent/roster/WantedMenu.js";
|
import { WantedMenu } from "../../agent/roster/WantedMenu.js";
|
||||||
import "./AgentsPage.css";
|
import "./AgentsPage.css";
|
||||||
|
|
||||||
// Same 30s default + same reasoning as HivesPage: no inputs on this page
|
// Same 30s default + same reasoning as HivesCard: no inputs on this page
|
||||||
// for a refresh to clobber, so the out-of-the-box behaviour should just
|
// for a refresh to clobber, so the out-of-the-box behaviour should just
|
||||||
// solve staleness rather than require an opt-in every visit.
|
// solve staleness rather than require an opt-in every visit.
|
||||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||||
import { Table, type TableColumn } from "../../ui/table/Table.js";
|
import { Table, type TableColumn } from "../../ui/table/Table.js";
|
||||||
import { domainCell } from "../hive-domain.js";
|
import { domainCell } from "../hive-domain.js";
|
||||||
|
import { builtAfterToggle } from "./lazy-open.js";
|
||||||
|
|
||||||
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
|
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
|
||||||
|
|
||||||
|
|
@ -160,7 +161,7 @@ export function HivesCard() {
|
||||||
open={open}
|
open={open}
|
||||||
onToggle={(next) => {
|
onToggle={(next) => {
|
||||||
setOpen(next);
|
setOpen(next);
|
||||||
if (next) setBuilt(true);
|
setBuilt((was) => builtAfterToggle(was, next));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{built ? <HiveRoster /> : null}
|
{built ? <HiveRoster /> : null}
|
||||||
|
|
|
||||||
5
frontend/packages/swarm-ui/src/pages/agents/lazy-open.ts
Normal file
5
frontend/packages/swarm-ui/src/pages/agents/lazy-open.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// A folded card builds its body on the first open and keeps it after, so a
|
||||||
|
// card nobody opens never fetches, and closing it does not drop what it read.
|
||||||
|
export function builtAfterToggle(built: boolean, open: boolean): boolean {
|
||||||
|
return built || open;
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ import { SettingsMenu } from "@hive/shared/settings-menu.js";
|
||||||
import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js";
|
import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js";
|
||||||
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
|
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
|
||||||
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
|
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
|
||||||
import { isFullBleedRoute } from "./routes.js";
|
import { isFullBleedRoute, visibleNavItems } from "./routes.js";
|
||||||
import { useDynamicTabs } from "./useDynamicTabs.js";
|
import { useDynamicTabs } from "./useDynamicTabs.js";
|
||||||
import { useSwarmName } from "./useSwarmName.js";
|
import { useSwarmName } from "./useSwarmName.js";
|
||||||
import "./Shell.css";
|
import "./Shell.css";
|
||||||
|
|
@ -212,9 +212,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
||||||
// The components tab is a developer page: it goes while "hide developer
|
// The components tab is a developer page: it goes while "hide developer
|
||||||
// details" is on. The page itself still answers at `/components`.
|
// details" is on. The page itself still answers at `/components`.
|
||||||
const hideDeveloper = useHideDebug();
|
const hideDeveloper = useHideDebug();
|
||||||
const navItems = hideDeveloper
|
const navItems = visibleNavItems(NAV_ITEMS, hideDeveloper);
|
||||||
? NAV_ITEMS.filter((item) => item.href !== "/components")
|
|
||||||
: NAV_ITEMS;
|
|
||||||
|
|
||||||
// Applied once here, not inside `SettingsMenu` — every route mounts
|
// Applied once here, not inside `SettingsMenu` — every route mounts
|
||||||
// through this one `<Shell>`, so the override takes effect regardless
|
// through this one `<Shell>`, so the override takes effect regardless
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,15 @@ const FULL_BLEED_ROUTE_PATTERN =
|
||||||
export function isFullBleedRoute(location: string): boolean {
|
export function isFullBleedRoute(location: string): boolean {
|
||||||
return location === "/" || FULL_BLEED_ROUTE_PATTERN.test(location);
|
return location === "/" || FULL_BLEED_ROUTE_PATTERN.test(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Routes that only a developer needs. "hide developer details" takes their
|
||||||
|
// tabs out of the nav; the pages themselves still answer at their paths.
|
||||||
|
const DEVELOPER_ROUTES = ["/components"];
|
||||||
|
|
||||||
|
export function visibleNavItems<T extends { href: string }>(
|
||||||
|
items: readonly T[],
|
||||||
|
hideDeveloper: boolean,
|
||||||
|
): readonly T[] {
|
||||||
|
if (!hideDeveloper) return items;
|
||||||
|
return items.filter((item) => !DEVELOPER_ROUTES.includes(item.href));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
// belongs next to the thing it affects, not tucked into a catch-all
|
// belongs next to the thing it affects, not tucked into a catch-all
|
||||||
// menu" — and per mara's review on the refresh-interval PR, not a
|
// menu" — and per mara's review on the refresh-interval PR, not a
|
||||||
// dedicated row stealing vertical space from the panel's actual
|
// dedicated row stealing vertical space from the panel's actual
|
||||||
// content either). `HivesPage`'s refresh-interval picker is the
|
// content either). `HivesCard`'s refresh-interval picker is the
|
||||||
// motivating caller.
|
// motivating caller.
|
||||||
//
|
//
|
||||||
// `onClose`: end-of-header close (✕) for `Dialog`'s `plain` mode.
|
// `onClose`: end-of-header close (✕) for `Dialog`'s `plain` mode.
|
||||||
|
|
|
||||||
16
frontend/packages/swarm-ui/test/lazy-open.test.mjs
Normal file
16
frontend/packages/swarm-ui/test/lazy-open.test.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// A folded card fetches nothing until first opened. Run by `npm test`.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { builtAfterToggle } from "../src/pages/agents/lazy-open.ts";
|
||||||
|
|
||||||
|
test("a card nobody opened is not built", () => {
|
||||||
|
assert.equal(builtAfterToggle(false, false), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the first open builds it", () => {
|
||||||
|
assert.equal(builtAfterToggle(false, true), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing it afterwards keeps it built", () => {
|
||||||
|
assert.equal(builtAfterToggle(true, false), true);
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// The shell's per-route layout: which paths are full-bleed. Run by `npm test`.
|
// The shell's per-route layout: which paths are full-bleed. Run by `npm test`.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { isFullBleedRoute } from "../src/shell/routes.ts";
|
import { isFullBleedRoute, visibleNavItems } from "../src/shell/routes.ts";
|
||||||
|
|
||||||
test("the start page is full-bleed", () => {
|
test("the start page is full-bleed", () => {
|
||||||
assert.equal(isFullBleedRoute("/"), true);
|
assert.equal(isFullBleedRoute("/"), true);
|
||||||
|
|
@ -29,3 +29,16 @@ test("a terminal path with extra segments is not full-bleed", () => {
|
||||||
assert.equal(isFullBleedRoute("/agents/iris/terminal/extra"), false);
|
assert.equal(isFullBleedRoute("/agents/iris/terminal/extra"), false);
|
||||||
assert.equal(isFullBleedRoute("/agents//terminal"), false);
|
assert.equal(isFullBleedRoute("/agents//terminal"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const NAV = [{ href: "/agents" }, { href: "/components" }, { href: "/jobs" }];
|
||||||
|
|
||||||
|
test("hide developer details takes the components tab out of the nav", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
visibleNavItems(NAV, true).map((i) => i.href),
|
||||||
|
["/agents", "/jobs"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with it off the nav is whole again", () => {
|
||||||
|
assert.deepEqual(visibleNavItems(NAV, false), NAV);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue