From 9224c0bd159c2c136f56b9e77a5a649fb865536c Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 2 Oct 2026 21:20:40 +0200 Subject: [PATCH] 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 --- docs/swarm/ui.md | 11 +- .../swarm-ui/src/pages/agents/AgentsPage.tsx | 42 +++--- .../src/pages/agents/LinkedAccounts.tsx | 94 +++++--------- swarm-controller/README.md | 5 +- swarm-controller/src/linked_accounts.rs | 121 +++--------------- 5 files changed, 78 insertions(+), 195 deletions(-) diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index a9143db2..1129e2fe 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -26,10 +26,9 @@ into the swarm secret store through swarm-controller. All three are blind set/update actions: no route hands a token back. The agent's detail panel lists its linked accounts under **accounts**, one row -per account: kind, name and host. The table view's matrix column lists the -matrix rows. The page fills both from one request, -`GET /api/agents/linked-accounts`, which returns every agent's accounts as -names and hosts, grouped by hive and agent, and never a credential. +per account: kind, name and host. Opening an agent makes one request, +`GET /api/hives/{hive}/agents/{agent}/linked-accounts`, which returns every +account of that agent as names and hosts and never a credential. | kind | one row per | name | host | | ------- | ------------------------------------------- | ----------- | ------------------------ | @@ -38,8 +37,8 @@ names and hosts, grouped by hive and agent, and never a credential. | github | `swarm/agents//github-token`, if any | `github` | `github.com` | The matrix `main` row is the agent's own account, which the swarm mints; -it carries an **own account** badge. The lists refresh when a link dialog -closes. +it carries an **own account** badge. The panel requests the list again when +a link dialog closes. - **link a matrix account** — `PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`, either a pasted bearer token or a user id + password that diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx index 229f5a73..85e624ad 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx @@ -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(null); // The row showing the "link a forge account" dialog, same shape. const [forgeTarget, setForgeTarget] = useState(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 ? ( - - ) : null} - } - 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" - } - /> - + } + 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 ? ( ) : ( "—" diff --git a/frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.tsx b/frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.tsx index 7c7a5028..ecf17eba 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.tsx @@ -1,104 +1,78 @@ // — 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(null); + const [error, setError] = useState(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 } - | { 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({ - 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 ( ); } - if (state.status === "loading") { - return …; - } - 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 …; + if (accounts.length === 0) { return none linked; } return (
    - {shown.map((a) => ( + {accounts.map((a) => (
  • {a.host ?? "—"} diff --git a/swarm-controller/README.md b/swarm-controller/README.md index 180ea412..8b1016c3 100644 --- a/swarm-controller/README.md +++ b/swarm-controller/README.md @@ -32,9 +32,8 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients. GitHub account for one agent, stored in the swarm secret store (`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`, `.../forge-accounts/{label}`, `.../github-account`); distinct from the agent's own swarm-minted - accounts above. `GET /api/agents/linked-accounts` lists them for every agent in one - response, plus each agent's own `main` matrix account, by kind, name and - host, never with a credential. + accounts above. `GET .../linked-accounts` lists them, plus the agent's own + `main` matrix account, by kind, name and host, never with a credential. - **Config PR status** — each agent's open config-repo PR, cached from forge webhooks (`GET /api/config-prs`, `/api/agents/{name}/config-pr`). - **Swarm-wide forge objects and webhooks** → diff --git a/swarm-controller/src/linked_accounts.rs b/swarm-controller/src/linked_accounts.rs index 866a3204..cb820e67 100644 --- a/swarm-controller/src/linked_accounts.rs +++ b/swarm-controller/src/linked_accounts.rs @@ -1,5 +1,5 @@ -//! The accounts linked to each agent, as kind, name and host: what the swarm -//! UI's agents page lists, served for every agent in one response. +//! The accounts linked to one agent, as kind, name and host: what the swarm +//! UI's agent panel lists. //! //! Read from the paths [`crate::matrix_account`], [`crate::forge_account`] and //! [`crate::github_account`] write, plus the agent's own `main` matrix account, @@ -21,7 +21,7 @@ use serde::de::DeserializeOwned; use swarm_secret_client::{Error, SecretStore, forge, github, matrix}; use utoipa::ToSchema; -use super::{AppState, error_problem}; +use super::{AppState, error_problem, swarm_hive}; /// The host shown for a GitHub token. The store keeps none: a token is only /// ever used against github.com. @@ -141,78 +141,36 @@ pub(crate) async fn linked_accounts( Ok(out) } -/// One agent's linked accounts, beside the hive it reported from. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)] -pub struct AgentLinkedAccounts { - /// `None` for an agent that has never reported, as in `/api/agents/status`. - hive: Option, - agent: String, - accounts: Vec, -} - -/// [`linked_accounts`] for each `(hive, agent)`, in the order given. -/// -/// # Errors -/// The first error [`linked_accounts`] returns for any agent. -pub(crate) async fn every_agent( - store: &impl AccountStore, - agents: Vec<(Option, String)>, -) -> Result, Error> { - let mut out = Vec::with_capacity(agents.len()); - for (hive, agent) in agents { - let accounts = linked_accounts(store, &agent).await?; - out.push(AgentLinkedAccounts { - hive, - agent, - accounts, - }); - } - Ok(out) -} - -/// The accounts linked to every agent, one entry per agent `/api/agents/status` -/// returns a row for: names and hosts, never a credential. +/// List the accounts linked to an agent: names and hosts, never a credential. #[utoipa::path( get, - path = "/api/agents/linked-accounts", + path = "/api/hives/{hive}/agents/{agent}/linked-accounts", + params( + ("hive" = String, Path, description = "hive the agent runs on"), + ("agent" = String, Path, description = "agent whose accounts to list"), + ), responses( - (status = 200, description = "one entry per agent, its accounts empty when it has none", body = Vec), - (status = 500, description = "the secret store could not be read (problem+json)", body = String), - (status = 503, description = "no swarm queue or identity bridge is configured here, or either could not be read (problem+json)", body = String), + (status = 200, description = "the agent's linked accounts, empty when it has none", body = Vec), + (status = 400, description = "the agent is not an identifier, or the hive is not in this swarm (problem+json)", body = String), + (status = 500, description = "the store could not be read (problem+json)", body = String), ), tag = "agents" )] pub async fn get_linked_accounts( State(state): State, -) -> Result>, problem_details::ProblemDetails> { - let unavailable = |detail: &str| error_problem(StatusCode::SERVICE_UNAVAILABLE, detail); - let Some(reader) = state.agent_status.as_ref() else { - return Err(unavailable("no swarm queue is configured on this host")); - }; - let Some(bridge) = state.auth.as_ref() else { - return Err(unavailable("no identity bridge is configured on this host")); - }; - let roster = bridge.list_agent_identities().await.map_err(|e| { - let detail = format!("{e:#}"); - tracing::warn!(error = %detail, "reading the agent roster failed"); - unavailable(&detail) - })?; - let rows = reader - .view(&roster, std::time::SystemTime::now()) - .await - .map_err(|e| { - let detail = format!("{e:#}"); - tracing::warn!(error = %detail, "reading the agent-status bucket failed"); - unavailable(&detail) - })?; + axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>, +) -> Result>, problem_details::ProblemDetails> { + let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?; + let agent = hive_types::Ident::parse(&agent) + .map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))? + .into_string(); let store = crate::store::connect().await.map_err(|e| { tracing::warn!(error = %e, "connecting to the swarm secret store failed"); error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) })?; - let agents = rows.into_iter().map(|r| (r.hive, r.name)).collect(); - let accounts = every_agent(&store, agents).await.map_err(|e| { - tracing::warn!(error = %e, "listing linked accounts failed"); + let accounts = linked_accounts(&store, &agent).await.map_err(|e| { + tracing::warn!(%hive, %agent, error = %e, "listing linked accounts failed"); error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) })?; Ok(Json(accounts)) @@ -226,7 +184,7 @@ mod tests { use serde_json::{Value, json}; use swarm_secret_client::Error; - use super::{AccountKind, AccountStore, LinkedAccount, every_agent, linked_accounts}; + use super::{AccountKind, AccountStore, LinkedAccount, linked_accounts}; /// Objects by path. A list answers the next segment of every path under /// the directory, with a trailing `/` when it goes deeper, as the store @@ -376,43 +334,6 @@ mod tests { assert!(text.contains("catgirl"), "{text}"); } - #[tokio::test] - async fn every_agent_is_one_entry_per_hive_and_agent_with_no_credential() { - let got = every_agent( - &every_kind(), - vec![ - (Some("pr1ma".to_owned()), "atlas".to_owned()), - (Some("pr1ma".to_owned()), "red".to_owned()), - (None, "quiet".to_owned()), - ], - ) - .await - .expect("store answers"); - let body = serde_json::to_value(&got).expect("serialises"); - let entries = body.as_array().expect("an array"); - assert_eq!(entries.len(), 3, "{body}"); - for entry in entries { - let mut keys: Vec<&str> = entry - .as_object() - .expect("an object") - .keys() - .map(String::as_str) - .collect(); - keys.sort_unstable(); - assert_eq!(keys, ["accounts", "agent", "hive"], "{entry}"); - } - assert_eq!(body[0]["hive"], "pr1ma"); - assert_eq!(body[0]["agent"], "atlas"); - assert_eq!(body[0]["accounts"].as_array().map(Vec::len), Some(5)); - assert_eq!(body[1]["agent"], "red"); - assert_eq!(body[1]["accounts"][0]["name"], "catgirl"); - assert_eq!(body[2]["hive"], serde_json::Value::Null); - assert_eq!(body[2]["accounts"], serde_json::json!([])); - let text = body.to_string(); - assert!(!text.contains("value"), "{text}"); - assert!(!text.contains("t0k3n"), "{text}"); - } - #[tokio::test] async fn an_agent_with_nothing_stored_lists_nothing() { let got = linked_accounts(&FakeStore::default(), "atlas")