Each agent card now leads with the agent's icon, loaded as an `<img>` from `GET /api/agents/<name>/icon`: the same 5em square, background and fallback as the hive dashboard's container row. An agent with no icon (the route's 404), or any other failed load, shows the dimmed hyperhive mark (`/favicon.svg`) instead of a broken image. Only ever an `<img>`, never inline markup: the body is an agent-authored SVG, and an image load does not run its script.
130 lines
4.5 KiB
TypeScript
130 lines
4.5 KiB
TypeScript
// <AgentCard> — one card per roster agent (mara: "main view: name,
|
|
// status, message, wanted" / "message as second line" / "more like
|
|
// card per agent") — the FilterableView `view === "cards"` alternative
|
|
// to the original `Table`, not its replacement (see `AgentsPage`'s own
|
|
// comment). Clicking a card opens a detail panel repeating this same
|
|
// info plus what doesn't fit here (hive, config-PR link, matrix
|
|
// link-account) — mara: "the info from main list should be included in
|
|
// the agent view" too, not just the leftovers.
|
|
//
|
|
// Built on the shared `Card` primitive (`ui/card/`) for the clickable/
|
|
// selectable-container mechanics — the `stopPropagation` wrapper around
|
|
// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized,
|
|
// `WantedMenu` hosts real `<button>`s) from also firing `Card`'s
|
|
// `onClick` and opening the detail panel.
|
|
import { useState } from "preact/hooks";
|
|
import { Badge } from "@hive/shared/badge.js";
|
|
import type { ProblemDetails } from "@hive/shared/api-error.js";
|
|
import { Card } from "../../ui/card/Card.js";
|
|
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
|
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
|
import { WantedMenu } from "./WantedMenu.js";
|
|
import "./AgentCard.css";
|
|
|
|
export function AgentCard({
|
|
row,
|
|
pending,
|
|
error,
|
|
selected,
|
|
onSelectUp,
|
|
onSelectOffline,
|
|
onSelectPaused,
|
|
onOpenDetail,
|
|
}: {
|
|
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 selection 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;
|
|
onOpenDetail: (row: AgentRow) => void;
|
|
}) {
|
|
const { tone, label } = FRESHNESS[row.freshness];
|
|
return (
|
|
<Card selected={selected} onClick={() => onOpenDetail(row)}>
|
|
<div class="ui-agent-card-row">
|
|
<AgentIcon name={row.name} />
|
|
<div class="ui-agent-card-body">
|
|
<div class="ui-agent-card-line1">
|
|
<span class="ui-agent-card-name">{row.name}</span>
|
|
<Badge
|
|
tone={tone}
|
|
title={
|
|
row.freshness === "unknown"
|
|
? "reported by its hive but not registered in swarm-level identity — needs migration"
|
|
: undefined
|
|
}
|
|
value={
|
|
<>
|
|
{label}
|
|
{row.last_seen_unix !== null ? (
|
|
<>
|
|
{" "}
|
|
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
|
|
</>
|
|
) : null}
|
|
</>
|
|
}
|
|
/>
|
|
<span
|
|
class="ui-agent-card-wanted"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<WantedMenu
|
|
row={row}
|
|
pending={pending}
|
|
showDestroy={false}
|
|
onSelectUp={onSelectUp}
|
|
onSelectOffline={onSelectOffline}
|
|
onSelectPaused={onSelectPaused}
|
|
/>
|
|
{error ? (
|
|
<Badge
|
|
tone="negative"
|
|
value="failed"
|
|
title={error.detail ?? "the declaration failed"}
|
|
/>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
<div class="ui-agent-card-message">
|
|
{row.snapshot?.status_text ?? "—"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// The agent's icon from swarm-controller's `GET /api/agents/{name}/icon`,
|
|
// the same square and fallback as the hive dashboard's container row: an
|
|
// agent with no icon (404) or an unreachable controller shows the dimmed
|
|
// hyperhive mark instead of a broken image. Always an `<img>`: the body is
|
|
// an agent-authored SVG, and only an image load keeps its script from running.
|
|
function AgentIcon({ name }: { name: string }) {
|
|
const [fallback, setFallback] = useState(false);
|
|
return (
|
|
<div
|
|
class={
|
|
fallback
|
|
? "ui-agent-card-icon ui-agent-card-icon-none"
|
|
: "ui-agent-card-icon"
|
|
}
|
|
>
|
|
<img
|
|
alt=""
|
|
src={
|
|
fallback
|
|
? "/favicon.svg"
|
|
: `/api/agents/${encodeURIComponent(name)}/icon`
|
|
}
|
|
onError={() => setFallback(true)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|