swarm-ui/agents: bring back the table view (and its filters) as a toggle
Mara's follow-up on #4257, right after the card-only version went up: "still want the filters tho, maybe split the data component from the view" / "if we split data component and view, we can make it switchable between table and specialized card view." AgentsPage already had its data/actions (rows, declareState, the dialog-target state) separated from rendering by this point - the only missing piece was a second renderer. Restores the original Table + columns array as the "table" viewMode alongside the new card view, adds a small two-button toggle in the Panel's actions row, and persists the choice to localStorage (same pattern as Table's own per-column filter persistence) so a reload keeps the last choice. Cards stays the default. Verified with a real CDP click switching to table view: columns, sort arrows, and filter icons are all back.
This commit is contained in:
parent
26b0b0d173
commit
1e6156d116
2 changed files with 247 additions and 29 deletions
|
|
@ -8,11 +8,43 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
}
|
}
|
||||||
.ui-agent-card-list-empty {
|
/* Shared by both views (`Table` has its own `emptyMessage` rendering,
|
||||||
|
but the card view has nothing equivalent — this covers both, and
|
||||||
|
`AgentsPage` only mounts it when `rows` is loaded and empty). */
|
||||||
|
.ui-agents-empty {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 1.25em 0.75em;
|
padding: 1.25em 0.75em;
|
||||||
}
|
}
|
||||||
|
/* Two-way view switcher in the Panel's title-row actions — same visual
|
||||||
|
weight as `RefreshIntervalPicker` next to it (quiet until touched),
|
||||||
|
but a segmented pair rather than a select since there's no listbox
|
||||||
|
worth opening for a two-option choice. */
|
||||||
|
.ui-agents-view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.4em;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ui-agents-view-toggle button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.45em 0.75em;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ui-agents-view-toggle button + button {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ui-agents-view-toggle button.active {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.ui-agents-view-toggle button:not(.active):hover {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
.ui-agent-card {
|
.ui-agent-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
// <AgentsPage> — the swarm's agent roster, merged with each agent's open
|
// <AgentsPage> — the swarm's agent roster, merged with each agent's open
|
||||||
// config-PR status, swarm-wide health status, and declared wanted state
|
// config-PR status, swarm-wide health status, and declared wanted state.
|
||||||
// in the same table. Three separate asks (a roster listing, a per-agent
|
// A roster with no per-row detail is thin, and neither a config-PR nor a
|
||||||
// config-PR indicator, and per-agent status/liveness) that turned out to
|
// status panel has anything to render against without a roster to embed
|
||||||
// be one page: a roster with no per-row detail is thin, and neither a
|
// in — so all three ended up as one page.
|
||||||
// config-PR panel nor a status panel has anything to render against
|
|
||||||
// without a roster to embed in.
|
|
||||||
//
|
//
|
||||||
// One fetch, on a refresh-interval cadence like HivesPage: `GET
|
// One fetch, on a refresh-interval cadence like HivesPage: `GET
|
||||||
// /api/agents/status` — one row per roster agent (identity store is the
|
// /api/agents/status` — one row per roster agent (identity store is the
|
||||||
// roster, so a never-reported agent still gets a row), each already
|
// roster, so a never-reported agent still gets a row), each already
|
||||||
// carrying its freshness/snapshot/config PR/wanted state. That merge
|
// carrying its freshness/snapshot/config PR/wanted state. Used to be
|
||||||
// used to be three separate fetches (`/api/agents`, `/api/config-prs`,
|
// three separate fetches joined client-side by name; per operator
|
||||||
// `/api/agents/status`) joined client-side by name; per operator review
|
// review feedback ("the view should be filled by a single backend
|
||||||
// feedback ("the view should be filled by a single backend call") the
|
// call") the join moved server-side instead — see
|
||||||
// join moved server-side instead — see `swarm-controller/src/main.rs`'s
|
// `swarm-controller/src/main.rs`'s `get_agents_status` handler. No more
|
||||||
// `get_agents_status` handler for where `config_pr`/`wanted` get merged
|
// per-page joining left to do here: the wire row *is* the row this page renders.
|
||||||
// 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 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. The form itself
|
// natural home for the action that populates it. The form itself
|
||||||
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
|
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
|
||||||
//
|
//
|
||||||
// Renders as one `AgentCard` per row, not a `Table` — see that
|
// Two interchangeable views over the same `rows`/actions, picked by
|
||||||
// component's own comment below for the card/detail-panel split.
|
// `viewMode`: the original `Table` (full columns, per-column sort/
|
||||||
|
// filter) and one `AgentCard` per row (see that component's own
|
||||||
|
// comment for the card/detail-panel split). Mara, after the card view
|
||||||
|
// landed: "still want the filters tho" / "split data component from
|
||||||
|
// the view... switchable between table and specialized card view" —
|
||||||
|
// so the toggle, not a card-only replacement.
|
||||||
//
|
//
|
||||||
// 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.
|
||||||
|
|
@ -45,6 +45,7 @@ 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";
|
import "./AgentsPage.css";
|
||||||
|
|
@ -142,6 +143,11 @@ const CONFIRM_COPY: Record<
|
||||||
// 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;
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
// The "wanted" column's control: one badge showing the current
|
// The "wanted" column's control: one badge showing the current
|
||||||
// declaration, opening a `Dropdown` with the four explicit states —
|
// declaration, opening a `Dropdown` with the four explicit states —
|
||||||
// replaces the old toggle-badge-plus-separate-destroy-badge pair.
|
// replaces the old toggle-badge-plus-separate-destroy-badge pair.
|
||||||
|
|
@ -247,15 +253,15 @@ function WantedMenu({
|
||||||
}
|
}
|
||||||
|
|
||||||
// One card per roster agent (mara: "main view: name, status, message,
|
// One card per roster agent (mara: "main view: name, status, message,
|
||||||
// wanted" / "message as second line" / "more like card per agent",
|
// wanted" / "message as second line" / "more like card per agent") —
|
||||||
// replacing the old table row). Everything else the old table's other
|
// the `viewMode === "cards"` alternative to the original `Table`, not
|
||||||
// columns carried (hive, matrix link-account, config-PR link, destroy)
|
// its replacement (see `AgentsPage`'s own comment). Everything the
|
||||||
// moved to the detail panel `AgentsPage` opens on card click — a plain
|
// table's other columns carried (hive, matrix link-account, config-PR
|
||||||
// `Dialog`, not the shared `hive-side-panel` slide-in drawer: that's a
|
// link, destroy) moves to a detail panel on card click instead — a
|
||||||
// shadow-DOM custom element, and swarm-ui's esbuild config can't consume
|
// plain `Dialog`, not the shared `hive-side-panel` slide-in drawer:
|
||||||
// those at all yet (same already-tracked gap `Dialog.tsx`'s own comment
|
// that's a shadow-DOM custom element, and swarm-ui's esbuild config
|
||||||
// notes for `hive-dialog`). Revisit once that gap closes. The old
|
// can't consume those yet (same gap `Dialog.tsx`'s own comment already
|
||||||
// per-column sort/filter has no replacement yet in this view.
|
// flags for `hive-dialog`). Revisit once that gap closes.
|
||||||
//
|
//
|
||||||
// A `role="button"` div, not a real `<button>`: the card also hosts the
|
// A `role="button"` div, not a real `<button>`: the card also hosts the
|
||||||
// real `<button>`s inside `WantedMenu`, and nested buttons are invalid
|
// real `<button>`s inside `WantedMenu`, and nested buttons are invalid
|
||||||
|
|
@ -346,6 +352,25 @@ export function AgentsPage() {
|
||||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
const [intervalMs, setIntervalMs] =
|
const [intervalMs, setIntervalMs] =
|
||||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
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);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
// Per-agent, not one page-wide flag: one row's declare-in-flight
|
// Per-agent, not one page-wide flag: one row's declare-in-flight
|
||||||
// shouldn't disable every other row's button.
|
// shouldn't disable every other row's button.
|
||||||
|
|
@ -484,6 +509,141 @@ export function AgentsPage() {
|
||||||
await declareState(row, "destroyed");
|
await declareState(row, "destroyed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `viewMode === "table"`'s columns — the full field set (including
|
||||||
|
// per-column sort/filter, which the card view doesn't have) mara asked
|
||||||
|
// to keep. 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)}
|
||||||
|
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",
|
||||||
|
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"
|
||||||
|
|
@ -493,6 +653,24 @@ export function AgentsPage() {
|
||||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
||||||
+ agent
|
+ agent
|
||||||
</Button>
|
</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
|
<RefreshIntervalPicker
|
||||||
id="agents-refresh"
|
id="agents-refresh"
|
||||||
value={intervalMs}
|
value={intervalMs}
|
||||||
|
|
@ -509,12 +687,12 @@ export function AgentsPage() {
|
||||||
) : null}
|
) : null}
|
||||||
{!error && rows === null ? <p>loading…</p> : null}
|
{!error && rows === null ? <p>loading…</p> : null}
|
||||||
{rows && rows.length === 0 ? (
|
{rows && rows.length === 0 ? (
|
||||||
<p class="ui-agent-card-list-empty">
|
<p class="ui-agents-empty">
|
||||||
no agents yet — the swarm-wide identity store has no agents registered
|
no agents yet — the swarm-wide identity store has no agents registered
|
||||||
on any hive
|
on any hive
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{rows && rows.length > 0 ? (
|
{rows && rows.length > 0 && viewMode === "cards" ? (
|
||||||
<div class="ui-agent-card-list">
|
<div class="ui-agent-card-list">
|
||||||
{rows.map((a) => (
|
{rows.map((a) => (
|
||||||
<AgentCard
|
<AgentCard
|
||||||
|
|
@ -534,6 +712,14 @@ export function AgentsPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{rows && rows.length > 0 && viewMode === "table" ? (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
rowKey={(a) => a.name}
|
||||||
|
storageKey="swarm-ui:agents:table-filters"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={detailTarget !== null}
|
open={detailTarget !== null}
|
||||||
onClose={() => setDetailTarget(null)}
|
onClose={() => setDetailTarget(null)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue