Watch
0
0
Fork
You've already forked hyperhive
0

swarm UI: one request for every agent's linked accounts

GET /api/agents/linked-accounts returns one entry per agent that
/api/agents/status has a row for, as {hive, agent, accounts}, from one
store login. The agents page fetches it once (and again when a link dialog
closes) and hands each table row and the detail panel its agent's slice,
so the page makes no per-agent request. The per-agent route had no caller
left and is removed. The bao grant is unchanged: the same list on each
agent's matrix and forge metadata directories.

Refs #4855
This commit is contained in:
atlas 2026-10-02 20:41:35 +02:00
commit 7ccde4647b
5 changed files with 165 additions and 66 deletions

View file

@ -33,7 +33,7 @@ import { LinkIcon } from "@hive/shared/icons.js";
import { AgentCard } from "./AgentCard.js";
import { AgentTermPreview } from "./AgentTermPreview.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { LinkedAccounts } from "./LinkedAccounts.js";
import { LinkedAccounts, useLinkedAccounts } from "./LinkedAccounts.js";
import { Button } from "../../ui/button/Button.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
@ -159,9 +159,10 @@ export function AgentsPage() {
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
// The row showing the "link a forge account" dialog, same shape.
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
// Bumped whenever a link dialog closes; every `LinkedAccounts` refetches
// Bumped whenever a link dialog closes; `useLinkedAccounts` refetches
// on a change, so a newly linked account shows without a reload.
const [linkVersion, setLinkVersion] = useState(0);
const linked = useLinkedAccounts(linkVersion);
const linkClosed = () => setLinkVersion((v) => v + 1);
const closeMatrix = () => {
setMatrixTarget(null);
@ -395,9 +396,8 @@ export function AgentsPage() {
{a.hive ? (
<LinkedAccounts
key={`${a.hive}/${a.name}`}
hive={a.hive}
state={linked}
agent={a.name}
version={linkVersion}
kinds={["matrix"]}
/>
) : null}
@ -602,9 +602,8 @@ export function AgentsPage() {
{detailTarget.hive ? (
<LinkedAccounts
key={`${detailTarget.hive}/${detailTarget.name}`}
hive={detailTarget.hive}
state={linked}
agent={detailTarget.name}
version={linkVersion}
/>
) : (
"—"

View file

@ -1,13 +1,10 @@
// <LinkedAccounts> — the accounts linked to one agent, one row each: kind,
// name, host. Reads `GET /api/hives/{hive}/agents/{agent}/linked-accounts`,
// which carries names and hosts only, never a credential.
// name, host. Renders its slice of `GET /api/agents/linked-accounts`, which
// `AgentsPage` fetches once for every agent (`useLinkedAccounts`) and which
// carries names and hosts only, never a credential.
//
// Fetched on mount and again whenever `version` changes; `AgentsPage` bumps
// it when a link dialog closes, so an account linked there shows up without
// waiting for a reload.
//
// Its state belongs to one agent: callers key it by hive and agent, so
// switching agents remounts it instead of showing the previous agent's rows.
// Callers key it by hive and agent, so switching agents remounts it instead
// of reusing the previous agent's element.
import { useEffect, useState } from "preact/hooks";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
@ -16,60 +13,83 @@ import "./LinkedAccounts.css";
export type AccountKind = "matrix" | "forgejo" | "github";
// Mirrors `linked_accounts::LinkedAccount`.
interface LinkedAccount {
export interface LinkedAccount {
kind: AccountKind;
name: string;
host: string | null;
reserved: boolean;
}
export function LinkedAccounts({
hive,
agent,
version,
kinds,
}: {
hive: string;
// Mirrors `linked_accounts::AgentLinkedAccounts`.
interface AgentLinkedAccounts {
hive: string | null;
agent: string;
version: number;
/** Only these kinds; all of them when omitted. */
kinds?: AccountKind[];
}) {
const [accounts, setAccounts] = useState<LinkedAccount[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
accounts: LinkedAccount[];
}
// What the page holds: each agent's accounts by agent name (the roster's
// row key), or the error the one fetch failed with.
export type LinkedAccountsState =
| { status: "loading" }
| { status: "loaded"; byAgent: ReadonlyMap<string, LinkedAccount[]> }
| { status: "error"; problem: ProblemDetails };
// The one fetch, re-run whenever `version` changes; `AgentsPage` bumps it
// when a link dialog closes, so an account linked there shows up without a
// reload. Keeps the previous result on screen while a refetch is in flight.
export function useLinkedAccounts(version: number): LinkedAccountsState {
const [state, setState] = useState<LinkedAccountsState>({
status: "loading",
});
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/linked-accounts`,
);
const r = await fetch("/api/agents/linked-accounts");
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
const problem = await readApiError(r);
if (!cancelled) setState({ status: "error", problem });
return;
}
const data = (await r.json()) as LinkedAccount[];
const data = (await r.json()) as AgentLinkedAccounts[];
if (cancelled) return;
setAccounts(data);
setError(null);
setState({
status: "loaded",
byAgent: new Map(data.map((e) => [e.agent, e.accounts])),
});
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
if (!cancelled)
setState({ status: "error", problem: { detail: String(e) } });
});
return () => {
cancelled = true;
};
}, [hive, agent, version]);
}, [version]);
return state;
}
if (error) {
export function LinkedAccounts({
state,
agent,
kinds,
}: {
state: LinkedAccountsState;
agent: string;
/** Only these kinds; all of them when omitted. */
kinds?: AccountKind[];
}) {
if (state.status === "error") {
return (
<Badge
tone="negative"
value="accounts unavailable"
title={error.detail ?? "listing linked accounts failed"}
title={state.problem.detail ?? "listing linked accounts failed"}
/>
);
}
if (accounts === null) return <span class="ui-linked-accounts-muted">…</span>;
if (state.status === "loading") {
return <span class="ui-linked-accounts-muted">…</span>;
}
const accounts = state.byAgent.get(agent) ?? [];
const shown = kinds
? accounts.filter((a) => kinds.includes(a.kind))
: accounts;