swarm-ui: hives are a folded card on the agents page, and hide developer details also hides the components tab
The hives nav tab and route go, with no redirect; the roster is a card above the agents panel that starts folded and is read on the first open. The settings toggle is renamed from hide debug output to hide developer details, as it now also drops the components tab (the page still answers at /components); the stored preference keeps its key.
This commit is contained in:
parent
0cbfa806d3
commit
70917b7dd0
12 changed files with 110 additions and 49 deletions
|
|
@ -21,10 +21,9 @@ changing anything. Typing the start of an option's name picks it, also with the
|
|||
shut. A dropdown inside a dialog opens above the dialog and the dialog doesn't cut it off.
|
||||
|
||||
| route | what |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here |
|
||||
| `/hives` | the hive directory, each hive with its last reported status |
|
||||
| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps, permissions; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts |
|
||||
| `/agents` | every agent, under a folded **hives** card with the hive directory and each hive's last reported status: status, config PR, wanted state, inference settings, resource caps, permissions; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts |
|
||||
| `/agents/<name>/terminal` | one agent's live terminal |
|
||||
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
||||
| `/alerts` | the alerts the swarm's Grafana holds now: the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token |
|
||||
|
|
@ -95,10 +94,11 @@ stream with an error status, the browser gives up and the badge reads
|
|||
**disconnected**. The **reconnect** button beside it opens the stream again, and
|
||||
the stream replays the rows. The messages page works the same way.
|
||||
|
||||
The settings menu in the header has a **hide debug output** toggle. It drops
|
||||
The settings menu in the header has a **hide developer details** toggle. It drops
|
||||
the muted debug rows (thinking, coalesced ticks, harness chatter) from every
|
||||
terminal, the preview and the tabs, and rows already on screen hide or show as
|
||||
you flip it. The browser keeps the choice for the swarm UI, apart from the
|
||||
you flip it. It also hides the **components** tab, a page for developers, which
|
||||
still answers at `/components`. The browser keeps the choice for the swarm UI, apart from the
|
||||
per-agent pages'.
|
||||
|
||||
A terminal's own tab takes the agent's icon as its browser-tab icon, so tabs of
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ that motivated the change.
|
|||
| `warn` | amber, left rule | stderr, an unrecognised event shape, API retries |
|
||||
| `error` | red, left rule | turn failed, a tool result with `is_error: true` |
|
||||
|
||||
The settings menu's "hide debug output" toggle (`getHideDebugPref()`,
|
||||
The settings menu's "hide developer details" toggle (`getHideDebugPref()`,
|
||||
`@hive/shared/prefs.js`) skips `debug`-level rows entirely rather than
|
||||
muting them further — client-side only, no reload. The per-agent page and
|
||||
the swarm UI both have the toggle, as one shared row
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Settings-menu row: "hide debug output". Rendered as a `<SettingsMenu>`
|
||||
// Settings-menu row: "hide developer details". Rendered as a `<SettingsMenu>`
|
||||
// child (`./SettingsMenu.js`) by each page that has a terminal, the
|
||||
// per-agent page and the swarm UI, rather than as a prop on the menu: the
|
||||
// caller owns its row. Both read the same preference (`../prefs.js`), which
|
||||
|
|
@ -11,7 +11,7 @@ export function HideDebugSetting() {
|
|||
const [hideDebug, setHideDebugState] = useState(() => getHideDebugPref());
|
||||
return (
|
||||
<label class="settings-menu-row">
|
||||
<span>hide debug output</span>
|
||||
<span>hide developer details</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideDebug}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ function DiffBody({ text }: { text: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
// A `debug` row, which follows the "hide debug output" preference as it
|
||||
// A `debug` row, which follows the "hide developer details" preference as it
|
||||
// changes. Only debug rows subscribe, so a long terminal does not hold a
|
||||
// listener per row.
|
||||
function DebugRow({ row }: { row: TermRow }) {
|
||||
|
|
@ -67,7 +67,7 @@ function DebugRow({ row }: { row: TermRow }) {
|
|||
() => subscribeHideDebugPref(() => setHidden(getHideDebugPref())),
|
||||
[],
|
||||
);
|
||||
// "hide debug output" is a skip, not a dimmer — a hidden debug row
|
||||
// "hide developer details" is a skip, not a dimmer — a hidden debug row
|
||||
// still occupies no DOM node at all, same as it never streamed, rather
|
||||
// than a CSS `display: none` that would keep it in the layout/DOM for
|
||||
// no benefit.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
|
|||
import { NotificationsPage } from "./pages/notifications/NotificationsPage.js";
|
||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||
import { JobsPage } from "./pages/JobsPage.js";
|
||||
import { HivesPage } from "./pages/HivesPage.js";
|
||||
import { IssueReportPage } from "./pages/IssueReportPage.js";
|
||||
import { ReviewPage } from "./pages/review/ReviewPage.js";
|
||||
import { SchedulesPage } from "./pages/schedules/SchedulesPage.js";
|
||||
|
|
@ -34,7 +33,6 @@ export function App() {
|
|||
<Shell>
|
||||
<Switch>
|
||||
<Route path="/" component={StartPage} />
|
||||
<Route path="/hives" component={HivesPage} />
|
||||
<Route path="/alerts" component={AlertsPage} />
|
||||
<Route path="/agents" component={AgentsPage} />
|
||||
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// <StartPage> — `/`, the swarm's landing page: the swarm's name over the
|
||||
// digital-rain backdrop, plus the setup assistant while the swarm has no
|
||||
// `ruth`. The header's swarm name links here. The hive roster that used to be
|
||||
// `/` is now `/hives`.
|
||||
// `ruth`. The header's swarm name links here. The hive roster is a card on the agents page.
|
||||
import { MatrixRain } from "../shell/MatrixRain.js";
|
||||
import { useSwarmName } from "../shell/useSwarmName.js";
|
||||
import { SetupAssistant } from "./SetupAssistant.js";
|
||||
|
|
|
|||
|
|
@ -36,3 +36,14 @@
|
|||
.ui-agents-view-toggle button:not(.active):hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* The folded hives card over the agents and details panels. */
|
||||
.ui-agents-hives {
|
||||
margin-block-end: 1em;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { Button } from "../../ui/button/Button.js";
|
|||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import { HivesCard } from "./HivesCard.js";
|
||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
|
|
@ -255,6 +256,9 @@ export function AgentsPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div class="ui-agents-hives">
|
||||
<HivesCard />
|
||||
</div>
|
||||
<SplitView
|
||||
primary={
|
||||
<Panel
|
||||
|
|
|
|||
169
frontend/packages/swarm-ui/src/pages/agents/HivesCard.tsx
Normal file
169
frontend/packages/swarm-ui/src/pages/agents/HivesCard.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// <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 { Disclosure } from "../../ui/disclosure/Disclosure.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() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [built, setBuilt] = useState(false);
|
||||
return (
|
||||
<Disclosure
|
||||
title="hives"
|
||||
icon="🐝"
|
||||
hint="the swarm's hives and when each last reported"
|
||||
open={open}
|
||||
onToggle={(next) => {
|
||||
setOpen(next);
|
||||
if (next) setBuilt(true);
|
||||
}}
|
||||
>
|
||||
{built ? <HiveRoster /> : null}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import type { ComponentChildren } from "preact";
|
|||
import { Link, useLocation } from "wouter-preact";
|
||||
import { LinksMenu } from "./LinksMenu.js";
|
||||
import { UserMenu } from "./UserMenu.js";
|
||||
import { useHideDebug } from "./useHideDebug.js";
|
||||
import { SettingsMenu } from "@hive/shared/settings-menu.js";
|
||||
import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js";
|
||||
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
|
||||
|
|
@ -49,7 +50,6 @@ const THEME_KEY = "swarm-ui:theme-override";
|
|||
const MOTION_KEY = "swarm-ui:motion-override";
|
||||
|
||||
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
||||
{ href: "/hives", label: "hives", accent: "var(--purple)" },
|
||||
{ href: "/agents", label: "agents", accent: "var(--green)" },
|
||||
{ href: "/alerts", label: "alerts", accent: "var(--red)" },
|
||||
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
||||
|
|
@ -209,6 +209,13 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
// rather than reusing `NavLink`.
|
||||
const { tabs, close: closeTab } = useDynamicTabs();
|
||||
|
||||
// The components tab is a developer page: it goes while "hide developer
|
||||
// details" is on. The page itself still answers at `/components`.
|
||||
const hideDeveloper = useHideDebug();
|
||||
const navItems = hideDeveloper
|
||||
? NAV_ITEMS.filter((item) => item.href !== "/components")
|
||||
: NAV_ITEMS;
|
||||
|
||||
// Applied once here, not inside `SettingsMenu` — every route mounts
|
||||
// through this one `<Shell>`, so the override takes effect regardless
|
||||
// of which page is showing or whether the menu's ever been opened,
|
||||
|
|
@ -226,7 +233,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
// scroll-offset bookkeeping needed.
|
||||
function measureIndex(index: number): IndicatorRect | null {
|
||||
const navEl = navRef.current;
|
||||
const item = NAV_ITEMS[index];
|
||||
const item = navItems[index];
|
||||
const textEl = item ? textRefs.current[item.href] : null;
|
||||
if (!navEl || !item || !textEl) return null;
|
||||
const navRect = navEl.getBoundingClientRect();
|
||||
|
|
@ -245,8 +252,14 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
// [c's colour]"). Re-runs on navigation; a separate resize listener
|
||||
// below re-measures the already-settled position without re-hopping
|
||||
// (a reflow isn't a navigation).
|
||||
// The list changed (the components tab came or went), so the settled index
|
||||
// no longer names the same item: land directly on the active one again.
|
||||
useEffect(() => {
|
||||
const targetIndex = NAV_ITEMS.findIndex((item) => item.href === location);
|
||||
settledIndexRef.current = -1;
|
||||
}, [hideDeveloper]);
|
||||
|
||||
useEffect(() => {
|
||||
const targetIndex = navItems.findIndex((item) => item.href === location);
|
||||
if (targetIndex === -1) {
|
||||
settledIndexRef.current = -1;
|
||||
setIndicator(null);
|
||||
|
|
@ -278,7 +291,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
cancelled = true;
|
||||
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [location]);
|
||||
}, [location, hideDeveloper]);
|
||||
|
||||
// Fires the render *after* the indicator's first non-null commit —
|
||||
// deliberately one tick behind, not folded into the effect above,
|
||||
|
|
@ -323,7 +336,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
// label its page registered as a dynamic tab. An unmatched path gets
|
||||
// the bare brand.
|
||||
const pageLabel =
|
||||
NAV_ITEMS.find((item) => item.href === location)?.label ??
|
||||
navItems.find((item) => item.href === location)?.label ??
|
||||
tabs.find((tab) => tab.href === location)?.label;
|
||||
useEffect(() => {
|
||||
document.title = pageLabel ? `${pageLabel} · ${brand}` : brand;
|
||||
|
|
@ -355,7 +368,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
</span>
|
||||
</Link>
|
||||
<nav class="shell-nav" ref={navRef}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
|
|
|
|||
17
frontend/packages/swarm-ui/src/shell/useHideDebug.ts
Normal file
17
frontend/packages/swarm-ui/src/shell/useHideDebug.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// The "hide developer details" preference as a value that follows its toggle:
|
||||
// this tab's, through `setHideDebugPref`, and another tab's, through the
|
||||
// `storage` event. The header uses it to hide the components tab.
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import {
|
||||
getHideDebugPref,
|
||||
subscribeHideDebugPref,
|
||||
} from "@hive/shared/prefs.js";
|
||||
|
||||
export function useHideDebug(): boolean {
|
||||
const [hide, setHide] = useState(getHideDebugPref);
|
||||
useEffect(
|
||||
() => subscribeHideDebugPref(() => setHide(getHideDebugPref())),
|
||||
[],
|
||||
);
|
||||
return hide;
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ test("the start page is full-bleed", () => {
|
|||
});
|
||||
|
||||
test("the hive directory moved off / and is not full-bleed", () => {
|
||||
assert.equal(isFullBleedRoute("/hives"), false);
|
||||
assert.equal(isFullBleedRoute("/agents"), false);
|
||||
});
|
||||
|
||||
test("an agent's terminal and a subagent's are full-bleed", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue