frontend: shared ApiErrorPanel component, promote readApiError from credentials.js

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 <hive-warn> 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.
This commit is contained in:
iris 2026-08-17 21:15:07 +02:00 committed by mara
commit f60aab4717
8 changed files with 230 additions and 45 deletions

View file

@ -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);

View file

@ -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/"

View file

@ -0,0 +1,77 @@
// ApiErrorPanel.tsx — <ApiErrorPanel>, 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 `<hive-warn>`, 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 `<style>` empty,
// rendering unstyled with no build error (filed separately as a forge
// issue against swarm-ui's build config). This component
// is self-contained light-DOM instead, so it needs nothing beyond a
// normal `.css` import either build already handles, matching
// `JobqRollup`'s pattern. Colour semantics are still copied from
// `hive-warn.css`'s `level="error"` rule on purpose — same look, no
// shared shadow root.
//
// Same shared-component shape as `JobqRollup`/`JobqGraph`:
// `render(h(ApiErrorPanel, { problem }), container)` from vanilla JS, or
// `<ApiErrorPanel problem={...} />` from swarm-ui's JSX.
import { useState } from 'preact/hooks';
import type { ProblemDetails } from '../api-error.js';
import './api-error-panel.css';
export interface ApiErrorPanelProps {
problem: ProblemDetails;
// Optional short prefix naming what failed ("failed to load the hive
// roster") — the panel is otherwise just the server's own words, which
// don't always say what the caller was trying to do.
context?: string;
}
function formatForCopy(p: ProblemDetails): string {
const lines: string[] = [];
if (p.status !== undefined) lines.push(`status: ${p.status}`);
if (p.title) lines.push(`title: ${p.title}`);
if (p.type) lines.push(`type: ${p.type}`);
if (p.detail) lines.push(`detail: ${p.detail}`);
return lines.length ? lines.join('\n') : 'request failed';
}
export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
const [copied, setCopied] = useState(false);
const heading = problem.title || (problem.status !== undefined ? `http ${problem.status}` : 'request failed');
async function copy() {
try {
await navigator.clipboard.writeText(formatForCopy(problem));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// clipboard permission denied / unavailable (e.g. non-secure
// context) — the text is still fully visible to select by hand,
// so this is a silent no-op rather than a second error to show.
}
}
return (
<div class="api-error-panel" role="alert">
<div class="api-error-heading">
<span class="api-error-title">
{context ? `${context}: ` : ''}
{heading}
</span>
<button type="button" class="api-error-copy" onClick={copy}>
{copied ? 'copied' : 'copy'}
</button>
</div>
{problem.detail ? <p class="api-error-detail">{problem.detail}</p> : null}
</div>
);
}

View file

@ -0,0 +1,55 @@
/* api-error-panel.css `<ApiErrorPanel>`'s own styling, colour + layout
both. Not slotted into `<hive-warn>` (see the component's own header
for why) the border/background/pulse below are deliberately copied
from `hive-warn.css`'s `level="error"` rule rather than invented fresh,
so this still reads as "the same error banner" everywhere it appears,
just declared locally instead of shared through a shadow root. Reuses
theme.css's own documented semantics (`--red` = "errors, fail state"),
same as `hive-warn.css` does. */
.api-error-panel {
display: block;
margin-top: 1em;
margin-bottom: 0.6em;
border: 1px solid var(--red);
border-radius: 4px;
padding: 0.5em 0.8em;
color: var(--red);
background: color-mix(in srgb, var(--red) 8%, transparent);
text-shadow: 0 0 6px color-mix(in srgb, currentColor 40%, transparent);
animation: api-error-panel-pulse 2.4s ease-in-out infinite;
}
@keyframes api-error-panel-pulse {
0%, 100% { box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent); }
50% { box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent); }
}
.api-error-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.8em;
}
.api-error-title {
font-weight: 600;
}
.api-error-copy {
flex: none;
background: transparent;
color: inherit;
border: 1px solid currentColor;
border-radius: 0.3em;
padding: 0.1em 0.6em;
font: inherit;
font-size: 0.85em;
cursor: pointer;
}
.api-error-copy:hover {
background: color-mix(in srgb, currentColor 12%, transparent);
}
.api-error-detail {
margin: 0.5em 0 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.9em;
opacity: 0.9;
}

View file

@ -0,0 +1,61 @@
// api-error.ts — reads a failed fetch `Response` into a `ProblemDetails`
// (RFC 9457, `application/problem+json`) object, shape-agnostically.
//
// Promoted from `dashboard/src/credentials.js`'s original `readErrorBody`,
// which returned a flat message string. This returns the structured object
// instead so a caller — chiefly `ApiErrorPanel` (./api-error-panel/) — can
// render title/status/detail separately and build a useful copy-button
// payload, rather than re-parsing a pre-squashed string.
//
// RFC 9457 is this hive's committed error-body contract for first-party
// APIs (mara: "any api of our own responding with error that is not rfc
// shaped is a bug to be filed against backend") — so the non-JSON
// fallback below is a *compatibility* path, not a standing requirement.
// As of this writing it exists for exactly one known gap: swarm-
// controller's status route, being converted in a separate in-flight fix.
// Once every first-party route emits problem+json, the raw-text path only
// ever fires against a genuine bug (worth filing, not silently absorbing).
export interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
}
export async function readApiError(resp: Response): Promise<ProblemDetails> {
const status = resp.status;
let raw: string;
try {
raw = (await resp.text()).trim();
} catch {
return { status };
}
if (raw && (raw[0] === '{' || raw[0] === '[')) {
try {
const body = JSON.parse(raw) as Record<string, unknown>;
return {
type: typeof body.type === 'string' ? body.type : undefined,
title: typeof body.title === 'string' ? body.title : undefined,
status: typeof body.status === 'number' ? body.status : status,
detail:
typeof body.detail === 'string'
? body.detail
: typeof body.error === 'string'
? body.error
: undefined,
};
} catch {
// not JSON after all — fall through to the raw-text path below
}
}
return { status, detail: raw || undefined };
}
// One-line message for a context that only has room for a single string
// (a form's inline result line, a toast) rather than the full panel —
// same fallback order `readErrorBody` used: detail (or its `error` alias,
// already folded in above) → title → a bare status line.
export function problemMessage(p: ProblemDetails): string {
return p.detail || p.title || (p.status !== undefined ? `http ${p.status}` : 'request failed');
}

View file

@ -49,6 +49,3 @@
.create-agent-result-ok {
color: var(--green);
}
.create-agent-result-error {
color: var(--red);
}

View file

@ -23,6 +23,8 @@
// comment states.
import { useState } from 'preact/hooks';
import { Link } from 'wouter-preact';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Panel } from '../ui/panel/Panel.js';
import './CreateAgentPage.css';
@ -34,7 +36,7 @@ type SubmitState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'done'; nodeId: number }
| { status: 'error'; message: string };
| { status: 'error'; problem: ProblemDetails };
// Client-side mirror of `hive_types::Ident` (1-63 chars of `[a-z0-9-]`) —
// a `pattern` hint only, not a substitute for the server's own
@ -55,12 +57,15 @@ export function CreateAgentPage() {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!r.ok) throw new Error((await r.text()) || `http ${r.status}`);
if (!r.ok) {
setResult({ status: 'error', problem: await readApiError(r) });
return;
}
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: 'done', nodeId: data.node_id });
setName('');
} catch (err) {
setResult({ status: 'error', message: String(err) });
setResult({ status: 'error', problem: { detail: String(err) } });
}
}
@ -96,9 +101,7 @@ export function CreateAgentPage() {
</p>
)}
{result.status === 'error' && (
<p class="create-agent-result create-agent-result-error">
failed to queue: {result.message}
</p>
<ApiErrorPanel context="failed to queue" problem={result.problem} />
)}
</Panel>
);

View file

@ -8,6 +8,8 @@
// than living in `App.tsx`, matching `JobsPage`'s shape: `App.tsx` is
// routing, a page owns its own fetch + render.
import { useEffect, useState } from 'preact/hooks';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Panel } from '../ui/panel/Panel.js';
import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
@ -60,21 +62,22 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
export function OverviewPage() {
const [hives, setHives] = useState<HiveStatus[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
useEffect(() => {
fetch('/api/hives/status')
.then((r) => {
if (!r.ok) throw new Error(`http ${r.status}`);
return r.json() as Promise<HiveStatus[]>;
})
.then(setHives)
.catch((e: unknown) => setError(String(e)));
(async () => {
const r = await fetch('/api/hives/status');
if (!r.ok) {
setError(await readApiError(r));
return;
}
setHives((await r.json()) as HiveStatus[]);
})().catch((e: unknown) => setError({ detail: String(e) }));
}, []);
return (
<Panel title="overview">
{error ? <p>failed to load the hive roster: {error}</p> : null}
{error ? <ApiErrorPanel context="failed to load the hive roster" problem={error} /> : null}
{!error && hives === null ? <p>loading</p> : null}
{hives ? <Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> : null}
</Panel>