swarm-ui/agents: per-agent card view + detail panel, split from the roster table
Replaces AgentsPage's Table-rendered roster with one AgentCard per agent: name/status/wanted on the first line, the free-form status message on the second (mara, scoping #4257: "main view: name, status, message, wanted" / "message as second line" / "more like card per agent"). Everything the old table's other columns carried (hive, matrix link-account, config-PR link, destroy) moves into a detail panel that opens on card click, reusing the existing Dialog modal rather than a new docked/slideover primitive - the shared hive-side-panel drawer is a shadow-DOM custom element swarm-ui's esbuild config can't consume yet (same gap Dialog.tsx's own comment already flags for hive-dialog). WantedMenu gains a showDestroy flag: the card's own menu keeps the three everyday states, destroy gets its own button in the detail panel instead of a fourth dropdown entry next to states someone reaches for often. Known regression, flagged for follow-up rather than silently dropped: the old table's per-column sort/filter has no replacement in this view yet. Backend list/detail endpoint split (also requested in #4257) is deliberately left for a follow-up PR - it's an orthogonal optimization, not required for this interaction to work correctly against the existing single /api/agents/status response.
This commit is contained in:
parent
bb62bf1aa9
commit
26b0b0d173
2 changed files with 294 additions and 175 deletions
74
frontend/packages/swarm-ui/src/pages/AgentsPage.css
Normal file
74
frontend/packages/swarm-ui/src/pages/AgentsPage.css
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/* <AgentCard> list — a vertical stack of per-agent cards replacing the
|
||||
old table body (mara: "more like card per agent"). Full-width rows,
|
||||
not a grid: the second line (the agent's free-form status message)
|
||||
reads better wrapping the card's own width than squeezed into a fixed
|
||||
grid cell. */
|
||||
.ui-agent-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.ui-agent-card-list-empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 1.25em 0.75em;
|
||||
}
|
||||
.ui-agent-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
padding: 0.75em 1em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5em;
|
||||
background: var(--bg-elev);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui-agent-card:hover,
|
||||
.ui-agent-card:focus-visible {
|
||||
border-color: var(--purple);
|
||||
outline: none;
|
||||
}
|
||||
.ui-agent-card-line1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
}
|
||||
.ui-agent-card-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Pushes the wanted-state control to the row's trailing edge regardless
|
||||
of how wide the status badge next to it ends up. */
|
||||
.ui-agent-card-wanted {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
margin-left: auto;
|
||||
}
|
||||
.ui-agent-card-message {
|
||||
color: var(--muted);
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Detail panel (`Dialog` body) — the fields `AgentCard`'s main view
|
||||
doesn't show. */
|
||||
.ui-agent-detail-name {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
.ui-agent-detail-fields {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.4em 1em;
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
.ui-agent-detail-fields dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-agent-detail-fields dd {
|
||||
margin: 0;
|
||||
}
|
||||
.ui-agent-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
|
@ -17,13 +17,14 @@
|
|||
// `get_agents_status` handler for where `config_pr`/`wanted` get merged
|
||||
// in and why that's the handler's job rather than
|
||||
// `agent_status::AgentStatusReader`'s. No more per-page joining left to
|
||||
// do here: the wire row *is* the table row.
|
||||
// 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; a separate top-level
|
||||
// nav entry would be one click of indirection for no benefit. The form
|
||||
// itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than
|
||||
// its own route.
|
||||
// natural home for the action that populates it. The form itself
|
||||
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
|
||||
//
|
||||
// Renders as one `AgentCard` per row, not a `Table` — see that
|
||||
// component's own comment below for the card/detail-panel split.
|
||||
//
|
||||
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
|
||||
// that component's own comment above `AgentsPage` for why.
|
||||
|
|
@ -44,9 +45,9 @@ import {
|
|||
useRefreshInterval,
|
||||
type RefreshIntervalMs,
|
||||
} from "../ui/refresh-interval/RefreshInterval.js";
|
||||
import { Table, 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;
|
||||
|
|
@ -69,8 +70,9 @@ interface AgentStatusSnapshot {
|
|||
type Wanted = string | null;
|
||||
|
||||
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the
|
||||
// table row now, not a shape assembled from it, so there's no separate
|
||||
// join-result type to keep in sync with the wire contract by hand.
|
||||
// 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;
|
||||
|
|
@ -165,13 +167,24 @@ function WantedMenu({
|
|||
onSelectOffline,
|
||||
onSelectPaused,
|
||||
onDestroy,
|
||||
showDestroy = true,
|
||||
}: {
|
||||
row: AgentRow;
|
||||
pending: boolean;
|
||||
onSelectUp: (row: AgentRow) => void;
|
||||
onSelectOffline: (row: AgentRow) => void;
|
||||
onSelectPaused: (row: AgentRow) => void;
|
||||
onDestroy: (row: AgentRow) => void;
|
||||
onDestroy?: (row: AgentRow) => void;
|
||||
/**
|
||||
* The card view (see `AgentCard` below) keeps this menu to the
|
||||
* three everyday states and gives "destroy" — the rare, one-way
|
||||
* one — its own button in the detail panel instead, so it can't be
|
||||
* reached by an extra click off a state that's already open (mara,
|
||||
* scoping the card/detail split: destroy belongs with the "rarely
|
||||
* used actions" bucket, not next to up/paused/offline). The
|
||||
* `WantedMenu` inside that detail panel still gets the full set.
|
||||
*/
|
||||
showDestroy?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -188,7 +201,9 @@ function WantedMenu({
|
|||
{ value: "up", label: "up" },
|
||||
{ value: "paused", label: "paused" },
|
||||
{ value: "offline", label: "offline" },
|
||||
{ value: "destroy", label: "destroy", danger: true },
|
||||
...(showDestroy
|
||||
? [{ value: "destroy", label: "destroy", danger: true }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -222,7 +237,7 @@ function WantedMenu({
|
|||
if (value === "up") onSelectUp(row);
|
||||
else if (value === "offline") onSelectOffline(row);
|
||||
else if (value === "paused") onSelectPaused(row);
|
||||
else onDestroy(row);
|
||||
else onDestroy?.(row);
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
|
|
@ -231,6 +246,101 @@ function WantedMenu({
|
|||
);
|
||||
}
|
||||
|
||||
// One card per roster agent (mara: "main view: name, status, message,
|
||||
// wanted" / "message as second line" / "more like card per agent",
|
||||
// replacing the old table row). Everything else the old table's other
|
||||
// columns carried (hive, matrix link-account, config-PR link, destroy)
|
||||
// moved to the detail panel `AgentsPage` opens on card click — 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 at all yet (same already-tracked gap `Dialog.tsx`'s own comment
|
||||
// notes for `hive-dialog`). Revisit once that gap closes. The old
|
||||
// per-column sort/filter has no replacement yet in this view.
|
||||
//
|
||||
// A `role="button"` div, not a real `<button>`: the card also hosts the
|
||||
// real `<button>`s inside `WantedMenu`, and nested buttons are invalid
|
||||
// HTML. `onOpenDetail` fires for a click/Enter/Space landing on the card
|
||||
// itself; the `stopPropagation` wrapper around `WantedMenu` keeps its own
|
||||
// clicks (mouse or keyboard-synthesized) from also opening the panel, and
|
||||
// the `e.target !== e.currentTarget` guard below does the same for keys.
|
||||
function AgentCard({
|
||||
row,
|
||||
pending,
|
||||
error,
|
||||
onSelectUp,
|
||||
onSelectOffline,
|
||||
onSelectPaused,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
row: AgentRow;
|
||||
pending: boolean;
|
||||
error: ProblemDetails | undefined;
|
||||
onSelectUp: (row: AgentRow) => void;
|
||||
onSelectOffline: (row: AgentRow) => void;
|
||||
onSelectPaused: (row: AgentRow) => void;
|
||||
onOpenDetail: (row: AgentRow) => void;
|
||||
}) {
|
||||
const { tone, label } = FRESHNESS[row.freshness];
|
||||
return (
|
||||
<div
|
||||
class="ui-agent-card"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpenDetail(row)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsPage() {
|
||||
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
|
|
@ -269,6 +379,10 @@ export function AgentsPage() {
|
|||
// confirmation of a `declareState` call, it's an unrelated action with
|
||||
// its own form (`LinkMatrixAccountForm`).
|
||||
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
||||
// The row showing the detail panel (hive, matrix link-account,
|
||||
// config-PR link, destroy — everything `AgentCard`'s main view
|
||||
// doesn't) — same null-means-closed shape as the two above.
|
||||
const [detailTarget, setDetailTarget] = useState<AgentRow | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
const res = await fetch("/api/agents/status");
|
||||
|
|
@ -370,162 +484,6 @@ export function AgentsPage() {
|
|||
await declareState(row, "destroyed");
|
||||
}
|
||||
|
||||
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 ?? "",
|
||||
// "—" (not "") so the missing-hive option in the filter list reads
|
||||
// the same as the cell itself, rather than showing a blank choice.
|
||||
// Multiselect, not single-value `"select"` — an operator narrowing
|
||||
// to a handful of hives at once shouldn't need to filter one at a
|
||||
// time.
|
||||
filterValues: (a) => [a.hive ?? "—"],
|
||||
filterMode: "multiselect",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "status",
|
||||
// Technical freshness only — the agent's own free-text status
|
||||
// string used to be concatenated into this same badge, which
|
||||
// stuffed a full sentence into a pill meant for a short discrete
|
||||
// label and blew the row out (mara: "looks messy"). That string
|
||||
// now lives in its own "message" column below.
|
||||
sortBy: (a) => FRESHNESS[a.freshness].label,
|
||||
// Multiselect, same reasoning as the hive column above.
|
||||
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",
|
||||
// Plain text, not a Badge — the agent's own free-form status
|
||||
// sentence, as distinct from the technical freshness state in the
|
||||
// "status" column. A `running: false` snapshot always carries
|
||||
// `status_text: null` (the wire contract's own rule), so a stopped
|
||||
// agent just shows an em dash here rather than a stale message.
|
||||
render: (a) => a.snapshot?.status_text ?? "—",
|
||||
filterValue: (a) => a.snapshot?.status_text ?? "",
|
||||
},
|
||||
{
|
||||
key: "wanted",
|
||||
header: "wanted",
|
||||
// One `WantedMenu` (badge + dropdown, defined above) per row —
|
||||
// real per-badge logic lives there, this column just wires its
|
||||
// callbacks to the page's own state/handlers.
|
||||
sortBy: (a) => a.wanted ?? "",
|
||||
// Multiselect, same reasoning as the hive column above.
|
||||
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)}
|
||||
onSelectUp={(row) => void declareState(row, "up")}
|
||||
onSelectOffline={(row) =>
|
||||
setConfirmTarget({ row, state: "offline" })
|
||||
}
|
||||
onSelectPaused={(row) =>
|
||||
setConfirmTarget({ row, state: "paused" })
|
||||
}
|
||||
onDestroy={setDestroyTarget}
|
||||
/>
|
||||
{err ? (
|
||||
<Badge
|
||||
tone="negative"
|
||||
value="failed"
|
||||
title={err.detail ?? "the declaration failed"}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "matrix",
|
||||
header: "matrix",
|
||||
// Icon-only trigger, quiet variant (no permanent pill fill) — same
|
||||
// "chrome, not a status chip" reasoning as `LinksMenu`/`SettingsMenu`'s
|
||||
// own `Badge` triggers (see `@hive/shared/badge/Badge.tsx`'s
|
||||
// `BadgeVariant` doc). Disabled with no hive on record, same guard
|
||||
// `WantedMenu` uses — the endpoint is hive-scoped, there's nothing
|
||||
// to PUT against without one.
|
||||
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}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="agents"
|
||||
|
|
@ -550,15 +508,102 @@ export function AgentsPage() {
|
|||
/>
|
||||
) : null}
|
||||
{!error && rows === null ? <p>loading…</p> : null}
|
||||
{rows ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(a) => a.name}
|
||||
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
|
||||
storageKey="swarm-ui:agents:table-filters"
|
||||
/>
|
||||
{rows && rows.length === 0 ? (
|
||||
<p class="ui-agent-card-list-empty">
|
||||
no agents yet — the swarm-wide identity store has no agents registered
|
||||
on any hive
|
||||
</p>
|
||||
) : null}
|
||||
{rows && rows.length > 0 ? (
|
||||
<div class="ui-agent-card-list">
|
||||
{rows.map((a) => (
|
||||
<AgentCard
|
||||
key={a.name}
|
||||
row={a}
|
||||
pending={pendingAgents.has(a.name)}
|
||||
error={actionErrors.get(a.name)}
|
||||
onSelectUp={(row) => void declareState(row, "up")}
|
||||
onSelectOffline={(row) =>
|
||||
setConfirmTarget({ row, state: "offline" })
|
||||
}
|
||||
onSelectPaused={(row) =>
|
||||
setConfirmTarget({ row, state: "paused" })
|
||||
}
|
||||
onOpenDetail={setDetailTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Dialog
|
||||
open={detailTarget !== null}
|
||||
onClose={() => setDetailTarget(null)}
|
||||
label={detailTarget ? `${detailTarget.name} details` : "agent details"}
|
||||
>
|
||||
{/* Everything `AgentCard`'s main view doesn't already show — see
|
||||
that component's own comment for the field split. */}
|
||||
{detailTarget ? (
|
||||
<div class="ui-agent-detail">
|
||||
<h2 class="ui-agent-detail-name">{detailTarget.name}</h2>
|
||||
<dl class="ui-agent-detail-fields">
|
||||
<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);
|
||||
setDetailTarget(null);
|
||||
}
|
||||
: 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"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
disabled={detailTarget.wanted === "destroyed"}
|
||||
onClick={() => {
|
||||
setDestroyTarget(detailTarget);
|
||||
setDetailTarget(null);
|
||||
}}
|
||||
>
|
||||
destroy agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue