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
|
// `get_agents_status` handler for where `config_pr`/`wanted` get merged
|
||||||
// in and why that's the handler's job rather than
|
// in and why that's the handler's job rather than
|
||||||
// `agent_status::AgentStatusReader`'s. No more per-page joining left to
|
// `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
|
// Owns the "+ agent" trigger too — the roster this populates is the
|
||||||
// natural home for the action that populates it; a separate top-level
|
// natural home for the action that populates it. The form itself
|
||||||
// nav entry would be one click of indirection for no benefit. The form
|
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
|
||||||
// itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than
|
//
|
||||||
// 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
|
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
|
||||||
// that component's own comment above `AgentsPage` for why.
|
// that component's own comment above `AgentsPage` for why.
|
||||||
|
|
@ -44,9 +45,9 @@ import {
|
||||||
useRefreshInterval,
|
useRefreshInterval,
|
||||||
type RefreshIntervalMs,
|
type RefreshIntervalMs,
|
||||||
} from "../ui/refresh-interval/RefreshInterval.js";
|
} from "../ui/refresh-interval/RefreshInterval.js";
|
||||||
import { Table, type TableColumn } from "../ui/table/Table.js";
|
|
||||||
import { CreateAgentForm } from "./CreateAgentForm.js";
|
import { CreateAgentForm } from "./CreateAgentForm.js";
|
||||||
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
|
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
|
||||||
|
import "./AgentsPage.css";
|
||||||
|
|
||||||
interface ConfigPrStatus {
|
interface ConfigPrStatus {
|
||||||
pr_number: number;
|
pr_number: number;
|
||||||
|
|
@ -69,8 +70,9 @@ interface AgentStatusSnapshot {
|
||||||
type Wanted = string | null;
|
type Wanted = string | null;
|
||||||
|
|
||||||
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the
|
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the
|
||||||
// table row now, not a shape assembled from it, so there's no separate
|
// row this page renders, not a shape assembled from it, so there's no
|
||||||
// join-result type to keep in sync with the wire contract by hand.
|
// separate join-result type to keep in sync with the wire contract by
|
||||||
|
// hand.
|
||||||
interface AgentRow {
|
interface AgentRow {
|
||||||
name: string;
|
name: string;
|
||||||
hive: string | null;
|
hive: string | null;
|
||||||
|
|
@ -165,13 +167,24 @@ function WantedMenu({
|
||||||
onSelectOffline,
|
onSelectOffline,
|
||||||
onSelectPaused,
|
onSelectPaused,
|
||||||
onDestroy,
|
onDestroy,
|
||||||
|
showDestroy = true,
|
||||||
}: {
|
}: {
|
||||||
row: AgentRow;
|
row: AgentRow;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
onSelectUp: (row: AgentRow) => void;
|
onSelectUp: (row: AgentRow) => void;
|
||||||
onSelectOffline: (row: AgentRow) => void;
|
onSelectOffline: (row: AgentRow) => void;
|
||||||
onSelectPaused: (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 [open, setOpen] = useState(false);
|
||||||
const anchorRef = useRef<HTMLDivElement>(null);
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -188,7 +201,9 @@ function WantedMenu({
|
||||||
{ value: "up", label: "up" },
|
{ value: "up", label: "up" },
|
||||||
{ value: "paused", label: "paused" },
|
{ value: "paused", label: "paused" },
|
||||||
{ value: "offline", label: "offline" },
|
{ value: "offline", label: "offline" },
|
||||||
{ value: "destroy", label: "destroy", danger: true },
|
...(showDestroy
|
||||||
|
? [{ value: "destroy", label: "destroy", danger: true }]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -222,7 +237,7 @@ function WantedMenu({
|
||||||
if (value === "up") onSelectUp(row);
|
if (value === "up") onSelectUp(row);
|
||||||
else if (value === "offline") onSelectOffline(row);
|
else if (value === "offline") onSelectOffline(row);
|
||||||
else if (value === "paused") onSelectPaused(row);
|
else if (value === "paused") onSelectPaused(row);
|
||||||
else onDestroy(row);
|
else onDestroy?.(row);
|
||||||
}}
|
}}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
anchorRef={anchorRef}
|
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() {
|
export function AgentsPage() {
|
||||||
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
||||||
const [error, setError] = useState<ProblemDetails | 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
|
// confirmation of a `declareState` call, it's an unrelated action with
|
||||||
// its own form (`LinkMatrixAccountForm`).
|
// its own form (`LinkMatrixAccountForm`).
|
||||||
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
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() {
|
async function refresh() {
|
||||||
const res = await fetch("/api/agents/status");
|
const res = await fetch("/api/agents/status");
|
||||||
|
|
@ -370,162 +484,6 @@ export function AgentsPage() {
|
||||||
await declareState(row, "destroyed");
|
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 (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title="agents"
|
title="agents"
|
||||||
|
|
@ -550,15 +508,102 @@ export function AgentsPage() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!error && rows === null ? <p>loading…</p> : null}
|
{!error && rows === null ? <p>loading…</p> : null}
|
||||||
{rows ? (
|
{rows && rows.length === 0 ? (
|
||||||
<Table
|
<p class="ui-agent-card-list-empty">
|
||||||
columns={columns}
|
no agents yet — the swarm-wide identity store has no agents registered
|
||||||
rows={rows}
|
on any hive
|
||||||
rowKey={(a) => a.name}
|
</p>
|
||||||
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
|
|
||||||
storageKey="swarm-ui:agents:table-filters"
|
|
||||||
/>
|
|
||||||
) : null}
|
) : 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
|
<Dialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue