swarm UI: show the accounts linked to each agent
GET /api/hives/{hive}/agents/{agent}/linked-accounts returns one row per
account linked to the agent, as kind, name and host: each matrix account
under swarm/agents/<agent>/matrix (with its homeserver, and the agent's own
`main` marked reserved), each forge label under swarm/agents/<agent>/forge
(with its url), and github when swarm/agents/<agent>/github-token exists
(host github.com, which is not stored). No credential field is in the
response type.
Listing those two directories needs a new controller grant: `list` on
secret/metadata/swarm/agents/+/matrix and .../+/forge only, pinned in
bao-grants.nix as the only metadata stanzas under agents/ beside the queue
revocation. Checked against a dev OpenBao 2.6.3: the grant lists those two
directories and is refused on agents/, agents/<agent>/, and a leaf.
The swarm UI agent detail panel shows all rows under "accounts"; the table
view's matrix column shows the matrix rows. The link badges stay.
Refs #4855
This commit is contained in:
parent
6fd91b0e69
commit
3380c1915f
13 changed files with 577 additions and 30 deletions
|
|
@ -5,7 +5,8 @@
|
|||
// `ApiErrorPanel` like `LinkMatrixAccountForm`.
|
||||
//
|
||||
// The label is what the agent passes to `hive-forge -f <label>`. A blind
|
||||
// set/update: no route lists linked accounts, and none hands a token back.
|
||||
// set/update: no route hands a token back. Linked labels and URLs show on
|
||||
// the agent panel (`LinkedAccounts`).
|
||||
import { useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
// 400/500 as `problem+json`, shown via `ApiErrorPanel` like
|
||||
// `LinkForgeAccountForm`.
|
||||
//
|
||||
// One token per agent. A blind set/update: no route says whether a token is
|
||||
// stored, and none hands one back.
|
||||
// One token per agent. A blind set/update: no route hands a token back.
|
||||
// Whether one is stored shows on the agent panel (`LinkedAccounts`).
|
||||
import { useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
|
|
|
|||
|
|
@ -13,10 +13,9 @@
|
|||
// same as every other form here, since the response is `problem+json`
|
||||
// regardless of which arm fired.
|
||||
//
|
||||
// No "linked accounts" list to show first: no route exposes one (a
|
||||
// credential store shouldn't hand a secret back out anyway), so this is
|
||||
// a blind set/update action, not an edit of something already on
|
||||
// screen. That matches "make it 1:1 for now, we will split later" and
|
||||
// A blind set/update action: no route hands a token back, so there is
|
||||
// nothing to edit. What is already linked shows on the agent panel
|
||||
// (`LinkedAccounts`), names and hosts only. That matches "make it 1:1 for now, we will split later" and
|
||||
// "adding them and assigning them should be separate things" — both
|
||||
// mara's rulings on that issue — there's no assignment step here yet,
|
||||
// just the write.
|
||||
|
|
|
|||
|
|
@ -33,6 +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 { Button } from "../../ui/button/Button.js";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
|
|
@ -158,6 +159,22 @@ 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
|
||||
// on a change, so a newly linked account shows without a reload.
|
||||
const [linkVersion, setLinkVersion] = useState(0);
|
||||
const linkClosed = () => setLinkVersion((v) => v + 1);
|
||||
const closeMatrix = () => {
|
||||
setMatrixTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
const closeForge = () => {
|
||||
setForgeTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
const closeGithub = () => {
|
||||
setGithubTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
// Which agent the detail panel shows, *by name* — not the `AgentRow`
|
||||
// object itself. Storing the row would snapshot it at selection time;
|
||||
// `rows` replaces its whole array on every `refresh()` and every
|
||||
|
|
@ -374,18 +391,28 @@ export function AgentsPage() {
|
|||
key: "matrix",
|
||||
header: "matrix",
|
||||
render: (a) => (
|
||||
<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"
|
||||
}
|
||||
/>
|
||||
<>
|
||||
{a.hive ? (
|
||||
<LinkedAccounts
|
||||
hive={a.hive}
|
||||
agent={a.name}
|
||||
version={linkVersion}
|
||||
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"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -569,6 +596,18 @@ export function AgentsPage() {
|
|||
"—"
|
||||
)}
|
||||
</dd>
|
||||
<dt>accounts</dt>
|
||||
<dd>
|
||||
{detailTarget.hive ? (
|
||||
<LinkedAccounts
|
||||
hive={detailTarget.hive}
|
||||
agent={detailTarget.name}
|
||||
version={linkVersion}
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
<div class="ui-agent-detail-actions">
|
||||
<Badge
|
||||
|
|
@ -641,7 +680,7 @@ export function AgentsPage() {
|
|||
</Dialog>
|
||||
<Dialog
|
||||
open={matrixTarget !== null}
|
||||
onClose={() => setMatrixTarget(null)}
|
||||
onClose={closeMatrix}
|
||||
label="link a matrix account"
|
||||
plain
|
||||
>
|
||||
|
|
@ -652,13 +691,13 @@ export function AgentsPage() {
|
|||
<LinkMatrixAccountForm
|
||||
hive={matrixTarget.hive}
|
||||
agent={matrixTarget.name}
|
||||
onClose={() => setMatrixTarget(null)}
|
||||
onClose={closeMatrix}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={forgeTarget !== null}
|
||||
onClose={() => setForgeTarget(null)}
|
||||
onClose={closeForge}
|
||||
label="link a forge account"
|
||||
plain
|
||||
>
|
||||
|
|
@ -666,13 +705,13 @@ export function AgentsPage() {
|
|||
<LinkForgeAccountForm
|
||||
hive={forgeTarget.hive}
|
||||
agent={forgeTarget.name}
|
||||
onClose={() => setForgeTarget(null)}
|
||||
onClose={closeForge}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={githubTarget !== null}
|
||||
onClose={() => setGithubTarget(null)}
|
||||
onClose={closeGithub}
|
||||
label="link a github account"
|
||||
plain
|
||||
>
|
||||
|
|
@ -680,7 +719,7 @@ export function AgentsPage() {
|
|||
<LinkGithubAccountForm
|
||||
hive={githubTarget.hive}
|
||||
agent={githubTarget.name}
|
||||
onClose={() => setGithubTarget(null)}
|
||||
onClose={closeGithub}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
/* <LinkedAccounts> — one account per line: kind/name badge, then host. */
|
||||
.ui-linked-accounts {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.3em;
|
||||
}
|
||||
.ui-linked-accounts li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ui-linked-accounts-host,
|
||||
.ui-linked-accounts-muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
// <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.
|
||||
//
|
||||
// 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.
|
||||
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";
|
||||
|
||||
// Mirrors `linked_accounts::LinkedAccount`.
|
||||
interface LinkedAccount {
|
||||
kind: AccountKind;
|
||||
name: string;
|
||||
host: string | null;
|
||||
reserved: boolean;
|
||||
}
|
||||
|
||||
export function LinkedAccounts({
|
||||
hive,
|
||||
agent,
|
||||
version,
|
||||
kinds,
|
||||
}: {
|
||||
hive: string;
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const r = await fetch(
|
||||
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/linked-accounts`,
|
||||
);
|
||||
if (!r.ok) {
|
||||
if (!cancelled) setError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
const data = (await r.json()) as LinkedAccount[];
|
||||
if (cancelled) return;
|
||||
setAccounts(data);
|
||||
setError(null);
|
||||
})().catch((e: unknown) => {
|
||||
if (!cancelled) setError({ detail: String(e) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hive, agent, version]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Badge
|
||||
tone="negative"
|
||||
value="accounts unavailable"
|
||||
title={error.detail ?? "listing linked accounts failed"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (accounts === null) return <span class="ui-linked-accounts-muted">…</span>;
|
||||
const shown = kinds
|
||||
? accounts.filter((a) => kinds.includes(a.kind))
|
||||
: accounts;
|
||||
if (shown.length === 0) {
|
||||
return <span class="ui-linked-accounts-muted">none linked</span>;
|
||||
}
|
||||
return (
|
||||
<ul class="ui-linked-accounts">
|
||||
{shown.map((a) => (
|
||||
<li key={`${a.kind}/${a.name}`}>
|
||||
<Badge label={a.kind} value={a.name} />
|
||||
<span class="ui-linked-accounts-host">{a.host ?? "—"}</span>
|
||||
{a.reserved ? (
|
||||
<Badge
|
||||
value="own account"
|
||||
title="the agent's own account, which the swarm mints"
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue