From f60aab4717e47c74111259e0f897094e6028c049 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 17 Aug 2026 21:15:07 +0200 Subject: [PATCH] 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 `