swarm-ui: add a destroy trigger to the agents page
Closes #4067. Backend half (Wanted::Destroyed + reconcile) shipped in #4065 with no swarm-controller API changes needed -- SetAgentStateRequest already accepted {"state": "destroyed"}, it just had nothing in swarm-ui sending it. Destroy is a separate `quiet`-variant badge next to the existing start/stop toggle (#3988's "wanted" column), not a third state folded into that same click target -- one wrong click on a shared toggle would be irreversible, where a dedicated badge only fires from its own confirm dialog. That confirm is a real `Dialog`, not the native `window.confirm` the reversible stop direction uses -- the wanted column's own comment called this out as the case that would justify one when it was first written. Shared the PUT-declare/pending/error/patch-rows logic between the existing toggleWanted and the new destroyAgent (declareState) rather than duplicating it -- confirmation and target-state selection are the only parts that differ between a toggle and a one-way declaration. Verified: `tsc --noEmit` clean, `nix fmt` reports the expected formatting-only diff, wire shapes (state string "destroyed", AgentDeclaration response) checked against swarm-queue-client's AgentState::as_str and swarm-controller's actual handler rather than assumed from the issue description.
This commit is contained in:
parent
a8fb08a7e8
commit
becc025f13
2 changed files with 112 additions and 27 deletions
17
frontend/packages/swarm-ui/src/pages/AgentsPage.css
Normal file
17
frontend/packages/swarm-ui/src/pages/AgentsPage.css
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/* <AgentsPage> — the destroy-confirm dialog's own layout. Everything
|
||||
else on this page (the table, the wanted/destroy badges) draws chrome
|
||||
from the shared `ui/` kit and needs nothing page-scoped; this file
|
||||
exists only because the confirm dialog's copy + button row needed
|
||||
somewhere to live, same reasoning as CreateAgentForm.css owning its
|
||||
own layout next to the shared form-kit chrome it wraps. */
|
||||
.agents-destroy-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
max-width: 28em;
|
||||
}
|
||||
.agents-destroy-confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
|
@ -26,7 +26,8 @@
|
|||
// its own route.
|
||||
//
|
||||
// The "wanted" column is the start/stop control — see its own `render`
|
||||
// and `toggleWanted` below for how.
|
||||
// and `toggleWanted` below for how. Destroy is a separate control next
|
||||
// to it — see `destroyTarget`/`destroyAgent`.
|
||||
import { useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
|
|
@ -42,6 +43,7 @@ import {
|
|||
} from "../ui/refresh-interval/RefreshInterval.js";
|
||||
import { Table, type TableColumn } from "../ui/table/Table.js";
|
||||
import { CreateAgentForm } from "./CreateAgentForm.js";
|
||||
import "./AgentsPage.css";
|
||||
|
||||
interface ConfigPrStatus {
|
||||
pr_number: number;
|
||||
|
|
@ -56,8 +58,9 @@ interface AgentStatusSnapshot {
|
|||
running: boolean;
|
||||
}
|
||||
|
||||
// `"up"` / `"offline"`, wire-spelled by `swarm_queue_client::wanted::AgentState::as_str`
|
||||
// — kept as a bare `string | null` rather than a union, same reason
|
||||
// `"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;
|
||||
|
|
@ -114,6 +117,10 @@ export function AgentsPage() {
|
|||
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.
|
||||
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
const res = await fetch("/api/agents/status");
|
||||
|
|
@ -132,30 +139,13 @@ export function AgentsPage() {
|
|||
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
||||
});
|
||||
|
||||
// Declares the opposite of `row`'s current state. A row with no
|
||||
// declaration yet (`wanted === null`) has nothing to flip, so the
|
||||
// target is read off the agent's own last-reported `running` instead
|
||||
// — the button's first click always means "make the declaration match
|
||||
// reality, then flip it", which is the only reading that makes sense
|
||||
// without a declaration to toggle.
|
||||
async function toggleWanted(row: AgentRow) {
|
||||
// Shared by `toggleWanted` and `destroyAgent` — both are "PUT a new
|
||||
// `wanted` declaration, track per-agent pending/error state, patch the
|
||||
// response back into `rows`" with nothing else distinguishing them.
|
||||
// Confirmation (native vs. `Dialog`) and target-state selection stay
|
||||
// in each caller, since those are the parts that actually differ.
|
||||
async function declareState(row: AgentRow, target: string) {
|
||||
if (!row.hive) return;
|
||||
const impliedCurrent =
|
||||
row.wanted ?? (row.snapshot?.running ? "up" : "offline");
|
||||
const target = impliedCurrent === "up" ? "offline" : "up";
|
||||
if (
|
||||
target === "offline" &&
|
||||
// Native confirm, not a `Dialog` — no confirm-dialog component
|
||||
// exists in swarm-ui yet, and stop is the one disruptive direction
|
||||
// here (start isn't gated). A swarm-level agent destroy, unlike
|
||||
// this reversible declare, is the irreversible case that will
|
||||
// justify a real one.
|
||||
!window.confirm(
|
||||
`Declare ${row.name} offline? The hive brings its container down on its next reconcile sweep.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPendingAgents((prev) => new Set(prev).add(row.name));
|
||||
setActionErrors((prev) => {
|
||||
const next = new Map(prev);
|
||||
|
|
@ -206,6 +196,41 @@ export function AgentsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// Declares the opposite of `row`'s current state. A row with no
|
||||
// declaration yet (`wanted === null`) has nothing to flip, so the
|
||||
// target is read off the agent's own last-reported `running` instead
|
||||
// — the button's first click always means "make the declaration match
|
||||
// reality, then flip it", which is the only reading that makes sense
|
||||
// without a declaration to toggle.
|
||||
async function toggleWanted(row: AgentRow) {
|
||||
if (!row.hive) return;
|
||||
const impliedCurrent =
|
||||
row.wanted ?? (row.snapshot?.running ? "up" : "offline");
|
||||
const target = impliedCurrent === "up" ? "offline" : "up";
|
||||
if (
|
||||
target === "offline" &&
|
||||
// Native confirm, not a `Dialog` — stop is reversible (a later
|
||||
// start un-does it), so the lighter-weight native prompt is
|
||||
// proportionate here. Destroy is the irreversible direction and
|
||||
// gets the real `Dialog` confirm instead — see `destroyAgent`.
|
||||
!window.confirm(
|
||||
`Declare ${row.name} offline? The hive brings its container down on its next reconcile sweep.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await declareState(row, target);
|
||||
}
|
||||
|
||||
// Fires from the confirm `Dialog`, never directly off a row click —
|
||||
// see `destroyTarget`/the "destroy" badge in the `wanted` column.
|
||||
// Unlike `toggleWanted`, 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");
|
||||
}
|
||||
|
||||
const columns: TableColumn<AgentRow>[] = [
|
||||
{
|
||||
key: "name",
|
||||
|
|
@ -275,7 +300,11 @@ export function AgentsPage() {
|
|||
// badge toggles it — `Badge`'s own "chip plus, optionally, the
|
||||
// control" shape (see its header comment, which names pause/resume
|
||||
// as the exact motivating case), not a separate status chip next
|
||||
// to a separate button.
|
||||
// to a separate button. Destroy is a second, `quiet`-variant badge
|
||||
// next to it rather than a third toggle state: folding it into the
|
||||
// same click target would make one wrong click irreversible, and
|
||||
// `wanted === "destroyed"` already reads fine as this badge's own
|
||||
// display value without a dedicated affordance to view it.
|
||||
sortBy: (a) => a.wanted ?? "",
|
||||
filterValue: (a) => a.wanted ?? "no declaration",
|
||||
render: (a) => {
|
||||
|
|
@ -285,6 +314,9 @@ export function AgentsPage() {
|
|||
const actionLabel = impliedCurrent === "up" ? "stop" : "start";
|
||||
const tone: BadgeTone = a.wanted === "up" ? "positive" : "neutral";
|
||||
const err = actionErrors.get(a.name);
|
||||
// Already destroyed, or nothing to destroy against — same guard
|
||||
// shape as the wanted toggle's own `!a.hive` check.
|
||||
const destroyable = a.hive && a.wanted !== "destroyed";
|
||||
return (
|
||||
<>
|
||||
<Badge
|
||||
|
|
@ -298,6 +330,16 @@ export function AgentsPage() {
|
|||
: "no hive on record for this agent — nothing to declare against"
|
||||
}
|
||||
/>
|
||||
{destroyable ? (
|
||||
<Badge
|
||||
variant="quiet"
|
||||
tone="negative"
|
||||
value="destroy"
|
||||
onClick={() => setDestroyTarget(a)}
|
||||
disabled={pending}
|
||||
title={`destroy ${a.name} — tears the container down, irreversible`}
|
||||
/>
|
||||
) : null}
|
||||
{err ? (
|
||||
<Badge
|
||||
tone="negative"
|
||||
|
|
@ -373,6 +415,32 @@ export function AgentsPage() {
|
|||
>
|
||||
<CreateAgentForm />
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={destroyTarget !== null}
|
||||
onClose={() => setDestroyTarget(null)}
|
||||
label="destroy agent"
|
||||
>
|
||||
{destroyTarget ? (
|
||||
<div class="agents-destroy-confirm">
|
||||
<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>
|
||||
<div class="agents-destroy-confirm-actions">
|
||||
<Button onClick={() => setDestroyTarget(null)}>cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void destroyAgent(destroyTarget)}
|
||||
>
|
||||
destroy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Dialog>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue