From 1d9a06482a5dd8b1a1fb577742a9e63be808883b Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 17 Aug 2026 18:39:34 +0200 Subject: [PATCH 1/8] docs(setup): point operator forge/matrix account creation at swarm SSO --- docs/setup.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index 9af3819f..d5ec447c 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -21,16 +21,19 @@ operator has to place, and where. ### 1 · Forge ```bash -# Provision (or refresh) ruth's own forge account — do this first +# Provision (or refresh) ruth's own forge account — do this first. Ruth's +# bootstrap bypasses the normal spawn-approval flow (see step 6), so unlike +# every other agent it does not get its forge account auto-provisioned — +# this manual step is still load-bearing. hivectl forge create-user ruth -# Create a human operator account (prints the token to stdout) -hivectl forge create-user mara --password hunter2 - -# Provision forge accounts for any sub-agents spawned later -hivectl forge create-user +# Sub-agents spawned later (via the approval flow in step 6) get their +# forge accounts auto-provisioned — nothing to run here for them. ``` +The human operator's own forge account is created via swarm SSO instead of +a manual `hivectl` step — see step 3 (`swarmctl user add`). + ### 2 · Gateway (HTTP Basic auth) ```bash @@ -89,20 +92,21 @@ control: [`swarm/ui.md`](swarm/ui.md). # 5a. Ensure the hive-internal admin account exists first hivectl matrix sync-admin -# 5b. Provision ruth's own matrix account +# 5b. Provision ruth's own matrix account — same bootstrap-bypass reasoning +# as forge above, still a required manual step. hivectl matrix create-user ruth -# 5c. Create a human matrix account -hivectl matrix create-user mara --password hunter2 - -# 5d. Invite the operator to the hive Space (and optionally to rooms) +# 5c. Invite the operator to the hive Space (and optionally to rooms) hivectl matrix invite mara hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' -# 5e. Promote the operator to homeserver admin if needed +# 5d. Promote the operator to homeserver admin if needed hivectl matrix promote-user mara ``` +The human operator's own matrix account is created via swarm SSO instead of +a manual `hivectl` step — see step 3 (`swarmctl user add`). + ### 6 · Spawn sub-agents Sub-agent creation goes through the approval queue — ruth proposes, the From 58588a6866659430be5539e4954963de943f38cd Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 17 Aug 2026 21:06:56 +0200 Subject: [PATCH 2/8] fix(#3412): swarm-controller answers errors as RFC 9457 problem+json Its three error paths returned a bare string with a status code, which forces a caller to treat the whole body as prose. hive-c0re converted some time ago, so swarm-controller was the last backend on the old shape -- and it is the one behind the hive status page's 503, where the body is frequently the entire diagnosis rather than a summary. Adds the commitment to docs/conventions.md, since it was implied by the code in one daemon and written down nowhere: an endpoint of ours answering with a bare string is a bug to file, not something callers work around. The test asserts the rendered response -- media type plus an addressable detail -- rather than the problem_details value, because a handler that built the value correctly and then returned it as a string would satisfy a test written against the type alone. --- Cargo.lock | 1 + docs/conventions.md | 22 +++++++++++ swarm-controller/Cargo.toml | 4 ++ swarm-controller/src/main.rs | 71 ++++++++++++++++++++++++++++++++---- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 041b08a9..edbb6a33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4591,6 +4591,7 @@ dependencies = [ "hive-jobq", "hive-jobq-wire", "hive-types", + "problem_details", "reqwest 0.13.1", "serde", "serde_json", diff --git a/docs/conventions.md b/docs/conventions.md index bbc97f2f..c19fedfe 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -318,6 +318,28 @@ refactor's concern. The dashboard frontend parses via `util.js::epochSec` wherever it needs arithmetic and feeds the string straight to `new Date(s)` for display. +### HTTP error bodies + +Every HTTP API in this repo answers failures with **RFC 9457 +`application/problem+json`** (`{ type, title, status, detail }`), with the +human-readable cause in `detail`. An endpoint of ours returning a bare string +or a bespoke error shape is a **bug to file against the backend**, not +something for the caller to work around. + +Use the `problem_details` crate (`features = ["axum"]`), which the daemons +already depend on: type a handler `Result<_, ProblemDetails>` and hand +`ProblemDetails::from_status_code(...).with_detail(...)` to `Err`. + +The reason is the consumer, not tidiness. The UIs show errors through one +shared component with a copy button, so a caller has to know **which part of +the body is the message**. A bare string forces it to treat the whole payload +as prose, which is the difference between offering "copy the cause" and +dumping a response — and the cause is frequently the entire diagnosis (a +JetStream permission refusal, a TLS chain failure) rather than a summary. + +Not in scope: the `hivectl` host-admin and in-agent unix sockets. Those are a +JSON-line protocol with their own result types; RFC 9457 is an HTTP format. + ## Tool groups The MCP tool surface an agent receives is derived from a set of named diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index aad95ae4..6ac33c60 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -27,6 +27,10 @@ forgejo-api.workspace = true # raw bytes. base64.workspace = true futures-util.workspace = true +# RFC 9457 `application/problem+json` error bodies. Same version + `axum` +# feature as hive-c0re: the two daemons answer the same operator UIs, so a +# reader that handles one's failures has to handle the other's. +problem_details = { version = "0.9.0", features = ["axum"] } # The graph itself, held directly rather than behind a c0re-style wrapper # module — that layering (`hive-c0re::job_queue`) is partially legacy (predates # `hive-jobq`'s extraction into its own crate) and this daemon does not need it diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 26aa08fd..f0a9841d 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -428,14 +428,29 @@ async fn get_links(State(state): State) -> Json> { /// body because a bare 503 on an operator-facing diagnostic is how a /// misconfiguration costs an afternoon; it is a queue/JetStream error /// string, and this surface is already behind the swarm's SSO. +/// +/// It travels in `detail` of an RFC 9457 `application/problem+json` body +/// rather than as a bare string, so the cause is an addressable field +/// instead of the whole payload — see `error_problem` below. struct StatusUnavailable(String); impl axum::response::IntoResponse for StatusUnavailable { fn into_response(self) -> axum::response::Response { - (axum::http::StatusCode::SERVICE_UNAVAILABLE, self.0).into_response() + error_problem(axum::http::StatusCode::SERVICE_UNAVAILABLE, &self.0).into_response() } } +/// Every error this daemon returns, in one shape. +/// +/// RFC 9457 `application/problem+json` is the hive-wide contract for HTTP +/// error bodies (`docs/conventions.md`), and the operator UIs read `detail` +/// for display. A bare string forces the reader to treat the entire body as +/// the message, which is the difference between a UI that can offer "copy the +/// cause" and one that can only dump a response. +fn error_problem(status: axum::http::StatusCode, detail: &str) -> problem_details::ProblemDetails { + problem_details::ProblemDetails::from_status_code(status).with_detail(detail) +} + /// What each hive last said about itself, read from the swarm queue at /// request time. /// @@ -522,17 +537,17 @@ struct CreateAgentResponse { request_body = CreateAgentRequest, responses( (status = 200, description = "job chain queued", body = CreateAgentResponse), - (status = 400, description = "`name` is not a valid identifier", body = String), - (status = 500, description = "the job chain could not be queued", body = String), + (status = 400, description = "`name` is not a valid identifier (problem+json)", body = String), + (status = 500, description = "the job chain could not be queued (problem+json)", body = String), ), tag = "agents" )] async fn create_agent( State(state): State, Json(req): Json, -) -> Result, (axum::http::StatusCode, String)> { +) -> Result, problem_details::ProblemDetails> { let agent = hive_types::Ident::parse(&req.name) - .map_err(|reason| (axum::http::StatusCode::BAD_REQUEST, reason.to_owned()))? + .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))? .into_string(); let repo = agent.clone(); @@ -562,7 +577,12 @@ async fn create_agent( .after_ok(create_repo); vec![create_identity.guid()] }) - .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + .map_err(|e| { + error_problem( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + &e.to_string(), + ) + })?; let [id] = ids[..] else { unreachable!("exactly one handle was asked for"); }; @@ -770,11 +790,46 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { use super::{ - DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, SwarmNodeKind, WorkerDeps, - load_hives, load_links, run_swarm_node, + DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, StatusUnavailable, + SwarmNodeKind, WorkerDeps, load_hives, load_links, run_swarm_node, }; use std::path::Path; + /// The 503 this route returns is what the operator UIs' shared error + /// component renders, so assert the RENDERED response rather than the + /// `problem_details` crate: the contract a UI depends on is the content + /// type plus a `detail` it can address, and a handler that built the + /// value and returned it as a bare string would satisfy any test + /// written against the type alone. + #[tokio::test] + async fn status_unavailable_renders_problem_json_with_the_cause_in_detail() { + use axum::response::IntoResponse as _; + + let cause = "listing status bucket keys: timed out"; + let resp = StatusUnavailable(cause.to_owned()).into_response(); + + assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE); + let ct = resp + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned(); + assert!( + ct.starts_with("application/problem+json"), + "RFC 9457 media type, got {ct:?}" + ); + + let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024) + .await + .expect("body reads"); + let v: serde_json::Value = serde_json::from_slice(&bytes).expect("problem+json parses"); + assert_eq!(v["status"], 503); + // The cause is an addressable field, not the entire payload — that + // distinction is the point of the change, so it is what is asserted. + assert_eq!(v["detail"], cause); + } + /// Drives `SwarmNodeKind::CreateRepo` through the real /// `hive_jobq::scheduler::Scheduler` claim → run → complete path, /// rather than only through `create_agent`'s endpoint test (there From 346a6b1b4ce488e9de3c3be1dc0b8442f9646e68 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 17 Aug 2026 21:12:27 +0200 Subject: [PATCH 3/8] fix(#3393): render an email for every swarm user, synthesised when none was given Authelia serves no `email` claim for a user without one, and a relying party that wants that claim does not degrade -- grafana falls through to `/emails`, a GitHub-ism authelia does not implement, and the login dies with InternalError naming nothing useful. So an absent address is a broken login rather than a sparse profile. Uses `@hyperhive.local`, the domain hive-c0re already gives every agent's forge account and every hyperhive-authored commit. Not deployment- derived: that would have to be plumbed in from config, and an operator already supplying a domain may as well supply the whole address. Synthesised in the renderer, never in the store: users.json stays honest that none was supplied, an operator who later sets a real one is not fighting an invented value, and existing users are fixed by the next render with no migration step. A supplied address always wins. The old test asserting an absent email is omitted pinned exactly the behaviour that broke the login; it is split so the group half keeps its meaning and the email half states the new contract. --- swarmctl/src/users.rs | 94 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 9 deletions(-) diff --git a/swarmctl/src/users.rs b/swarmctl/src/users.rs index 400d89a3..a423d10d 100644 --- a/swarmctl/src/users.rs +++ b/swarmctl/src/users.rs @@ -122,6 +122,37 @@ fn quote(s: &str) -> String { out } +/// Domain for an address this crate invents. +/// +/// The same one `hive-c0re` already gives every agent's forge account +/// (`forge::users::agent_email`) and every hyperhive-authored git commit. A +/// deployment-derived domain was considered and rejected: it would have to be +/// passed in from config, and an operator who is already supplying a domain +/// may as well supply the whole address — while a *second* convention for +/// synthetic identities is a thing to keep in sync forever. +/// +/// Never routable, and that is correct rather than a compromise. Nothing +/// sends mail here; the address exists so that a relying party asking for an +/// `email` claim gets one. +const SYNTHETIC_EMAIL_DOMAIN: &str = "hyperhive.local"; + +/// The address a user with no explicit one is rendered as. +/// +/// Every user needs an email in the rendered file, because a relying party +/// that asks for the `email` claim and gets nothing does not degrade — it +/// fails. Grafana's OIDC login is the measured case: with no email claim it +/// falls through to `/emails`, a GitHub-ism authelia does not +/// implement, and the login dies with `InternalError` rather than anything +/// naming the missing field. +/// +/// Synthesised in the **renderer**, never written to the store: `users.json` +/// stays honest that no address was supplied, so an operator who later sets a +/// real one is not fighting a value swarmctl invented, and every existing user +/// is fixed by the next render with no migration step. +fn synthetic_email(username: &str) -> String { + format!("{username}@{SYNTHETIC_EMAIL_DOMAIN}") +} + /// Render the store as authelia's block-style YAML users database. /// /// Fallible because it re-runs validation over every value it is about to @@ -142,10 +173,15 @@ pub fn render_yaml(store: &UserStore) -> Result { writeln!(out, " {name}:")?; writeln!(out, " displayname: {}", quote(&user.displayname))?; writeln!(out, " password: {}", quote(&user.password))?; - if let Some(email) = &user.email { - reject_control_chars("email", email)?; - writeln!(out, " email: {}", quote(email))?; - } + // Unconditional: an absent email is the failure mode, not a valid + // rendering. The synthetic address is validated on the same path as + // a supplied one so neither can smuggle a control character. + let email = match &user.email { + Some(supplied) => supplied.clone(), + None => synthetic_email(name), + }; + reject_control_chars("email", &email)?; + writeln!(out, " email: {}", quote(&email))?; if !user.groups.is_empty() { writeln!(out, " groups:")?; for group in &user.groups { @@ -325,21 +361,61 @@ mod tests { } #[test] - fn optional_fields_are_omitted_rather_than_emitted_empty() { + fn an_empty_group_list_is_omitted_rather_than_emitted_empty() { let mut store = UserStore::default(); store.users.insert("mara".to_owned(), user("$argon2id$x")); let out = render_yaml(&store).expect("renders"); - assert!( - !out.contains("email"), - "absent email must not appear:\n{out}" - ); assert!( !out.contains("groups"), "an empty group list must not appear:\n{out}" ); } + /// Email is deliberately NOT in the test above any more. It used to + /// assert that an absent one is omitted, which pinned the behaviour that + /// broke grafana's login: authelia serves no `email` claim, and a relying + /// party that wants one fails rather than degrading. + #[test] + fn a_user_with_no_email_still_renders_one_from_the_shared_domain() { + let mut store = UserStore::default(); + store.users.insert("mara".to_owned(), user("$argon2id$x")); + + let out = render_yaml(&store).expect("renders"); + assert!( + out.contains(r#"email: "mara@hyperhive.local""#), + "a user with no email must still render one:\n{out}" + ); + } + + #[test] + fn a_supplied_email_is_never_replaced_by_the_synthetic_one() { + let mut u = user("$argon2id$x"); + u.email = Some("real@elsewhere.example".to_owned()); + let mut store = UserStore::default(); + store.users.insert("mara".to_owned(), u); + + let out = render_yaml(&store).expect("renders"); + assert!( + out.contains(r#"email: "real@elsewhere.example""#), + "the supplied address must win:\n{out}" + ); + assert!( + !out.contains("mara@hyperhive.local"), + "the synthetic address must not also appear:\n{out}" + ); + } + + /// The synthetic address goes through the same validation as a supplied + /// one. A username is already constrained to `[A-Za-z0-9._-]`, so this + /// cannot currently fail — which is exactly why it is worth pinning: the + /// day username rules loosen, the renderer must still refuse rather than + /// quietly emit whatever it built. + #[test] + fn the_synthetic_address_is_built_from_the_username_and_domain() { + assert_eq!(synthetic_email("mara"), "mara@hyperhive.local"); + } + #[test] fn groups_render_as_a_block_sequence() { let mut u = user("$argon2id$x"); From bfe921c2540094d1bf05c7fa7ae6e6ef0b21bf06 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 17 Aug 2026 21:15:18 +0200 Subject: [PATCH 4/8] feat(#3405): declarative grafana plugins for the swarm dashboard Plugin management is server-admin scoped, and on an SSO hive nobody holds that role: auto_assign_org_role grants an org role, and the built-in local admin that does hold server admin cannot log in because the login form is disabled whenever SSO is configured. Two individually-correct decisions leaving no path to the plugin UI at all. Declarative is the way through rather than a workaround for it -- plugins land in the store and in git, survive a rebuild and a state reset, and the container needs no runtime egress to grafana.com. mkIf rather than passing the list through: upstream's default is null, while an empty list is a real value pointing the plugin path at an empty store dir, so a hive that sets nothing must keep seeing null. --- nix/host-modules/swarm-grafana.nix | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 878f4b1f..79a99907 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -174,6 +174,30 @@ in ''; }; }; + + plugins = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + example = lib.literalExpression "[ pkgs.grafanaPlugins.grafana-piechart-panel ]"; + description = '' + Grafana plugins to install, as packages. Declarative rather than + installed through the UI, which is the only shape that works here: + plugin management is **server-admin** scoped, and on an SSO hive + nobody holds that role — `users.auto_assign_org_role` grants an + *org* role, and the built-in local admin that does hold server + admin cannot log in because the login form is disabled whenever + SSO is configured. + + That is a deliberate pair of decisions rather than an oversight, + and this option is the way through it: plugins live in the store + and in git, so they survive a container rebuild and a state reset, + and the container needs no runtime egress to grafana.com. + + Empty by default, which leaves grafana's own plugin handling + untouched. Setting it takes over the plugin directory entirely — + anything installed by other means stops being visible. + ''; + }; }; config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { @@ -437,6 +461,25 @@ in enable = true; package = cfg.package; + # Passed through unconditionally, empty default included. + # Upstream distinguishes `null` from `[ ]`, and both differences + # favour always handing it a list: + # + # - `null` points the plugin path at grafana's mutable + # `/plugins`; any list points it at a store path. + # Switching on the day someone adds their first plugin would + # bury that change inside an unrelated one. + # - upstream defaults its plugin update-check to + # `declarativePlugins == null`, so a list also stops the + # container phoning grafana.com. That is the no-runtime-egress + # property this option exists for — it should not arrive only + # once a plugin happens to be listed. + # + # Nothing is taken over by claiming the directory on a hive with no + # plugins: manual installation is already impossible here (see the + # option's description), so there is nothing in it to lose. + declarativePlugins = cfg.plugins; + settings = { server = { # Already upstream's default (measured), but pinned rather From f60aab4717e47c74111259e0f897094e6028c049 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 17 Aug 2026 21:15:07 +0200 Subject: [PATCH 5/8] frontend: shared ApiErrorPanel component, promote readApiError from credentials.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3410. `ApiErrorPanel` renders a ProblemDetails (RFC 9457) error nicely, with a copy button so the full text can be pasted straight into a bug report. No truncation of `detail` — on the #3363 incident that motivated this issue, that string was the entire diagnosis. `readApiError`/`problemMessage`/`ProblemDetails` are promoted out of credentials.js's original `readErrorBody` into `@hive/shared/api-error.js` (comment rewritten: the RFC 9457 rework has already landed everywhere except swarm-controller's status route, #3412 in flight, so the raw-text fallback is a compat shim for that one gap, not a general transition). credentials.js's 4 call sites switch to the shared reader (kept as one-line messages there, its result slots are single-line aria-live regions, not a panel context). Wired ApiErrorPanel into OverviewPage.tsx (the issue's own worked example) and CreateAgentPage.tsx (second real call site). Not built on despite matching its visual language — that custom element's CSS-as-text import only works under a build with loader: 'text' for .css (dashboard's), and silently renders unstyled under swarm-ui's default css loader (filed separately as #3415). ApiErrorPanel is a self-contained light-DOM component instead, per mara's own suggestion to keep it independent of the old UI's shapes. --- .../packages/dashboard/src/credentials.js | 38 +++------ frontend/packages/shared/package.json | 5 +- .../src/api-error-panel/ApiErrorPanel.tsx | 77 +++++++++++++++++++ .../src/api-error-panel/api-error-panel.css | 55 +++++++++++++ frontend/packages/shared/src/api-error.ts | 61 +++++++++++++++ .../swarm-ui/src/pages/CreateAgentPage.css | 3 - .../swarm-ui/src/pages/CreateAgentPage.tsx | 15 ++-- .../swarm-ui/src/pages/OverviewPage.tsx | 21 ++--- 8 files changed, 230 insertions(+), 45 deletions(-) create mode 100644 frontend/packages/shared/src/api-error-panel/ApiErrorPanel.tsx create mode 100644 frontend/packages/shared/src/api-error-panel/api-error-panel.css create mode 100644 frontend/packages/shared/src/api-error.ts diff --git a/frontend/packages/dashboard/src/credentials.js b/frontend/packages/dashboard/src/credentials.js index 5d2420c3..4ad106ec 100644 --- a/frontend/packages/dashboard/src/credentials.js +++ b/frontend/packages/dashboard/src/credentials.js @@ -23,6 +23,7 @@ import { $, esc, fmtAgeSecs, renderServerWarnings } from './common.js'; import { el } from '@hive/shared/dom.js'; import '@hive/shared/hive-tab-strip.js'; import { themedConfirm, themedToast } from '@hive/shared/modal.js'; +import { readApiError, problemMessage } from '@hive/shared/api-error.js'; let agents = []; // agent name → container running (bool), from /api/state. Cross-referenced by @@ -65,28 +66,13 @@ function renderAgentPicker() { for (const a of agents) sel.append(el('option', { value: a }, a)); } -// ─── shape-agnostic error-body parsing (shared by both tabs' submit -// handlers) ─────────────────────────────────────────────────────────────── -// The BE error-body shape is in transition: today hive-c0re's -// error_response sends a bare plain-text body; the RFC 9457 rework moves it -// to application/problem+json ({ type, title, detail, … }). Read shape- -// agnostically: pull the body once as text, and if it parses as JSON -// surface `detail` (problem+json) → `error`/`title` fallback, else use the -// raw text. A bare HTTP code is the last resort. -async function readErrorBody(resp) { - try { - const raw = (await resp.text()).trim(); - if (raw && (raw[0] === '{' || raw[0] === '[')) { - try { - const body = JSON.parse(raw); - return body.detail || body.error || body.title || raw; - } catch { /* not JSON after all — keep the raw text */ } - } - return raw; - } catch { - return ''; - } -} +// Shape-agnostic error-body parsing (shared by both tabs' submit handlers) +// lives in `@hive/shared/api-error.js` now — `readApiError` + +// `problemMessage` (this page only needs the one-line message, not the +// full `ApiErrorPanel`; its result lines are single-line `aria-live` +// regions, not a swap-in-a-panel context). Was a local function here +// originally; promoted so swarm-ui shares the same +// shape-agnostic reader instead of each side maintaining its own copy. // ─── MATRIX tab ──────────────────────────────────────────────────────────── // Live status dot — the daemon heartbeats every ~30s (advances as_of_unix), @@ -253,7 +239,7 @@ async function submitLogin(e) { clearSecrets(formEl); } } else { - const msg = await readErrorBody(resp); + const msg = problemMessage(await readApiError(resp)); out.className = 'ma-result err'; out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')')); clearSecrets(formEl); @@ -337,7 +323,7 @@ async function submitGithub(e) { clearSecrets(formEl); } } else { - const msg = await readErrorBody(resp); + const msg = problemMessage(await readApiError(resp)); out.className = 'ma-result err'; out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')')); clearSecrets(formEl); @@ -419,7 +405,7 @@ async function onForgeRemoveClick(agent, forge, btn) { loadForgeAccounts(agent); return; } - const msg = await readErrorBody(resp); + const msg = problemMessage(await readApiError(resp)); btn.textContent = orig; btn.disabled = false; themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' }); @@ -474,7 +460,7 @@ async function submitForgeAccount(e) { clearSecrets(formEl); } } else { - const msg = await readErrorBody(resp); + const msg = problemMessage(await readApiError(resp)); out.className = 'ma-result err'; out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')')); clearSecrets(formEl); diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index ba195859..8584935c 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -31,7 +31,10 @@ "./jobq-graph.js": "./src/jobq-graph/JobqGraph.tsx", "./jobq-graph.css": "./src/jobq-graph/jobq-graph.css", "./jobq-rollup.js": "./src/jobq-rollup/JobqRollup.tsx", - "./jobq-rollup.css": "./src/jobq-rollup/jobq-rollup.css" + "./jobq-rollup.css": "./src/jobq-rollup/jobq-rollup.css", + "./api-error.js": "./src/api-error.ts", + "./api-error-panel.js": "./src/api-error-panel/ApiErrorPanel.tsx", + "./api-error-panel.css": "./src/api-error-panel/api-error-panel.css" }, "files": [ "src/" diff --git a/frontend/packages/shared/src/api-error-panel/ApiErrorPanel.tsx b/frontend/packages/shared/src/api-error-panel/ApiErrorPanel.tsx new file mode 100644 index 00000000..8034b42f --- /dev/null +++ b/frontend/packages/shared/src/api-error-panel/ApiErrorPanel.tsx @@ -0,0 +1,77 @@ +// ApiErrorPanel.tsx — , the shared "show the operator why +// an API call failed" component (mara, on the issue that asked for this: +// "the error display component is for showing ProblemDetails in a nicer +// way with copy button [...] wherever we want to show an error, this +// component should be used"). Renders a `ProblemDetails` (./api-error.ts) +// — heading + the full `detail` text, unclamped (it can be a raw NATS/ +// JetStream error, and on the incident that prompted this component +// *that string was the entire diagnosis* — truncating it defeats the +// point) — plus a copy button for pasting straight into a bug report. +// +// Deliberately NOT built on ``, despite matching its visual +// language, because that custom element's CSS-as-text import only +// resolves under a build with `loader: 'text'` for `.css` (dashboard's); +// swarm-ui's default `css` loader leaves its shadow `