swarm-ui/agents: shared filters across views, real second panel for detail
Two more asks from mara's live review: - "i want the same filters for the cards tho, thats why i suggested separating data and filter from view" - extracted Table's filter *state* (not its popover UI, which stays table-shaped) into a new exported useTableFilters hook. Table calls it internally, unchanged behavior for every existing caller. AgentsPage now calls the same hook with the same storageKey, so card view and table view read/write one shared filter state instead of each having their own (or cards having none at all). Card view gets its own toolbar (AgentFilterBar) - a name search input plus one FilterMultiselect per multiselect column, same checkbox-list markup Table's own popover uses, driving the same state. Switching the view toggle no longer loses or hides whatever's filtered. - "why no separate panel? i mean a second panel on agent page" - replaced the modal Dialog with a real second Panel, always mounted (empty state when nothing's selected, so selecting an agent never shifts the page's own layout). Panel gained an optional `class` prop so the two panels can flex-size themselves in a row. List/detail panels sit side by side in a flex-wrap row that stacks on a narrow viewport - content-driven, same approach the shell's own nav uses, not a second hardcoded breakpoint. Selected card gets a highlight so it's clear which one the detail panel is showing. Verified with real CDP clicks: split layout with nothing selected, selecting a card highlights it and populates the detail panel, opening a card-view filter and checking a value narrows both the card list AND (after switching the toggle) the table to the identical row set.
This commit is contained in:
parent
8786f24111
commit
60d7e8ce84
4 changed files with 586 additions and 263 deletions
|
|
@ -60,6 +60,14 @@
|
|||
border-color: var(--purple);
|
||||
outline: none;
|
||||
}
|
||||
/* The row currently shown in the detail panel — a solid border (not
|
||||
just the hover tint above, which needs to keep meaning "hovering",
|
||||
not double as "selected") plus a faint fill so it still reads once
|
||||
the pointer moves away. */
|
||||
.ui-agent-card-selected {
|
||||
border-color: var(--purple);
|
||||
background: color-mix(in srgb, var(--purple) 10%, var(--bg-elev));
|
||||
}
|
||||
.ui-agent-card-line1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -82,11 +90,8 @@
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Detail panel (`Dialog` body) — the fields `AgentCard`'s main view
|
||||
doesn't show. */
|
||||
.ui-agent-detail-name {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
/* Detail panel body — its own `Panel`, title supplied by the panel's
|
||||
`title` prop (the agent's name), not repeated in here. */
|
||||
.ui-agent-detail-fields {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
|
|
@ -104,3 +109,55 @@
|
|||
align-items: center;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
/* List panel + detail panel side by side — `flex-wrap`, not a
|
||||
`@media` breakpoint, so a narrow viewport stacks them the same
|
||||
content-driven way the shell's own nav already wraps (`Shell.css`),
|
||||
rather than picking a second, independent magic-number breakpoint. */
|
||||
.ui-agents-layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.ui-agents-list-panel {
|
||||
flex: 2 1 480px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ui-agents-detail-panel {
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Card view's filter toolbar — `Table`'s per-column popovers, minus the
|
||||
table to hang them off of. */
|
||||
.ui-agents-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.ui-agents-filter-search {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.35em 0.6em;
|
||||
font: inherit;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.ui-agents-filter {
|
||||
position: relative;
|
||||
}
|
||||
/* `.ui-table-filter-popover` (Table.css) supplies the panel chrome
|
||||
(background/border/shadow) — this just positions it under the
|
||||
trigger, `position: absolute` rather than the fixed/portal recipe
|
||||
`Table`'s own version needs: nothing here clips this popover, so the
|
||||
simpler positioning is enough (see this component's own comment). */
|
||||
.ui-agents-filter-popover {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
//
|
||||
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
|
||||
// that component's own comment above `AgentsPage` for why.
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { useEffect, useRef, 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";
|
||||
|
|
@ -45,7 +45,7 @@ import {
|
|||
useRefreshInterval,
|
||||
type RefreshIntervalMs,
|
||||
} from "../ui/refresh-interval/RefreshInterval.js";
|
||||
import { Table, type TableColumn } from "../ui/table/Table.js";
|
||||
import { Table, useTableFilters, type TableColumn } from "../ui/table/Table.js";
|
||||
import { CreateAgentForm } from "./CreateAgentForm.js";
|
||||
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
|
||||
import "./AgentsPage.css";
|
||||
|
|
@ -289,6 +289,7 @@ function AgentCard({
|
|||
row,
|
||||
pending,
|
||||
error,
|
||||
selected,
|
||||
onSelectUp,
|
||||
onSelectOffline,
|
||||
onSelectPaused,
|
||||
|
|
@ -297,6 +298,11 @@ function AgentCard({
|
|||
row: AgentRow;
|
||||
pending: boolean;
|
||||
error: ProblemDetails | undefined;
|
||||
/** This row is the one currently shown in the detail panel — a
|
||||
* highlight only (the panel itself is the source of truth), so a
|
||||
* page-refresh landing on a still-valid `detailTarget` reads as
|
||||
* obviously "that one" rather than a silent selection nothing marks. */
|
||||
selected: boolean;
|
||||
onSelectUp: (row: AgentRow) => void;
|
||||
onSelectOffline: (row: AgentRow) => void;
|
||||
onSelectPaused: (row: AgentRow) => void;
|
||||
|
|
@ -305,7 +311,9 @@ function AgentCard({
|
|||
const { tone, label } = FRESHNESS[row.freshness];
|
||||
return (
|
||||
<div
|
||||
class="ui-agent-card"
|
||||
class={
|
||||
selected ? "ui-agent-card ui-agent-card-selected" : "ui-agent-card"
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpenDetail(row)}
|
||||
|
|
@ -363,6 +371,180 @@ function AgentCard({
|
|||
);
|
||||
}
|
||||
|
||||
// One `<input>` (the "name" column's plain-text filter) plus one
|
||||
// `FilterMultiselect` per multiselect-mode filterable column — the card
|
||||
// view's answer to `Table`'s own per-column popover icons, driving the
|
||||
// exact same `useTableFilters` state `AgentsPage` passes down (mara:
|
||||
// "same filters for the cards tho"). Deliberately not a generic
|
||||
// "render every filterable column automatically" loop: the name column's
|
||||
// free-text filter needs its own always-visible `<input>`, not a
|
||||
// dropdown, so there's no single shape that covers every column anyway.
|
||||
function AgentFilterBar({
|
||||
columns,
|
||||
getFilter,
|
||||
updateFilter,
|
||||
toggleFilterValue,
|
||||
resetFilters,
|
||||
activeFilters,
|
||||
multiselectOptionsFor,
|
||||
}: {
|
||||
columns: TableColumn<AgentRow>[];
|
||||
getFilter: (key: string) => {
|
||||
value: string;
|
||||
values: string[];
|
||||
negate: boolean;
|
||||
};
|
||||
updateFilter: (
|
||||
key: string,
|
||||
patch: Partial<{ value: string; values: string[]; negate: boolean }>,
|
||||
) => void;
|
||||
toggleFilterValue: (key: string, value: string) => void;
|
||||
resetFilters: () => void;
|
||||
activeFilters: unknown[];
|
||||
multiselectOptionsFor: (c: TableColumn<AgentRow>) => string[];
|
||||
}) {
|
||||
const hiveCol = columns.find((c) => c.key === "hive");
|
||||
const statusCol = columns.find((c) => c.key === "status");
|
||||
const wantedCol = columns.find((c) => c.key === "wanted");
|
||||
return (
|
||||
<div class="ui-agents-filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
class="ui-agents-filter-search"
|
||||
placeholder="filter by name…"
|
||||
aria-label="filter by name"
|
||||
value={getFilter("name").value}
|
||||
onInput={(e) =>
|
||||
updateFilter("name", {
|
||||
value: (e.target as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{hiveCol ? (
|
||||
<FilterMultiselect
|
||||
label="hive"
|
||||
options={multiselectOptionsFor(hiveCol)}
|
||||
filter={getFilter("hive")}
|
||||
onToggle={(v) => toggleFilterValue("hive", v)}
|
||||
onNegateChange={(negate) => updateFilter("hive", { negate })}
|
||||
/>
|
||||
) : null}
|
||||
{statusCol ? (
|
||||
<FilterMultiselect
|
||||
label="status"
|
||||
options={multiselectOptionsFor(statusCol)}
|
||||
filter={getFilter("status")}
|
||||
onToggle={(v) => toggleFilterValue("status", v)}
|
||||
onNegateChange={(negate) => updateFilter("status", { negate })}
|
||||
/>
|
||||
) : null}
|
||||
{wantedCol ? (
|
||||
<FilterMultiselect
|
||||
label="wanted"
|
||||
options={multiselectOptionsFor(wantedCol)}
|
||||
filter={getFilter("wanted")}
|
||||
onToggle={(v) => toggleFilterValue("wanted", v)}
|
||||
onNegateChange={(negate) => updateFilter("wanted", { negate })}
|
||||
/>
|
||||
) : null}
|
||||
{activeFilters.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
class="ui-table-reset-filters"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
reset filters
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One multiselect filter's trigger + popover — same checkbox-list markup
|
||||
// `Table`'s own popover renders (`.ui-table-filter-checkbox`/
|
||||
// `-negate`, reused rather than duplicated), just `position: absolute`
|
||||
// under a `Badge` trigger instead of a portal: this never sits inside a
|
||||
// clipping scroll container or a `<dialog>` the way `WantedMenu`'s
|
||||
// portal mode has to route around, so the simpler positioning is enough.
|
||||
function FilterMultiselect({
|
||||
label,
|
||||
options,
|
||||
filter,
|
||||
onToggle,
|
||||
onNegateChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: string[];
|
||||
filter: { values: string[]; negate: boolean };
|
||||
onToggle: (value: string) => void;
|
||||
onNegateChange: (negate: boolean) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.target instanceof Node && ref.current?.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div class="ui-agents-filter" ref={ref}>
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value={
|
||||
filter.values.length > 0
|
||||
? `${label} (${filter.values.length})`
|
||||
: label
|
||||
}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
expanded={open}
|
||||
/>
|
||||
{open ? (
|
||||
<div
|
||||
class="ui-agents-filter-popover ui-table-filter-popover"
|
||||
role="dialog"
|
||||
aria-label={`filter by ${label}`}
|
||||
>
|
||||
<div class="ui-table-filter-multiselect">
|
||||
{options.map((v) => (
|
||||
<label key={v} class="ui-table-filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filter.values.includes(v)}
|
||||
onChange={() => onToggle(v)}
|
||||
/>
|
||||
{v}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label class="ui-table-filter-negate">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filter.negate}
|
||||
onChange={(e) =>
|
||||
onNegateChange((e.target as HTMLInputElement).checked)
|
||||
}
|
||||
/>
|
||||
exclude
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsPage() {
|
||||
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
|
|
@ -666,186 +848,226 @@ export function AgentsPage() {
|
|||
},
|
||||
];
|
||||
|
||||
// Same hook `Table` itself now uses internally, called again here with
|
||||
// the identical `columns` + `storageKey` — one filter state (in
|
||||
// localStorage), two independent readers. `rows ?? []`: the hook wants
|
||||
// a real array, and there's nothing to filter before the first
|
||||
// `refresh()` resolves anyway.
|
||||
const {
|
||||
visibleRows,
|
||||
activeFilters,
|
||||
getFilter,
|
||||
updateFilter,
|
||||
toggleFilterValue,
|
||||
resetFilters,
|
||||
multiselectOptionsFor,
|
||||
} = useTableFilters(columns, rows ?? [], "swarm-ui:agents:table-filters");
|
||||
|
||||
return (
|
||||
<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 && rows.length === 0 ? (
|
||||
<p class="ui-agents-empty">
|
||||
no agents yet — the swarm-wide identity store has no agents registered
|
||||
on any hive
|
||||
</p>
|
||||
) : null}
|
||||
{rows && rows.length > 0 && viewMode === "cards" ? (
|
||||
<div class="ui-agent-card-list">
|
||||
{rows.map((a) => (
|
||||
<AgentCard
|
||||
key={a.name}
|
||||
row={a}
|
||||
pending={pendingAgents.has(a.name)}
|
||||
error={actionErrors.get(a.name)}
|
||||
onSelectUp={selectUp}
|
||||
onSelectOffline={selectOffline}
|
||||
onSelectPaused={selectPaused}
|
||||
onOpenDetail={setDetailTarget}
|
||||
<>
|
||||
<div class="ui-agents-layout">
|
||||
<Panel
|
||||
title="agents"
|
||||
icon="👥"
|
||||
class="ui-agents-list-panel"
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{rows && rows.length > 0 && viewMode === "table" ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(a) => a.name}
|
||||
storageKey="swarm-ui:agents:table-filters"
|
||||
/>
|
||||
) : null}
|
||||
<Dialog
|
||||
open={detailTarget !== null}
|
||||
onClose={() => setDetailTarget(null)}
|
||||
label={detailTarget ? `${detailTarget.name} details` : "agent details"}
|
||||
>
|
||||
{/* Mara: "the info from main list should be included in the
|
||||
agent view" — so this repeats status/message/wanted rather
|
||||
than showing only what `AgentCard`'s main view doesn't, plus
|
||||
hive/config-PR/matrix-link, which really are panel-only. */}
|
||||
{detailTarget ? (
|
||||
<div class="ui-agent-detail">
|
||||
<h2 class="ui-agent-detail-name">{detailTarget.name}</h2>
|
||||
<dl class="ui-agent-detail-fields">
|
||||
<dt>status</dt>
|
||||
<dd>
|
||||
{(() => {
|
||||
const { tone, label } = FRESHNESS[detailTarget.freshness];
|
||||
return (
|
||||
) : null}
|
||||
{!error && rows === null ? <p>loading…</p> : null}
|
||||
{rows && rows.length === 0 ? (
|
||||
<p class="ui-agents-empty">
|
||||
no agents yet — the swarm-wide identity store has no agents
|
||||
registered on any hive
|
||||
</p>
|
||||
) : null}
|
||||
{/* Card view's own filter toolbar — table view keeps its
|
||||
existing per-column popovers instead (see `columns` above);
|
||||
both read/write the same `useTableFilters` state (same
|
||||
`storageKey`), so switching the view toggle doesn't reset or
|
||||
hide whatever's filtered. */}
|
||||
{rows && rows.length > 0 && viewMode === "cards" ? (
|
||||
<AgentFilterBar
|
||||
columns={columns}
|
||||
getFilter={getFilter}
|
||||
updateFilter={updateFilter}
|
||||
toggleFilterValue={toggleFilterValue}
|
||||
resetFilters={resetFilters}
|
||||
activeFilters={activeFilters}
|
||||
multiselectOptionsFor={multiselectOptionsFor}
|
||||
/>
|
||||
) : null}
|
||||
{rows && rows.length > 0 && viewMode === "cards" ? (
|
||||
<div class="ui-agent-card-list">
|
||||
{visibleRows.length === 0 ? (
|
||||
<p class="ui-agents-empty">no rows match the current filter</p>
|
||||
) : (
|
||||
visibleRows.map((a) => (
|
||||
<AgentCard
|
||||
key={a.name}
|
||||
row={a}
|
||||
pending={pendingAgents.has(a.name)}
|
||||
error={actionErrors.get(a.name)}
|
||||
selected={detailTarget?.name === a.name}
|
||||
onSelectUp={selectUp}
|
||||
onSelectOffline={selectOffline}
|
||||
onSelectPaused={selectPaused}
|
||||
onOpenDetail={setDetailTarget}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{rows && rows.length > 0 && viewMode === "table" ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(a) => a.name}
|
||||
storageKey="swarm-ui:agents:table-filters"
|
||||
/>
|
||||
) : 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. `.ui-agents-layout`'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. */}
|
||||
<Panel
|
||||
title={detailTarget ? detailTarget.name : "agent details"}
|
||||
icon="🔎"
|
||||
class="ui-agents-detail-panel"
|
||||
>
|
||||
{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={tone}
|
||||
tone="warning"
|
||||
value={
|
||||
<>
|
||||
{label}
|
||||
{detailTarget.last_seen_unix !== null ? (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<RelativeTime
|
||||
epochMs={detailTarget.last_seen_unix * 1000}
|
||||
/>
|
||||
)
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
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>
|
||||
<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={(row) => {
|
||||
setDestroyTarget(row);
|
||||
setDetailTarget(null);
|
||||
}}
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</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"
|
||||
}
|
||||
/>
|
||||
</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);
|
||||
setDetailTarget(null);
|
||||
}
|
||||
: 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>
|
||||
</div>
|
||||
) : null}
|
||||
</Dialog>
|
||||
) : (
|
||||
<p class="ui-agents-empty">select an agent to see its details</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
|
|
@ -900,6 +1122,6 @@ export function AgentsPage() {
|
|||
</p>
|
||||
) : null}
|
||||
</ConfirmDialog>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue