Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui/agents: split the page, extract FilterableView per mara's follow-up

Three more asks from the same review thread:

- "agentspage is now giant and deserves a split" - AgentsPage.tsx was
  1047 lines. Split into AgentTypes.ts (AgentRow and friends),
  WantedMenu.tsx, AgentCard.tsx (+ its own CSS), leaving AgentsPage.tsx
  as state/actions/columns/the render tree - 649 lines, and every piece
  it composes is now independently readable.

- "what about the component that represents filtered data ... that the
  card view and table can both use?" - extracted FilterableView
  (ui/filterable-view/): takes columns/rows/rowKey/storageKey/view/
  renderCard, builds its filter bar from *every* filterable column (not
  a hand-picked subset - the old AgentFilterBar only showed 4 of the
  agent columns' 6 filterable fields, an accidental gap the table's own
  popovers didn't have), and renders either the card list or Table.
  AgentsPage now just tells it which view to show; the view toggle
  itself stays page-side since it's Panel-header chrome, not filtering.
  Disclosed side effect: card view's filter bar now also covers
  message/config-PR (text filters), matching table view exactly instead
  of a narrower subset.

- CSS audit: AgentsPage.css now holds only what's genuinely page-specific
  (the view toggle, the detail-panel field grid) - everything else moved
  to its owning component's own colocated CSS.

FilterableView gets a /components demo (view toggle + filter bar + both
render modes, same day per the design guide). Verified: AgentsPage
still renders the same (real screenshot), and the demo's own table
toggle produces a real Table with the same rows.
This commit is contained in:
iris 2026-09-11 22:30:38 +02:00
commit 601068f1eb
9 changed files with 591 additions and 491 deletions

View file

@ -18,28 +18,24 @@
// natural home for the action that populates it. The form itself
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
//
// Two interchangeable views over the same `rows`/actions, picked by
// `viewMode`: the original `Table` (full columns, per-column sort/
// filter) and one `AgentCard` per row (see that component's own
// comment for the card/detail-panel split). Mara, after the card view
// landed: "still want the filters tho" / "split data component from
// the view... switchable between table and specialized card view" —
// so the toggle, not a card-only replacement.
//
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
// that component's own comment above `AgentsPage` for why.
import { useRef, useState } from "preact/hooks";
// This file owns the page's own state/actions/`columns` only —
// `AgentRow` and friends live in `AgentTypes.ts`, the wanted-state
// control in `WantedMenu.tsx`, the card content in `AgentCard.tsx`
// (mara: "agentspage is now giant and deserves a split"). Filtering +
// cards-vs-table rendering is `FilterableView` (`ui/filterable-view/`,
// see its own comment) — `viewMode` here just says which one to show.
import { useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import { Badge } from "@hive/shared/badge.js";
import { LinkIcon } from "@hive/shared/icons.js";
import { AgentCard } from "./AgentCard.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { Button } from "../ui/button/Button.js";
import { Card } from "../ui/card/Card.js";
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
import { Dialog } from "../ui/dialog/Dialog.js";
import { MultiselectFilter } from "../ui/multiselect-filter/MultiselectFilter.js";
import { FilterableView } from "../ui/filterable-view/FilterableView.js";
import { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import {
@ -48,65 +44,12 @@ import {
type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js";
import { SplitView } from "../ui/split-view/SplitView.js";
import { Table, useTableFilters, type TableColumn } from "../ui/table/Table.js";
import { type TableColumn } from "../ui/table/Table.js";
import { CreateAgentForm } from "./CreateAgentForm.js";
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
import { WantedMenu } from "./WantedMenu.js";
import "./AgentsPage.css";
interface ConfigPrStatus {
pr_number: number;
html_url: string | null;
}
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
interface AgentStatusSnapshot {
status_text: string | null;
status_set_at: number | null;
running: boolean;
}
// `"up"` / `"offline"` / `"destroyed"`, wire-spelled by
// `swarm_queue_client::wanted::AgentState::as_str` — kept as a bare
// `string | null` rather than a union, same reason
// `config_pr`'s shape isn't re-derived here: this page renders whatever
// the wire sends, it doesn't validate the enum client-side.
type Wanted = string | null;
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the
// row this page renders, not a shape assembled from it, so there's no
// separate join-result type to keep in sync with the wire contract by
// hand.
interface AgentRow {
name: string;
hive: string | null;
freshness: Freshness;
last_seen_unix: number | null;
// `age_seconds` deliberately unused here, same reason as HivesPage:
// `RelativeTime` recomputes age client-side from `last_seen_unix`
// rather than rendering a once-computed-at-fetch value.
snapshot: AgentStatusSnapshot | null;
config_pr: ConfigPrStatus | null;
wanted: Wanted;
}
// Same tone pairing as HivesPage — one freshness enum shared by both
// endpoints, so the same tone rule applies to both pages — but `unknown`'s
// label diverges deliberately: an agent outside the swarm-identity roster
// has a concrete next step (register it), while an unrecognized *hive* on
// HivesPage doesn't carry the same actionable meaning.
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` means the agent reported into the KV bucket but isn't in
// the swarm-identity roster — a real, distinct case ("hive-reported,
// not yet in swarm level"), not just a fallback default, so it gets a
// label that says what to do about it rather than reusing the enum
// name as the label.
unknown: { tone: "negative", label: "not in swarm identity" },
};
// The one thing that differs between the "offline" and "paused" confirm
// dialogs `confirmTarget` drives — everything else (button row, open/close
// wiring) is the shared `ConfirmDialog`.
@ -151,304 +94,6 @@ type ViewMode = "cards" | "table";
// a chosen view is a standing preference, not a per-visit default.
const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
// The "wanted" column's control: one badge showing the current
// declaration, opening a `Dropdown` with the four explicit states —
// replaces the old toggle-badge-plus-separate-destroy-badge pair.
// Explicit options also fix a real bug the toggle had: for an
// undeclared row, the toggle inferred a target as "the opposite of
// `snapshot.running`", so a click on an undeclared-but-running agent
// silently declared it *offline* rather than making its real state
// explicit — a dropdown just lets "up" be picked directly regardless
// of what's inferred, no flip-to-the-opposite-of-a-guess involved
// (mara: "when no state is declared, i want to set it to online").
// Presentational only, same split as `StatusChips`'s `Picker`/
// `StatusMenu`: the caller owns what each selection actually does.
//
// "paused" has no separate resume option here — selecting "up" from a
// paused row is the resume, same PUT either way (`declareState`'s
// backend counterpart, `hive-c0re`'s `decide_pause`, treats a declared
// `Up` with the marker still set as "clear it"). A dedicated "resume"
// entry would just be a second spelling of the option already above it.
function WantedMenu({
row,
pending,
onSelectUp,
onSelectOffline,
onSelectPaused,
onDestroy,
showDestroy = true,
portal = false,
}: {
row: AgentRow;
pending: boolean;
onSelectUp: (row: AgentRow) => void;
onSelectOffline: (row: AgentRow) => void;
onSelectPaused: (row: AgentRow) => void;
onDestroy?: (row: AgentRow) => void;
/**
* The card view (see `AgentCard` below) passes `false` here — its
* quick-access menu keeps to the three everyday states, so "destroy"
* can't be reached by an extra click off a state that's already open.
* `AgentsPage`'s detail-panel `WantedMenu` keeps the default (`true`)
* — mara: "destroy is already available via wanted state", i.e. the
* full menu there, not a separate button.
*/
showDestroy?: boolean;
/**
* Forwarded to `Dropdown`'s own `portal` (see its file-top comment) —
* only the table's "wanted" column needs it, to escape
* `.ui-table-scroll`'s clip on the table's last row (the bug `Dropdown`'s
* own `portal` prop exists to fix in the first place).
* Neither the card list nor the detail panel has a clipping ancestor,
* and inside the detail panel specifically `portal` is actively wrong:
* a `position: fixed` element appended to `document.body` renders
* *behind* an open native `<dialog>` (the dialog is promoted to the
* browser's top layer, which composites above ordinary body content
* regardless of z-index) — found while screenshotting this exact
* dropdown open inside the detail panel, only the portion extending
* past the dialog's own edge was visible.
*/
portal?: boolean;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const destroyed = row.wanted === "destroyed";
const tone: BadgeTone =
row.wanted === "up"
? "positive"
: row.wanted === "paused"
? "warning"
: destroyed
? "negative"
: "neutral";
const options: DropdownOption[] = [
{ value: "up", label: "up" },
{ value: "paused", label: "paused" },
{ value: "offline", label: "offline" },
...(showDestroy
? [{ value: "destroy", label: "destroy", danger: true }]
: []),
];
return (
<div class="ui-dropdown-anchor" ref={anchorRef}>
<Badge
tone={tone}
value={pending ? "…" : (row.wanted ?? "no declaration")}
// `destroyed` blocks opening the menu at all — same UX-only
// guard as before (real enforcement is server-side, see
// `declareState`'s caller), just applied to the trigger instead
// of a `disabled` toggle badge.
onClick={row.hive && !destroyed ? () => setOpen((o) => !o) : undefined}
expanded={open}
disabled={pending || !row.hive || destroyed}
title={
destroyed
? `${row.name} is destroyed — redeploy via "+ agent" to bring it back`
: row.hive
? `declare a new state for ${row.name}`
: "no hive on record for this agent — nothing to declare against"
}
/>
<Dropdown
open={open}
portal={portal}
options={options}
activeValue={row.wanted ?? undefined}
label={`declare ${row.name}`}
onSelect={(value) => {
setOpen(false);
if (value === "up") onSelectUp(row);
else if (value === "offline") onSelectOffline(row);
else if (value === "paused") onSelectPaused(row);
else onDestroy?.(row);
}}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
/>
</div>
);
}
// One card per roster agent (mara: "main view: name, status, message,
// wanted" / "message as second line" / "more like card per agent") —
// the `viewMode === "cards"` alternative to the original `Table`, not
// its replacement (see `AgentsPage`'s own comment). Clicking a card
// opens a detail panel repeating this same info plus what doesn't fit
// here (hive, config-PR link, matrix link-account) — mara: "the info
// from main list should be included in the agent view" too, not just
// the leftovers. A plain `Dialog`, not the shared `hive-side-panel`
// slide-in drawer: that's a shadow-DOM custom element, and swarm-ui's
// esbuild config can't consume those yet (same gap `Dialog.tsx`'s own
// comment already flags for `hive-dialog`). Revisit once that closes.
//
// Built on the shared `Card` primitive (`ui/card/`) for the clickable/
// selectable-container mechanics — the `stopPropagation` wrapper around
// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized,
// `WantedMenu` hosts real `<button>`s) from also firing `Card`'s
// `onClick` and opening the detail panel.
function AgentCard({
row,
pending,
error,
selected,
onSelectUp,
onSelectOffline,
onSelectPaused,
onOpenDetail,
}: {
row: AgentRow;
pending: boolean;
error: ProblemDetails | undefined;
/** This row is the one currently shown in the detail panel — a
* highlight only (the panel itself is the source of truth), so a
* page-refresh landing on a still-valid `detailTarget` reads as
* obviously "that one" rather than a silent selection nothing marks. */
selected: boolean;
onSelectUp: (row: AgentRow) => void;
onSelectOffline: (row: AgentRow) => void;
onSelectPaused: (row: AgentRow) => void;
onOpenDetail: (row: AgentRow) => void;
}) {
const { tone, label } = FRESHNESS[row.freshness];
return (
<Card selected={selected} onClick={() => onOpenDetail(row)}>
<div class="ui-agent-card-line1">
<span class="ui-agent-card-name">{row.name}</span>
<Badge
tone={tone}
title={
row.freshness === "unknown"
? "reported by its hive but not registered in swarm-level identity — needs migration"
: undefined
}
value={
<>
{label}
{row.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
<span class="ui-agent-card-wanted" onClick={(e) => e.stopPropagation()}>
<WantedMenu
row={row}
pending={pending}
showDestroy={false}
onSelectUp={onSelectUp}
onSelectOffline={onSelectOffline}
onSelectPaused={onSelectPaused}
/>
{error ? (
<Badge
tone="negative"
value="failed"
title={error.detail ?? "the declaration failed"}
/>
) : null}
</span>
</div>
<div class="ui-agent-card-message">
{row.snapshot?.status_text ?? "—"}
</div>
</Card>
);
}
// One `<input>` (the "name" column's plain-text filter) plus one shared
// `MultiselectFilter` (`ui/multiselect-filter/`) per multiselect-mode
// filterable column — the card view's answer to `Table`'s own
// per-column popover icons, driving the exact same `useTableFilters`
// state `AgentsPage` passes down (mara: "same filters for the cards
// tho"). Deliberately not a generic "render every filterable column
// automatically" loop: the name column's free-text filter needs its own
// always-visible `<input>`, not a dropdown, so there's no single shape
// that covers every column anyway.
function AgentFilterBar({
columns,
getFilter,
updateFilter,
toggleFilterValue,
resetFilters,
activeFilters,
multiselectOptionsFor,
}: {
columns: TableColumn<AgentRow>[];
getFilter: (key: string) => {
value: string;
values: string[];
negate: boolean;
};
updateFilter: (
key: string,
patch: Partial<{ value: string; values: string[]; negate: boolean }>,
) => void;
toggleFilterValue: (key: string, value: string) => void;
resetFilters: () => void;
activeFilters: unknown[];
multiselectOptionsFor: (c: TableColumn<AgentRow>) => string[];
}) {
const hiveCol = columns.find((c) => c.key === "hive");
const statusCol = columns.find((c) => c.key === "status");
const wantedCol = columns.find((c) => c.key === "wanted");
return (
<div class="ui-agents-filter-bar">
<input
type="text"
class="ui-agents-filter-search"
placeholder="filter by name…"
aria-label="filter by name"
value={getFilter("name").value}
onInput={(e) =>
updateFilter("name", {
value: (e.target as HTMLInputElement).value,
})
}
/>
{hiveCol ? (
<MultiselectFilter
label="hive"
options={multiselectOptionsFor(hiveCol)}
filter={getFilter("hive")}
onToggle={(v) => toggleFilterValue("hive", v)}
onNegateChange={(negate) => updateFilter("hive", { negate })}
/>
) : null}
{statusCol ? (
<MultiselectFilter
label="status"
options={multiselectOptionsFor(statusCol)}
filter={getFilter("status")}
onToggle={(v) => toggleFilterValue("status", v)}
onNegateChange={(negate) => updateFilter("status", { negate })}
/>
) : null}
{wantedCol ? (
<MultiselectFilter
label="wanted"
options={multiselectOptionsFor(wantedCol)}
filter={getFilter("wanted")}
onToggle={(v) => toggleFilterValue("wanted", v)}
onNegateChange={(negate) => updateFilter("wanted", { negate })}
/>
) : null}
{activeFilters.length > 0 ? (
<button
type="button"
class="ui-table-reset-filters"
onClick={resetFilters}
>
reset filters
</button>
) : null}
</div>
);
}
export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
@ -628,9 +273,10 @@ export function AgentsPage() {
const selectPaused = (row: AgentRow) =>
setConfirmTarget({ row, state: "paused" });
// `viewMode === "table"`'s columns — the full field set (including
// per-column sort/filter, which the card view doesn't have) mara asked
// to keep. Table's own `WantedMenu` keeps the default `showDestroy`
// `FilterableView`'s `columns` — the full field set (including
// per-column sort/filter, which drives both the table's own popovers
// and the card view's filter bar now — see that component's own
// comment). Table's own `WantedMenu` keeps the default `showDestroy`
// (the table's cells have room for the fourth option the card view
// moved out to its detail panel).
const columns: TableColumn<AgentRow>[] = [
@ -760,21 +406,6 @@ export function AgentsPage() {
},
];
// Same hook `Table` itself now uses internally, called again here with
// the identical `columns` + `storageKey` — one filter state (in
// localStorage), two independent readers. `rows ?? []`: the hook wants
// a real array, and there's nothing to filter before the first
// `refresh()` resolves anyway.
const {
visibleRows,
activeFilters,
getFilter,
updateFilter,
toggleFilterValue,
resetFilters,
multiselectOptionsFor,
} = useTableFilters(columns, rows ?? [], "swarm-ui:agents:table-filters");
return (
<>
<SplitView
@ -824,57 +455,26 @@ export function AgentsPage() {
/>
) : null}
{!error && rows === null ? <p>loading…</p> : null}
{rows && rows.length === 0 ? (
<p class="ui-agents-empty">
no agents yet — the swarm-wide identity store has no agents
registered on any hive
</p>
) : null}
{/* Card view's own filter toolbar — table view keeps its
existing per-column popovers instead (see `columns` above);
both read/write the same `useTableFilters` state (same
`storageKey`), so switching the view toggle doesn't reset or
hide whatever's filtered. */}
{rows && rows.length > 0 && viewMode === "cards" ? (
<AgentFilterBar
columns={columns}
getFilter={getFilter}
updateFilter={updateFilter}
toggleFilterValue={toggleFilterValue}
resetFilters={resetFilters}
activeFilters={activeFilters}
multiselectOptionsFor={multiselectOptionsFor}
/>
) : null}
{rows && rows.length > 0 && viewMode === "cards" ? (
<div class="ui-agent-card-list">
{visibleRows.length === 0 ? (
<p class="ui-agents-empty">
no rows match the current filter
</p>
) : (
visibleRows.map((a) => (
<AgentCard
key={a.name}
row={a}
pending={pendingAgents.has(a.name)}
error={actionErrors.get(a.name)}
selected={detailTargetName === a.name}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onOpenDetail={(row) => setDetailTargetName(row.name)}
/>
))
)}
</div>
) : null}
{rows && rows.length > 0 && viewMode === "table" ? (
<Table
{rows ? (
<FilterableView
columns={columns}
rows={rows}
rowKey={(a) => a.name}
storageKey="swarm-ui:agents:table-filters"
view={viewMode}
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
renderCard={(a) => (
<AgentCard
row={a}
pending={pendingAgents.has(a.name)}
error={actionErrors.get(a.name)}
selected={detailTargetName === a.name}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onOpenDetail={(row) => setDetailTargetName(row.name)}
/>
)}
/>
) : null}
</Panel>