swarm-ui: one badge+dropdown for wanted state, not multiple buttons
mara, hyperhive#4079: "agent wanted state is multiple buttons insteaf of a badge with dropdown ... same pattern as agent term badges with dropdowns". The wanted column used to be a toggle badge plus a separate quiet destroy badge, stacking under each other in the narrow column. Replaced both with one WantedMenu badge that opens a Dropdown with the three explicit states (up/offline/destroy) -- the exact badge-triggers-a-dropdown shape the per-agent terminals StatusChips already uses (and swarm-uis own ComponentsPage already demos with sample data), built from the same shared Badge/Dropdown components. "up" still declares straight away with no confirmation; "offline" and "destroy" still go through the existing ConfirmDialog modals unchanged -- only the trigger moved, the confirm behavior for the two directions that already had one is untouched. Explicit dropdown options also fix a real bug the old toggle had: mara also asked "when no state is declared, i want to set it to online" -- the old toggle inferred a target as the opposite of snapshot.running for an undeclared row, so a click on an undeclared-but-running agent silently declared it offline rather than making its actual state explicit. The dropdown just lets "up" be picked directly regardless of any inference, which is what she is asking for -- flagging this reading explicitly in case an actual one-time migration (auto-declaring every currently-undeclared agent up) was intended instead, which this does not do. Added a shared .ui-dropdown-anchor utility class to Dropdown.css -- this is the third near-identical "position: relative wrapper for a badge that opens a Dropdown" (after agents own StatusChips.css and swarm-uis ComponentsPage.css), so a new caller should not reinvent a fourth copy. Left the two existing ones alone rather than migrating them as a drive-by. Verified with a local esbuild build + a throwaway mock /api/agents/status server, screenshotted headlessly: the wanted column now shows exactly one badge per row instead of stacked badges.
This commit is contained in:
parent
c18aee8f74
commit
6f57b1f57c
2 changed files with 113 additions and 84 deletions
|
|
@ -3,6 +3,19 @@
|
||||||
comment) so it hangs directly under the badge that opened it.
|
comment) so it hangs directly under the badge that opened it.
|
||||||
`--bg-elev` is the same elevated-surface slot the badge uses when
|
`--bg-elev` is the same elevated-surface slot the badge uses when
|
||||||
open (../badge/Badge.css), so the pair reads as one continuous panel. */
|
open (../badge/Badge.css), so the pair reads as one continuous panel. */
|
||||||
|
/* Reusable wrapper for the "badge that opens a `Dropdown` right
|
||||||
|
underneath itself" shape: `position: relative` is what lets
|
||||||
|
`.ui-dropdown`'s `position: absolute` above anchor to this box
|
||||||
|
instead of the page. A couple of callers predate this and declare
|
||||||
|
their own page-scoped copy of the same two rules (agent's
|
||||||
|
`StatusChips.css` `.status-chip-anchor`, swarm-ui's
|
||||||
|
`ComponentsPage.css` `.components-badge-anchor`) — left alone here
|
||||||
|
rather than migrated as a drive-by, but a *new* caller should reach
|
||||||
|
for this instead of reinventing a third one. */
|
||||||
|
.ui-dropdown-anchor {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
.ui-dropdown {
|
.ui-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 0.25em);
|
top: calc(100% + 0.25em);
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,13 @@
|
||||||
// itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than
|
// itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than
|
||||||
// its own route.
|
// its own route.
|
||||||
//
|
//
|
||||||
// The "wanted" column is the start/stop control (`toggleWanted`) plus a
|
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
|
||||||
// separate destroy control (`destroyAgent`) — both confirm via the
|
// that component's own comment above `AgentsPage` for why.
|
||||||
// shared `ui/confirm-dialog`.
|
import { useRef, useState } from "preact/hooks";
|
||||||
import { useState } from "preact/hooks";
|
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||||
|
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
|
||||||
import { Button } from "../ui/button/Button.js";
|
import { Button } from "../ui/button/Button.js";
|
||||||
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
|
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
|
||||||
import { Dialog } from "../ui/dialog/Dialog.js";
|
import { Dialog } from "../ui/dialog/Dialog.js";
|
||||||
|
|
@ -103,6 +103,80 @@ const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
|
||||||
// solve staleness rather than require an opt-in every visit.
|
// solve staleness rather than require an opt-in every visit.
|
||||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||||
|
|
||||||
|
// The "wanted" column's control: one badge showing the current
|
||||||
|
// declaration, opening a `Dropdown` with the three 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.
|
||||||
|
function WantedMenu({
|
||||||
|
row,
|
||||||
|
pending,
|
||||||
|
onSelectUp,
|
||||||
|
onSelectOffline,
|
||||||
|
onDestroy,
|
||||||
|
}: {
|
||||||
|
row: AgentRow;
|
||||||
|
pending: boolean;
|
||||||
|
onSelectUp: (row: AgentRow) => void;
|
||||||
|
onSelectOffline: (row: AgentRow) => void;
|
||||||
|
onDestroy: (row: AgentRow) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const destroyed = row.wanted === "destroyed";
|
||||||
|
const tone: BadgeTone =
|
||||||
|
row.wanted === "up" ? "positive" : destroyed ? "negative" : "neutral";
|
||||||
|
const options: DropdownOption[] = [
|
||||||
|
{ value: "up", label: "up" },
|
||||||
|
{ value: "offline", label: "offline" },
|
||||||
|
{ 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}
|
||||||
|
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 onDestroy(row);
|
||||||
|
}}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
/>
|
||||||
|
</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);
|
||||||
|
|
@ -120,10 +194,10 @@ export function AgentsPage() {
|
||||||
// The row pending destroy confirmation, `null` when the dialog is
|
// The row pending destroy confirmation, `null` when the dialog is
|
||||||
// closed — not a boolean, so the dialog can name the agent without a
|
// closed — not a boolean, so the dialog can name the agent without a
|
||||||
// second piece of state to keep in sync with it. `stopTarget` mirrors
|
// second piece of state to keep in sync with it. `stopTarget` mirrors
|
||||||
// it for the "declare offline" direction of the same toggle — two
|
// it for `WantedMenu`'s "offline" option — two separate pieces of
|
||||||
// separate pieces of state (not one "pending confirm" union) since
|
// state (not one "pending confirm" union) since both `ConfirmDialog`s
|
||||||
// both `ConfirmDialog`s can't be open at once anyway and keeping them
|
// can't be open at once anyway and keeping them apart means each
|
||||||
// apart means each one's JSX below reads standalone.
|
// one's JSX below reads standalone.
|
||||||
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
|
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
|
||||||
const [stopTarget, setStopTarget] = useState<AgentRow | null>(null);
|
const [stopTarget, setStopTarget] = useState<AgentRow | null>(null);
|
||||||
|
|
||||||
|
|
@ -144,11 +218,12 @@ export function AgentsPage() {
|
||||||
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Shared by `confirmStop` and `destroyAgent` — both are "PUT a new
|
// The one PUT every `WantedMenu` selection ends at, whether or not it
|
||||||
// `wanted` declaration, track per-agent pending/error state, patch the
|
// went through a `ConfirmDialog` first (`confirmStop`/`destroyAgent`
|
||||||
// response back into `rows`" with nothing else distinguishing them.
|
// for "offline"/"destroy"; called directly for "up", which needs no
|
||||||
// Which `ConfirmDialog` fires it and the target state stay in each
|
// confirmation): "PUT a new `wanted` declaration, track per-agent
|
||||||
// caller, since those are the parts that actually differ.
|
// 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) {
|
async function declareState(row: AgentRow, target: string) {
|
||||||
if (!row.hive) return;
|
if (!row.hive) return;
|
||||||
setPendingAgents((prev) => new Set(prev).add(row.name));
|
setPendingAgents((prev) => new Set(prev).add(row.name));
|
||||||
|
|
@ -201,27 +276,6 @@ 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. The "up" direction needs no
|
|
||||||
// confirmation and declares straight away; "offline" opens the
|
|
||||||
// `stopTarget` `ConfirmDialog` instead of declaring directly — see
|
|
||||||
// `confirmStop`.
|
|
||||||
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") {
|
|
||||||
setStopTarget(row);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await declareState(row, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fires from the `stopTarget` confirm `Dialog`. Used to be a native
|
// Fires from the `stopTarget` confirm `Dialog`. Used to be a native
|
||||||
// `window.confirm` (stop is reversible, a later start un-does it, so a
|
// `window.confirm` (stop is reversible, a later start un-does it, so a
|
||||||
// lighter-weight prompt than destroy's felt proportionate) — mara,
|
// lighter-weight prompt than destroy's felt proportionate) — mara,
|
||||||
|
|
@ -235,9 +289,9 @@ export function AgentsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fires from the `destroyTarget` confirm `Dialog`, never directly off
|
// Fires from the `destroyTarget` confirm `Dialog`, never directly off
|
||||||
// a row click — see `destroyTarget`/the "destroy" badge in the
|
// a row click — see `destroyTarget`/`WantedMenu`'s "destroy" option.
|
||||||
// `wanted` column. Unlike `toggleWanted`, there's no "current state"
|
// Unlike the "up"/"offline" directions, there's no "current state" to
|
||||||
// to read: destroy is a one-way declaration, not a flip.
|
// read: destroy is a one-way declaration, not a flip.
|
||||||
async function destroyAgent(row: AgentRow) {
|
async function destroyAgent(row: AgentRow) {
|
||||||
setDestroyTarget(null);
|
setDestroyTarget(null);
|
||||||
await declareState(row, "destroyed");
|
await declareState(row, "destroyed");
|
||||||
|
|
@ -308,60 +362,22 @@ export function AgentsPage() {
|
||||||
{
|
{
|
||||||
key: "wanted",
|
key: "wanted",
|
||||||
header: "wanted",
|
header: "wanted",
|
||||||
// The start/stop control: clicking the current-declared-state
|
// One `WantedMenu` (badge + dropdown, defined above) per row —
|
||||||
// badge toggles it — `Badge`'s own "chip plus, optionally, the
|
// real per-badge logic lives there, this column just wires its
|
||||||
// control" shape (see its header comment, which names pause/resume
|
// callbacks to the page's own state/handlers.
|
||||||
// as the exact motivating case), not a separate status chip next
|
|
||||||
// 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 ?? "",
|
sortBy: (a) => a.wanted ?? "",
|
||||||
filterValue: (a) => a.wanted ?? "no declaration",
|
filterValue: (a) => a.wanted ?? "no declaration",
|
||||||
render: (a) => {
|
render: (a) => {
|
||||||
const pending = pendingAgents.has(a.name);
|
|
||||||
const impliedCurrent =
|
|
||||||
a.wanted ?? (a.snapshot?.running ? "up" : "offline");
|
|
||||||
const actionLabel = impliedCurrent === "up" ? "stop" : "start";
|
|
||||||
const tone: BadgeTone = a.wanted === "up" ? "positive" : "neutral";
|
|
||||||
const err = actionErrors.get(a.name);
|
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";
|
|
||||||
const destroyed = a.wanted === "destroyed";
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Badge
|
<WantedMenu
|
||||||
tone={tone}
|
row={a}
|
||||||
value={pending ? "…" : (a.wanted ?? "no declaration")}
|
pending={pendingAgents.has(a.name)}
|
||||||
// `destroyed` blocks the click too, not just the label —
|
onSelectUp={(row) => void declareState(row, "up")}
|
||||||
// argus caught this in review: without it, a click here
|
onSelectOffline={setStopTarget}
|
||||||
// still computed `target = "up"` and PUT a live
|
onDestroy={setDestroyTarget}
|
||||||
// declaration on a destroyed row, contradicting the
|
|
||||||
// destroy confirm's own "not reversible from here" copy.
|
|
||||||
onClick={
|
|
||||||
a.hive && !destroyed ? () => void toggleWanted(a) : undefined
|
|
||||||
}
|
|
||||||
disabled={pending || !a.hive || destroyed}
|
|
||||||
title={
|
|
||||||
destroyed
|
|
||||||
? `${a.name} is destroyed — redeploy via "+ agent" to bring it back`
|
|
||||||
: a.hive
|
|
||||||
? `click to ${actionLabel} ${a.name}`
|
|
||||||
: "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 ? (
|
{err ? (
|
||||||
<Badge
|
<Badge
|
||||||
tone="negative"
|
tone="negative"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue