swarm-ui/agents: move the split-out agent-page files into pages/agents/
mara, PR review: "make agentspage a subdir now that its split into sub components". AgentsPage.tsx/.css, AgentCard.tsx/.css, AgentTypes.ts, and WantedMenu.tsx move as a family into their own pages/agents/ directory; CreateAgentForm and LinkMatrixAccountForm stay in pages/ since they aren't part of this split (CreateAgentForm is still rendered inside AgentsPage's own dialog but is a standalone, independently-named form, not one of the pieces carved out of the page itself). Pure move: relative imports within the new pages/agents/ family are unchanged (they were always siblings), only the ones reaching back out to ui/ and the two forms above gained one more '../', plus App.tsx's route import.
This commit is contained in:
parent
601068f1eb
commit
1176de08bd
7 changed files with 14 additions and 14 deletions
647
frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx
Normal file
647
frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
// <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.
|
||||
//
|
||||
// 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 } 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 { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.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 {
|
||||
RefreshIntervalPicker,
|
||||
useRefreshInterval,
|
||||
type RefreshIntervalMs,
|
||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import { SplitView } from "../../ui/split-view/SplitView.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";
|
||||
|
||||
// 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";
|
||||
|
||||
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" });
|
||||
|
||||
// `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>[] = [
|
||||
{
|
||||
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}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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 ? (
|
||||
<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>
|
||||
}
|
||||
// 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue