swarm ui: the agents page is separate cards, not one big agents card
The filter bar is a card of its own, holding the + agent button and the refresh picker; the hives card and each agent card sit beside it.
This commit is contained in:
parent
70901115a2
commit
6e5dc3dd30
5 changed files with 78 additions and 44 deletions
|
|
@ -55,14 +55,16 @@ works too.
|
||||||
|
|
||||||
### The agents page
|
### The agents page
|
||||||
|
|
||||||
`/agents` is the hives card, then one column of cards, one per agent. A card's
|
`/agents` is a column of separate cards: the hives card, a filter card, then one
|
||||||
|
card per agent (they are not wrapped in one big card). A card's
|
||||||
header shows its icon, name, freshness, the status message, a **terminal**
|
header shows its icon, name, freshness, the status message, a **terminal**
|
||||||
button that opens the agent's terminal tab, the **wanted** menu (up, offline,
|
button that opens the agent's terminal tab, the **wanted** menu (up, offline,
|
||||||
paused, destroy), and a **login needed** badge with a
|
paused, destroy), and a **login needed** badge with a
|
||||||
**log in** button when its claude has no session. Select the header to open the
|
**log in** button when its claude has no session. Select the header to open the
|
||||||
card in place; select it again to close it. Several cards can be open at once,
|
card in place; select it again to close it. Several cards can be open at once,
|
||||||
and a card stays open across the page's refresh, but not across a reload. The
|
and a card stays open across the page's refresh, but not across a reload. The
|
||||||
filter bar above the cards filters by name, hive, status, message, wanted state
|
filter card, which also holds the **+ agent** button and the refresh picker,
|
||||||
|
filters by name, hive, status, message, wanted state
|
||||||
and config PR.
|
and config PR.
|
||||||
|
|
||||||
An opened card has four sections, each folded except **overview**, and each
|
An opened card has four sections, each folded except **overview**, and each
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* The folded hives card over the agents. */
|
/* The folded hives card over the agents. */
|
||||||
.ui-agents-hives {
|
.ui-agents-hives {
|
||||||
margin-block-end: 1em;
|
margin-block-end: 0.5em;
|
||||||
}
|
}
|
||||||
/* The roster's refresh picker, at the top right of the open card. */
|
/* The roster's refresh picker, at the top right of the open card. */
|
||||||
.ui-hives-refresh {
|
.ui-hives-refresh {
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@
|
||||||
// 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.
|
||||||
//
|
//
|
||||||
// The roster is one column of cards, each opening in place into its details
|
// The page is a column of separate cards: the hives card, a filter card (with
|
||||||
// (mara: "one column of expandable cards"). This file owns the page's own
|
// the "+ agent" button and the refresh picker), then one card per agent, each
|
||||||
|
// opening in place into its details (mara: "one column of expandable cards";
|
||||||
|
// the agents are not wrapped in one big card). This file owns the page's own
|
||||||
// state and the filter fields only — `AgentRow` and friends live in
|
// state and the filter fields only — `AgentRow` and friends live in
|
||||||
// `AgentTypes.ts`, the wanted-state control in `WantedMenu.tsx`, the actions
|
// `AgentTypes.ts`, the wanted-state control in `WantedMenu.tsx`, the actions
|
||||||
// on an agent (declare a state, link an account) in `AgentActions.tsx`, the
|
// on an agent (declare a state, link an account) in `AgentActions.tsx`, the
|
||||||
|
|
@ -34,7 +36,6 @@ import { FRESHNESS, type AgentRow } from "../../agent/AgentTypes.js";
|
||||||
import { Button } from "../../ui/button/Button.js";
|
import { Button } from "../../ui/button/Button.js";
|
||||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||||
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
||||||
import { Panel } from "../../ui/panel/Panel.js";
|
|
||||||
import { HivesCard } from "./HivesCard.js";
|
import { HivesCard } from "./HivesCard.js";
|
||||||
import {
|
import {
|
||||||
RefreshIntervalPicker,
|
RefreshIntervalPicker,
|
||||||
|
|
@ -128,43 +129,39 @@ export function AgentsPage() {
|
||||||
<div class="ui-agents-hives">
|
<div class="ui-agents-hives">
|
||||||
<HivesCard />
|
<HivesCard />
|
||||||
</div>
|
</div>
|
||||||
<Panel
|
{error ? (
|
||||||
title="agents"
|
<ApiErrorPanel
|
||||||
icon="👥"
|
context="failed to load the agent roster"
|
||||||
actions={
|
problem={error}
|
||||||
<>
|
/>
|
||||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
) : null}
|
||||||
+ agent
|
{!error && rows === null ? <p>loading…</p> : null}
|
||||||
</Button>
|
{rows ? (
|
||||||
<RefreshIntervalPicker
|
<FilterableView
|
||||||
id="agents-refresh"
|
view="cards"
|
||||||
value={intervalMs}
|
standalone
|
||||||
onChange={setIntervalMs}
|
barActions={
|
||||||
/>
|
<>
|
||||||
</>
|
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
||||||
}
|
+ agent
|
||||||
>
|
</Button>
|
||||||
{error ? (
|
<RefreshIntervalPicker
|
||||||
<ApiErrorPanel
|
id="agents-refresh"
|
||||||
context="failed to load the agent roster"
|
value={intervalMs}
|
||||||
problem={error}
|
onChange={setIntervalMs}
|
||||||
/>
|
/>
|
||||||
) : null}
|
</>
|
||||||
{!error && rows === null ? <p>loading…</p> : null}
|
}
|
||||||
{rows ? (
|
columns={FIELDS}
|
||||||
<FilterableView
|
rows={rows}
|
||||||
view="cards"
|
rowKey={(a) => a.name}
|
||||||
columns={FIELDS}
|
storageKey="swarm-ui:agents:table-filters"
|
||||||
rows={rows}
|
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
|
||||||
rowKey={(a) => a.name}
|
// Each card gets its live row from `rows` on every render, so an
|
||||||
storageKey="swarm-ui:agents:table-filters"
|
// opened card follows a refresh or a state change made inside it.
|
||||||
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
|
renderCard={(a) => <AgentCard row={a} actions={actions} />}
|
||||||
// Each card gets its live row from `rows` on every render, so an
|
/>
|
||||||
// opened card follows a refresh or a state change made inside it.
|
) : null}
|
||||||
renderCard={(a) => <AgentCard row={a} actions={actions} />}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,19 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
}
|
}
|
||||||
|
/* A filter bar that is a card of its own (`standalone`), the same border and
|
||||||
|
fill as a row card. */
|
||||||
|
.ui-filterable-view-filter-card {
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
/* The caller's buttons, at the bar's trailing edge. */
|
||||||
|
.ui-filterable-view-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,11 @@ function FilterBar<T>({
|
||||||
resetFilters,
|
resetFilters,
|
||||||
activeFilters,
|
activeFilters,
|
||||||
multiselectOptionsFor,
|
multiselectOptionsFor,
|
||||||
|
actions,
|
||||||
|
standalone,
|
||||||
}: {
|
}: {
|
||||||
|
actions?: ComponentChildren;
|
||||||
|
standalone?: boolean;
|
||||||
columns: FilterColumn<T>[];
|
columns: FilterColumn<T>[];
|
||||||
getFilter: (key: string) => MultiselectFilterState & { value: string };
|
getFilter: (key: string) => MultiselectFilterState & { value: string };
|
||||||
updateFilter: (
|
updateFilter: (
|
||||||
|
|
@ -65,7 +69,13 @@ function FilterBar<T>({
|
||||||
const filterable = columns.filter(isFilterable);
|
const filterable = columns.filter(isFilterable);
|
||||||
if (filterable.length === 0) return null;
|
if (filterable.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div class="ui-filterable-view-filter-bar">
|
<div
|
||||||
|
class={
|
||||||
|
standalone
|
||||||
|
? "ui-filterable-view-filter-bar ui-filterable-view-filter-card"
|
||||||
|
: "ui-filterable-view-filter-bar"
|
||||||
|
}
|
||||||
|
>
|
||||||
{filterable.map((c) => {
|
{filterable.map((c) => {
|
||||||
const label = columnLabel(c);
|
const label = columnLabel(c);
|
||||||
if (c.filterMode === "multiselect") {
|
if (c.filterMode === "multiselect") {
|
||||||
|
|
@ -105,6 +115,7 @@ function FilterBar<T>({
|
||||||
reset filters
|
reset filters
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{actions ? <div class="ui-filterable-view-actions">{actions}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -130,9 +141,15 @@ export function FilterableView<T>(
|
||||||
// (same as `Table`'s own "no rows match the current filter") — the
|
// (same as `Table`'s own "no rows match the current filter") — the
|
||||||
// two cases mean different things, same as `Table`'s own split.
|
// two cases mean different things, same as `Table`'s own split.
|
||||||
emptyMessage?: ComponentChildren;
|
emptyMessage?: ComponentChildren;
|
||||||
|
// Cards view only. The filter bar becomes a card of its own, so the caller
|
||||||
|
// can lay it out beside the row cards instead of inside a panel, and
|
||||||
|
// `barActions` (buttons, a refresh picker) sit at its trailing edge.
|
||||||
|
standalone?: boolean;
|
||||||
|
barActions?: ComponentChildren;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { columns, rows, rowKey, storageKey, renderCard, emptyMessage } = props;
|
const { columns, rows, rowKey, storageKey, renderCard, emptyMessage } = props;
|
||||||
|
const { standalone, barActions } = props;
|
||||||
const {
|
const {
|
||||||
visibleRows,
|
visibleRows,
|
||||||
activeFilters,
|
activeFilters,
|
||||||
|
|
@ -165,6 +182,8 @@ export function FilterableView<T>(
|
||||||
resetFilters={resetFilters}
|
resetFilters={resetFilters}
|
||||||
activeFilters={activeFilters}
|
activeFilters={activeFilters}
|
||||||
multiselectOptionsFor={multiselectOptionsFor}
|
multiselectOptionsFor={multiselectOptionsFor}
|
||||||
|
actions={barActions}
|
||||||
|
standalone={standalone}
|
||||||
/>
|
/>
|
||||||
{rows.length === 0 && emptyMessage ? (
|
{rows.length === 0 && emptyMessage ? (
|
||||||
<p class="ui-filterable-view-empty">{emptyMessage}</p>
|
<p class="ui-filterable-view-empty">{emptyMessage}</p>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue