argus, PR review: detailTarget stored the whole AgentRow object at selection time, so it never picked up a later refresh() or a declareState patch - the card list updated live, the panel next to it kept showing whatever was true the moment it was opened. Concrete repro: open an agent's detail panel, wait for the next refresh or destroy it from inside the panel itself, watch the panel not update. Fix: store only the selected agent's name (detailTargetName) and re-derive the actual row from rows on every render (rows?.find(r => r.name === detailTargetName)). The panel can't drift from what the list is showing since it's reading the same array. Verified with a real refresh cycle against a mock server that returns different data on the second call: before, both card and panel show "idle"; after a live refresh, both show the new value in sync.
1047 lines
39 KiB
TypeScript
1047 lines
39 KiB
TypeScript
// <AgentsPage> — the swarm's agent roster, merged with each agent's open
|
|
// config-PR status, swarm-wide health status, and declared wanted state.
|
|
// A roster with no per-row detail is thin, and neither a config-PR nor a
|
|
// 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 HivesPage: `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
|
|
// three separate fetches joined client-side by name; per operator
|
|
// review feedback ("the view should be filled by a single backend
|
|
// call") the join moved server-side instead — see
|
|
// `swarm-controller/src/main.rs`'s `get_agents_status` handler. No more
|
|
// per-page joining left to do here: the wire row *is* the row this page renders.
|
|
//
|
|
// Owns the "+ agent" trigger too — the roster this populates is the
|
|
// 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";
|
|
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 { LinkIcon } from "@hive/shared/icons.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 { Panel } from "../ui/panel/Panel.js";
|
|
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
|
|
import {
|
|
RefreshIntervalPicker,
|
|
useRefreshInterval,
|
|
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 { CreateAgentForm } from "./CreateAgentForm.js";
|
|
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.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`.
|
|
const CONFIRM_COPY: Record<
|
|
"offline" | "paused",
|
|
{
|
|
label: string;
|
|
confirmLabel: string;
|
|
message: (agentName: string) => ComponentChildren;
|
|
}
|
|
> = {
|
|
offline: {
|
|
label: "stop agent",
|
|
confirmLabel: "stop",
|
|
message: (agentName) => (
|
|
<p>
|
|
Declare <strong>{agentName}</strong> offline? The hive brings its
|
|
container down on its next reconcile sweep.
|
|
</p>
|
|
),
|
|
},
|
|
paused: {
|
|
label: "pause agent",
|
|
confirmLabel: "pause",
|
|
message: (agentName) => (
|
|
<p>
|
|
Declare <strong>{agentName}</strong> paused? Its container stays up —
|
|
the web UI and MCP daemons keep running — but its turn loop parks on the
|
|
hive's next reconcile sweep. Select "up" again to resume it.
|
|
</p>
|
|
),
|
|
},
|
|
};
|
|
|
|
// Same 30s default + same reasoning as HivesPage: 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;
|
|
|
|
type ViewMode = "cards" | "table";
|
|
// Persisted like `Table`'s own per-column filters (`storageKey` below) —
|
|
// 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);
|
|
const [intervalMs, setIntervalMs] =
|
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
|
// Lazy initializer (not a mount effect) so the very first render
|
|
// already reflects the stored choice — no default-then-flip flash.
|
|
const [viewMode, setViewModeState] = useState<ViewMode>(() => {
|
|
try {
|
|
return localStorage.getItem(VIEW_MODE_KEY) === "table"
|
|
? "table"
|
|
: "cards";
|
|
} catch {
|
|
return "cards";
|
|
}
|
|
});
|
|
function setViewMode(mode: ViewMode) {
|
|
setViewModeState(mode);
|
|
try {
|
|
localStorage.setItem(VIEW_MODE_KEY, mode);
|
|
} catch {
|
|
/* localStorage unavailable — choice is session-only */
|
|
}
|
|
}
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
// Per-agent, not one page-wide flag: one row's declare-in-flight
|
|
// shouldn't disable every other row's button.
|
|
const [pendingAgents, setPendingAgents] = useState<ReadonlySet<string>>(
|
|
new Set(),
|
|
);
|
|
const [actionErrors, setActionErrors] = useState<
|
|
ReadonlyMap<string, ProblemDetails>
|
|
>(new Map());
|
|
// The row pending destroy confirmation, `null` when the dialog is
|
|
// closed — not a boolean, so the dialog can name the agent without a
|
|
// second piece of state to keep in sync with it. Kept apart from
|
|
// `confirmTarget` below rather than folded into it as a third state:
|
|
// destroy is categorically different (irreversible, longer warning,
|
|
// danger-styled), not one more case of the same "confirm before parking
|
|
// a running agent" shape "offline"/"paused" share.
|
|
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
|
|
// "offline" and "paused" are the *same* confirm shape — same button
|
|
// treatment, same "disruptive to a running turn loop, unlike up" reason
|
|
// for needing a confirm at all — so one union state + one `ConfirmDialog`
|
|
// covers both instead of two near-identical copies (argus, PR review: "a
|
|
// shared confirm-dialog-body-generator would drop real line count").
|
|
// `CONFIRM_COPY` below supplies the one thing that differs.
|
|
const [confirmTarget, setConfirmTarget] = useState<{
|
|
row: AgentRow;
|
|
state: "offline" | "paused";
|
|
} | null>(null);
|
|
// The row currently showing the "link a matrix account" dialog — same
|
|
// null-means-closed shape as `destroyTarget`/`stopTarget`, own piece of
|
|
// state rather than folded into either since this dialog isn't a
|
|
// confirmation of a `declareState` call, it's an unrelated action with
|
|
// its own form (`LinkMatrixAccountForm`).
|
|
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
|
// Which agent the detail panel shows, *by name* — not the `AgentRow`
|
|
// object itself. Storing the row would snapshot it at selection time;
|
|
// `rows` replaces its whole array on every `refresh()` and every
|
|
// `declareState` patch, so a captured object goes stale the moment
|
|
// either fires (argus, PR review: "open a detail panel, wait for the
|
|
// next refresh or destroy from inside the panel itself — the card
|
|
// list updates live, the panel next to it doesn't"). `detailTarget`
|
|
// below re-derives the live row from `rows` every render instead, so
|
|
// it can't drift from what the list is showing.
|
|
const [detailTargetName, setDetailTargetName] = useState<string | null>(null);
|
|
const detailTarget = rows?.find((r) => r.name === detailTargetName) ?? null;
|
|
|
|
async function refresh() {
|
|
const res = await fetch("/api/agents/status");
|
|
if (!res.ok) {
|
|
setError(await readApiError(res));
|
|
return;
|
|
}
|
|
setRows((await res.json()) as AgentRow[]);
|
|
// A refresh that succeeds clears a previous failure — otherwise a
|
|
// transient error would sit on screen forever after the data itself
|
|
// has recovered.
|
|
setError(null);
|
|
}
|
|
|
|
useRefreshInterval(intervalMs, () => {
|
|
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
|
});
|
|
|
|
// The one PUT every `WantedMenu` selection ends at, whether or not it
|
|
// went through a `ConfirmDialog` first (`confirmStop`/`destroyAgent`
|
|
// for "offline"/"destroy"; called directly for "up", which needs no
|
|
// confirmation): "PUT a new `wanted` declaration, track per-agent
|
|
// pending/error state, patch the response back into `rows`" is the
|
|
// same shape regardless of target state or how the caller got here.
|
|
async function declareState(row: AgentRow, target: string) {
|
|
if (!row.hive) return;
|
|
setPendingAgents((prev) => new Set(prev).add(row.name));
|
|
setActionErrors((prev) => {
|
|
const next = new Map(prev);
|
|
next.delete(row.name);
|
|
return next;
|
|
});
|
|
try {
|
|
const res = await fetch(
|
|
`/api/hives/${encodeURIComponent(row.hive)}/agents/${encodeURIComponent(row.name)}/state`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ state: target }),
|
|
},
|
|
);
|
|
if (!res.ok) {
|
|
const problem = await readApiError(res);
|
|
setActionErrors((prev) => new Map(prev).set(row.name, problem));
|
|
return;
|
|
}
|
|
// The PUT response is the hive's whole declaration set (every
|
|
// agent's `wanted` state) — same shape `GET /api/hives/{hive}/wanted`
|
|
// returns — so patch just that field into the rows already on
|
|
// screen rather than firing a second, full `/api/agents/status`
|
|
// round-trip merely to learn one hive's `wanted` column changed.
|
|
// Everything else on the grid (freshness, snapshot, config PR)
|
|
// still catches up on the next interval-driven `refresh()`.
|
|
const declared = (await res.json()) as { agent: string; state: string }[];
|
|
setRows((prev) => {
|
|
if (!prev) return prev;
|
|
const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
|
|
return prev.map((r) =>
|
|
r.hive === row.hive && byAgent.has(r.name)
|
|
? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
|
|
: r,
|
|
);
|
|
});
|
|
} catch (e: unknown) {
|
|
setActionErrors((prev) =>
|
|
new Map(prev).set(row.name, { detail: String(e) }),
|
|
);
|
|
} finally {
|
|
setPendingAgents((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(row.name);
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fires from the `confirmTarget` confirm `Dialog` (covers both "offline"
|
|
// and "paused" — see that state's own comment). Used to be a native
|
|
// `window.confirm` for the "offline" case (stop is reversible, a later
|
|
// start un-does it, so a lighter-weight prompt than destroy's felt
|
|
// proportionate) — mara, reviewing the destroy confirm: use the shared
|
|
// component everywhere we already confirm before acting, not just for
|
|
// destroy. Once `ConfirmDialog` existed as a one-line-per-caller
|
|
// component, the "native is lighter" argument no longer bought
|
|
// consistency anything.
|
|
async function confirmDeclare() {
|
|
if (!confirmTarget) return;
|
|
const { row, state } = confirmTarget;
|
|
setConfirmTarget(null);
|
|
await declareState(row, state);
|
|
}
|
|
|
|
// Fires from the `destroyTarget` confirm `Dialog`, never directly off
|
|
// a row click — see `destroyTarget`/`WantedMenu`'s "destroy" option.
|
|
// Unlike the "up"/"offline" directions, there's no "current state" to
|
|
// read: destroy is a one-way declaration, not a flip.
|
|
async function destroyAgent(row: AgentRow) {
|
|
setDestroyTarget(null);
|
|
await declareState(row, "destroyed");
|
|
}
|
|
|
|
// The three non-destroy `WantedMenu` selections, factored out once
|
|
// rather than repeated per call site — the card, the table's "wanted"
|
|
// column, and the detail panel's own `WantedMenu` (below) all wire the
|
|
// same three.
|
|
const selectUp = (row: AgentRow) => void declareState(row, "up");
|
|
const selectOffline = (row: AgentRow) =>
|
|
setConfirmTarget({ row, state: "offline" });
|
|
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`
|
|
// (the table's cells have room for the fourth option the card view
|
|
// moved out to its detail panel).
|
|
const columns: TableColumn<AgentRow>[] = [
|
|
{
|
|
key: "name",
|
|
header: "name",
|
|
render: (a) => a.name,
|
|
sortBy: (a) => a.name,
|
|
filterValue: (a) => a.name,
|
|
},
|
|
{
|
|
key: "hive",
|
|
header: "hive",
|
|
render: (a) => a.hive ?? "—",
|
|
sortBy: (a) => a.hive ?? "",
|
|
filterValues: (a) => [a.hive ?? "—"],
|
|
filterMode: "multiselect",
|
|
},
|
|
{
|
|
key: "status",
|
|
header: "status",
|
|
sortBy: (a) => FRESHNESS[a.freshness].label,
|
|
filterValues: (a) => [FRESHNESS[a.freshness].label],
|
|
filterMode: "multiselect",
|
|
render: (a) => {
|
|
const { tone, label } = FRESHNESS[a.freshness];
|
|
return (
|
|
<Badge
|
|
tone={tone}
|
|
title={
|
|
a.freshness === "unknown"
|
|
? "reported by its hive but not registered in swarm-level identity — needs migration"
|
|
: undefined
|
|
}
|
|
value={
|
|
<>
|
|
{label}
|
|
{a.last_seen_unix !== null ? (
|
|
<>
|
|
{" "}
|
|
(<RelativeTime epochMs={a.last_seen_unix * 1000} />)
|
|
</>
|
|
) : null}
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: "message",
|
|
header: "message",
|
|
cellClass: "ui-table-prose",
|
|
render: (a) => a.snapshot?.status_text ?? "—",
|
|
filterValue: (a) => a.snapshot?.status_text ?? "",
|
|
},
|
|
{
|
|
key: "wanted",
|
|
header: "wanted",
|
|
sortBy: (a) => a.wanted ?? "",
|
|
filterValues: (a) => [a.wanted ?? "no declaration"],
|
|
filterMode: "multiselect",
|
|
render: (a) => {
|
|
const err = actionErrors.get(a.name);
|
|
return (
|
|
<>
|
|
<WantedMenu
|
|
row={a}
|
|
pending={pendingAgents.has(a.name)}
|
|
portal
|
|
onSelectUp={selectUp}
|
|
onSelectOffline={selectOffline}
|
|
onSelectPaused={selectPaused}
|
|
onDestroy={setDestroyTarget}
|
|
/>
|
|
{err ? (
|
|
<Badge
|
|
tone="negative"
|
|
value="failed"
|
|
title={err.detail ?? "the declaration failed"}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: "matrix",
|
|
header: "matrix",
|
|
render: (a) => (
|
|
<Badge
|
|
variant="quiet"
|
|
icon={<LinkIcon />}
|
|
value="link account"
|
|
onClick={a.hive ? () => setMatrixTarget(a) : undefined}
|
|
disabled={!a.hive}
|
|
title={
|
|
a.hive
|
|
? `link a matrix account to ${a.name}`
|
|
: "no hive on record for this agent — nothing to link against"
|
|
}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: "config-pr",
|
|
header: "config PR",
|
|
sortBy: (a) => a.config_pr?.pr_number ?? 0,
|
|
filterValue: (a) => (a.config_pr ? `#${a.config_pr.pr_number}` : ""),
|
|
render: (a) =>
|
|
a.config_pr ? (
|
|
<Badge
|
|
tone="warning"
|
|
value={
|
|
a.config_pr.html_url ? (
|
|
<a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
|
|
#{a.config_pr.pr_number}
|
|
</a>
|
|
) : (
|
|
`#${a.config_pr.pr_number}`
|
|
)
|
|
}
|
|
/>
|
|
) : (
|
|
"—"
|
|
),
|
|
},
|
|
];
|
|
|
|
// 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
|
|
primary={
|
|
<Panel
|
|
title="agents"
|
|
icon="👥"
|
|
actions={
|
|
<>
|
|
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
|
+ agent
|
|
</Button>
|
|
<div
|
|
class="ui-agents-view-toggle"
|
|
role="group"
|
|
aria-label="view"
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-pressed={viewMode === "cards"}
|
|
class={viewMode === "cards" ? "active" : undefined}
|
|
onClick={() => setViewMode("cards")}
|
|
>
|
|
cards
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-pressed={viewMode === "table"}
|
|
class={viewMode === "table" ? "active" : undefined}
|
|
onClick={() => setViewMode("table")}
|
|
>
|
|
table
|
|
</button>
|
|
</div>
|
|
<RefreshIntervalPicker
|
|
id="agents-refresh"
|
|
value={intervalMs}
|
|
onChange={setIntervalMs}
|
|
/>
|
|
</>
|
|
}
|
|
>
|
|
{error ? (
|
|
<ApiErrorPanel
|
|
context="failed to load the agent roster"
|
|
problem={error}
|
|
/>
|
|
) : 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
|
|
columns={columns}
|
|
rows={rows}
|
|
rowKey={(a) => a.name}
|
|
storageKey="swarm-ui:agents:table-filters"
|
|
/>
|
|
) : null}
|
|
</Panel>
|
|
}
|
|
// A second on-page `Panel`, not a modal — mara: "why no separate
|
|
// panel? ... i mean a second panel on agent page." Always mounted
|
|
// (an empty state when nothing's selected) rather than
|
|
// conditionally rendered, so picking an agent never causes the
|
|
// page's own layout to jump. `SplitView`'s `flex-wrap` stacks
|
|
// this below the list on a narrow viewport, same content-driven-
|
|
// not-a-fixed-breakpoint approach the shell's own nav already
|
|
// uses (`Shell.css`), rather than a new media query.
|
|
secondary={
|
|
<Panel
|
|
title={detailTarget ? detailTarget.name : "agent details"}
|
|
icon="🔎"
|
|
>
|
|
{detailTarget ? (
|
|
<div class="ui-agent-detail">
|
|
<dl class="ui-agent-detail-fields">
|
|
<dt>status</dt>
|
|
<dd>
|
|
{(() => {
|
|
const { tone, label } = FRESHNESS[detailTarget.freshness];
|
|
return (
|
|
<Badge
|
|
tone={tone}
|
|
value={
|
|
<>
|
|
{label}
|
|
{detailTarget.last_seen_unix !== null ? (
|
|
<>
|
|
{" "}
|
|
(
|
|
<RelativeTime
|
|
epochMs={detailTarget.last_seen_unix * 1000}
|
|
/>
|
|
)
|
|
</>
|
|
) : null}
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
})()}
|
|
</dd>
|
|
<dt>message</dt>
|
|
<dd>{detailTarget.snapshot?.status_text ?? "—"}</dd>
|
|
<dt>wanted</dt>
|
|
<dd>
|
|
{/* Full menu (destroy included) — mara: "destroy is
|
|
already available via wanted state", no separate
|
|
button needed. */}
|
|
<WantedMenu
|
|
row={detailTarget}
|
|
pending={pendingAgents.has(detailTarget.name)}
|
|
onSelectUp={selectUp}
|
|
onSelectOffline={selectOffline}
|
|
onSelectPaused={selectPaused}
|
|
onDestroy={setDestroyTarget}
|
|
/>
|
|
</dd>
|
|
<dt>hive</dt>
|
|
<dd>{detailTarget.hive ?? "—"}</dd>
|
|
<dt>config PR</dt>
|
|
<dd>
|
|
{detailTarget.config_pr ? (
|
|
<Badge
|
|
tone="warning"
|
|
value={
|
|
detailTarget.config_pr.html_url ? (
|
|
<a
|
|
href={detailTarget.config_pr.html_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
#{detailTarget.config_pr.pr_number}
|
|
</a>
|
|
) : (
|
|
`#${detailTarget.config_pr.pr_number}`
|
|
)
|
|
}
|
|
/>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</dd>
|
|
</dl>
|
|
<div class="ui-agent-detail-actions">
|
|
<Badge
|
|
variant="quiet"
|
|
icon={<LinkIcon />}
|
|
value="link matrix account"
|
|
onClick={
|
|
detailTarget.hive
|
|
? () => setMatrixTarget(detailTarget)
|
|
: undefined
|
|
}
|
|
disabled={!detailTarget.hive}
|
|
title={
|
|
detailTarget.hive
|
|
? `link a matrix account to ${detailTarget.name}`
|
|
: "no hive on record for this agent — nothing to link against"
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p class="ui-agents-empty">select an agent to see its details</p>
|
|
)}
|
|
</Panel>
|
|
}
|
|
/>
|
|
<Dialog
|
|
open={createOpen}
|
|
onClose={() => setCreateOpen(false)}
|
|
label="create agent"
|
|
>
|
|
<CreateAgentForm />
|
|
</Dialog>
|
|
<Dialog
|
|
open={matrixTarget !== null}
|
|
onClose={() => setMatrixTarget(null)}
|
|
label="link a matrix account"
|
|
>
|
|
{/* `matrixTarget.hive` is non-null here — the trigger badge above
|
|
is disabled without one, so this can only open with a real
|
|
hive to PUT against. */}
|
|
{matrixTarget?.hive ? (
|
|
<LinkMatrixAccountForm
|
|
hive={matrixTarget.hive}
|
|
agent={matrixTarget.name}
|
|
/>
|
|
) : null}
|
|
</Dialog>
|
|
<ConfirmDialog
|
|
open={confirmTarget !== null}
|
|
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
|
|
onCancel={() => setConfirmTarget(null)}
|
|
onConfirm={() => void confirmDeclare()}
|
|
confirmLabel={
|
|
confirmTarget
|
|
? CONFIRM_COPY[confirmTarget.state].confirmLabel
|
|
: undefined
|
|
}
|
|
>
|
|
{confirmTarget
|
|
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
|
|
: null}
|
|
</ConfirmDialog>
|
|
<ConfirmDialog
|
|
open={destroyTarget !== null}
|
|
label="destroy agent"
|
|
onCancel={() => setDestroyTarget(null)}
|
|
onConfirm={() => destroyTarget && void destroyAgent(destroyTarget)}
|
|
confirmLabel="destroy"
|
|
>
|
|
{destroyTarget ? (
|
|
<p>
|
|
Destroy <strong>{destroyTarget.name}</strong>? The hive tears its
|
|
container down on its next reconcile sweep. This is not reversible
|
|
from here — bringing it back means redeploying via "+ agent", which
|
|
reuses the agent's existing identity, config repo, and forge
|
|
collaborator access rather than starting over.
|
|
</p>
|
|
) : null}
|
|
</ConfirmDialog>
|
|
</>
|
|
);
|
|
}
|