swarm UI: fetch linked accounts only for the opened agent
The detail panel makes one request for the agent it shows,
GET /api/hives/{hive}/agents/{agent}/linked-accounts, which returns every
matrix, forge and github account of that agent as names and hosts. The
all-agents route and the table's matrix-column rows are removed, so the
table makes no linked-accounts request. The panel stays keyed by
hive/agent. The bao grant is unchanged.
Refs #4855
This commit is contained in:
parent
7ccde4647b
commit
9224c0bd15
5 changed files with 78 additions and 195 deletions
|
|
@ -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, useLinkedAccounts } from "./LinkedAccounts.js";
|
||||
import { LinkedAccounts } 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,10 +159,9 @@ 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; `useLinkedAccounts` refetches
|
||||
// Bumped whenever a link dialog closes; the panel's `LinkedAccounts` 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);
|
||||
|
|
@ -392,28 +391,18 @@ export function AgentsPage() {
|
|||
key: "matrix",
|
||||
header: "matrix",
|
||||
render: (a) => (
|
||||
<>
|
||||
{a.hive ? (
|
||||
<LinkedAccounts
|
||||
key={`${a.hive}/${a.name}`}
|
||||
state={linked}
|
||||
agent={a.name}
|
||||
kinds={["matrix"]}
|
||||
/>
|
||||
) : null}
|
||||
<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"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<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"
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -602,8 +591,9 @@ export function AgentsPage() {
|
|||
{detailTarget.hive ? (
|
||||
<LinkedAccounts
|
||||
key={`${detailTarget.hive}/${detailTarget.name}`}
|
||||
state={linked}
|
||||
hive={detailTarget.hive}
|
||||
agent={detailTarget.name}
|
||||
version={linkVersion}
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
|
|
|
|||
|
|
@ -1,104 +1,78 @@
|
|||
// <LinkedAccounts> — the accounts linked to one agent, one row each: kind,
|
||||
// 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.
|
||||
// name, host. Reads `GET /api/hives/{hive}/agents/{agent}/linked-accounts`,
|
||||
// which carries names and hosts only, never a credential.
|
||||
//
|
||||
// Callers key it by hive and agent, so switching agents remounts it instead
|
||||
// of reusing the previous agent's element.
|
||||
// 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.
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import "./LinkedAccounts.css";
|
||||
|
||||
export type AccountKind = "matrix" | "forgejo" | "github";
|
||||
type AccountKind = "matrix" | "forgejo" | "github";
|
||||
|
||||
// Mirrors `linked_accounts::LinkedAccount`.
|
||||
export interface LinkedAccount {
|
||||
interface LinkedAccount {
|
||||
kind: AccountKind;
|
||||
name: string;
|
||||
host: string | null;
|
||||
reserved: boolean;
|
||||
}
|
||||
|
||||
// Mirrors `linked_accounts::AgentLinkedAccounts`.
|
||||
interface AgentLinkedAccounts {
|
||||
hive: string | null;
|
||||
export function LinkedAccounts({
|
||||
hive,
|
||||
agent,
|
||||
version,
|
||||
}: {
|
||||
hive: string;
|
||||
agent: string;
|
||||
accounts: LinkedAccount[];
|
||||
}
|
||||
version: number;
|
||||
}) {
|
||||
const [accounts, setAccounts] = useState<LinkedAccount[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
|
||||
// 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/agents/linked-accounts");
|
||||
const r = await fetch(
|
||||
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/linked-accounts`,
|
||||
);
|
||||
if (!r.ok) {
|
||||
const problem = await readApiError(r);
|
||||
if (!cancelled) setState({ status: "error", problem });
|
||||
if (!cancelled) setError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
const data = (await r.json()) as AgentLinkedAccounts[];
|
||||
const data = (await r.json()) as LinkedAccount[];
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
status: "loaded",
|
||||
byAgent: new Map(data.map((e) => [e.agent, e.accounts])),
|
||||
});
|
||||
setAccounts(data);
|
||||
setError(null);
|
||||
})().catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setState({ status: "error", problem: { detail: String(e) } });
|
||||
if (!cancelled) setError({ detail: String(e) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [version]);
|
||||
return state;
|
||||
}
|
||||
}, [hive, agent, version]);
|
||||
|
||||
export function LinkedAccounts({
|
||||
state,
|
||||
agent,
|
||||
kinds,
|
||||
}: {
|
||||
state: LinkedAccountsState;
|
||||
agent: string;
|
||||
/** Only these kinds; all of them when omitted. */
|
||||
kinds?: AccountKind[];
|
||||
}) {
|
||||
if (state.status === "error") {
|
||||
if (error) {
|
||||
return (
|
||||
<Badge
|
||||
tone="negative"
|
||||
value="accounts unavailable"
|
||||
title={state.problem.detail ?? "listing linked accounts failed"}
|
||||
title={error.detail ?? "listing linked accounts failed"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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;
|
||||
if (shown.length === 0) {
|
||||
if (accounts === null) return <span class="ui-linked-accounts-muted">…</span>;
|
||||
if (accounts.length === 0) {
|
||||
return <span class="ui-linked-accounts-muted">none linked</span>;
|
||||
}
|
||||
return (
|
||||
<ul class="ui-linked-accounts">
|
||||
{shown.map((a) => (
|
||||
{accounts.map((a) => (
|
||||
<li key={`${a.kind}/${a.name}`}>
|
||||
<Badge label={a.kind} value={a.name} />
|
||||
<span class="ui-linked-accounts-host">{a.host ?? "—"}</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue