From the 2026-07-14 UI/UX audit (17/40). Four phases: P1 — Settings route crashed on Agents→Edit (Input/Textarea used but never imported). Added imports + a shared route-level error boundary (components/route-error.tsx) re-exported from all shell routes, so one crashing panel degrades to an explained card with the nav intact instead of replacing the whole app with a stack trace. Corrected the stale CLAUDE.md claim that `npm run typecheck` crashes — it works, and would have caught the missing import. P2 — Error/empty/feedback foundations. Fixed useSession identity churn that fired ~3x duplicate fetches per screen and self-inflicted 429s (referentially stable snapshot). New lib/errors.ts (describeError → plain-language + the fix) and components/data-state.tsx (DataState renders exactly one of error/loading/empty/content, so a failed load never shows as "empty"; DialogError for in-dialog failures; 429 auto-retry). Rolled across all 15 list routes; every mutation now toasts. Surfaced+fixed two silent-failure bugs (sso + buckets-CORS swallowed load errors; the latter could wipe rules on save). P3 — Nav IA + trust cleanup. Default-expanded rail; regrouped into 7 coherent sections; collapsed the Apps/Plan/Entitlements stub triplication into one honest /billing page; deleted fabricated seeded notifications and the dead Help menu item; fixed the 403 copy (referenced a tenant switcher that doesn't exist); removed orphan /assistant + /library routes; gated dev-seed login hints behind DEV; renamed /activity → /audit-log with a redirect. P4 — Tenant detail page (routes/tenants.$id.tsx + components/tenant-detail/*), closing the provision→configure gap. 8 tabs (Overview, Plan & quotas, Branding, Localization, Email & SMS, Feature flags, IP rules, Inbound webhooks), each verified saving to the real backend. Row name links to detail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
135 lines
4.3 KiB
TypeScript
135 lines
4.3 KiB
TypeScript
// One place that turns whatever the API threw into something an operator can
|
|
// act on. Before this existed, every list screen surfaced the raw status text
|
|
// ("Internal Server Error", "Too Many Requests", "Bad Request") — which names
|
|
// the failure but never the fix — and, worse, rendered its empty state *next
|
|
// to* the error, so "the load failed" and "there is nothing here" looked
|
|
// identical. An operator can't tell an empty audit log from a broken one.
|
|
|
|
import { ArcadiaError } from "@crema/arcadia-core-client"
|
|
|
|
export type LoadError = {
|
|
/** Plain-language headline. Never a raw HTTP status. */
|
|
title: string
|
|
/** What to do about it. Empty when there's genuinely nothing to suggest. */
|
|
detail: string
|
|
status?: number
|
|
/** Set for 429s — seconds until it's worth retrying. Drives auto-retry. */
|
|
retryAfterSec?: number
|
|
/** True when retrying might plausibly work (5xx, 429, network). */
|
|
retryable: boolean
|
|
/** Field-level validation messages, flattened from Ecto's error tree. */
|
|
fields?: string[]
|
|
}
|
|
|
|
/** Flatten Ecto's nested `{tenant: {slug: ["has already been taken"]}}`. */
|
|
function flattenFieldErrors(details: unknown): string[] {
|
|
const lines: string[] = []
|
|
const walk = (obj: unknown, prefix: string) => {
|
|
if (Array.isArray(obj)) {
|
|
lines.push(prefix ? `${prefix}: ${obj.join(", ")}` : obj.join(", "))
|
|
} else if (obj && typeof obj === "object") {
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
walk(v, prefix ? `${prefix}.${k}` : k)
|
|
}
|
|
}
|
|
}
|
|
walk(details, "")
|
|
return lines
|
|
}
|
|
|
|
export function describeError(err: unknown, context = "load"): LoadError {
|
|
// Network / CORS / server down — fetch rejects before any status exists.
|
|
if (err instanceof TypeError || (err instanceof Error && /fetch/i.test(err.message))) {
|
|
return {
|
|
title: "Can't reach arcadia",
|
|
detail:
|
|
"The API didn't respond. Check the service is running and that this host is allowed to call it, then retry.",
|
|
retryable: true,
|
|
}
|
|
}
|
|
|
|
if (!(err instanceof ArcadiaError)) {
|
|
return {
|
|
title: `Couldn't ${context}`,
|
|
detail: err instanceof Error && err.message ? err.message : "An unexpected error occurred.",
|
|
retryable: true,
|
|
}
|
|
}
|
|
|
|
const fields = err.details ? flattenFieldErrors(err.details) : undefined
|
|
|
|
switch (true) {
|
|
case err.status === 401:
|
|
return {
|
|
title: "Your session has expired",
|
|
detail: "Sign in again to continue.",
|
|
status: 401,
|
|
retryable: false,
|
|
}
|
|
|
|
case err.status === 403:
|
|
return {
|
|
title: "You don't have access to this",
|
|
detail:
|
|
"Your account lacks the role this screen needs. A platform administrator can grant it.",
|
|
status: 403,
|
|
retryable: false,
|
|
}
|
|
|
|
case err.status === 404:
|
|
return {
|
|
title: "Not found",
|
|
detail: "It may have been deleted, or the endpoint isn't available on this deployment.",
|
|
status: 404,
|
|
retryable: false,
|
|
}
|
|
|
|
case err.status === 422:
|
|
return {
|
|
title: "That didn't validate",
|
|
detail: fields?.length ? "" : err.message,
|
|
status: 422,
|
|
retryable: false,
|
|
fields,
|
|
}
|
|
|
|
case err.status === 429:
|
|
return {
|
|
title: "Too many requests",
|
|
detail: "arcadia is rate-limiting this console. It'll retry automatically.",
|
|
status: 429,
|
|
retryAfterSec: 30,
|
|
retryable: true,
|
|
}
|
|
|
|
case err.status >= 500:
|
|
return {
|
|
title: "arcadia hit a server error",
|
|
detail: `The request failed on the server${
|
|
err.requestId ? ` (request ${err.requestId})` : ""
|
|
}. Retry, and if it persists check the service logs.`,
|
|
status: err.status,
|
|
retryable: true,
|
|
}
|
|
|
|
default:
|
|
return {
|
|
title: `Couldn't ${context}`,
|
|
// Prefer the server's own message over the bare status line.
|
|
detail: fields?.length ? "" : err.message,
|
|
status: err.status,
|
|
retryable: err.status >= 500,
|
|
fields,
|
|
}
|
|
}
|
|
}
|
|
|
|
/** One-line form, for toasts and inside dialogs. */
|
|
export function errorMessage(err: unknown, context = "save"): string {
|
|
const d = describeError(err, context)
|
|
const parts = [d.title]
|
|
if (d.fields?.length) parts.push(d.fields.join("; "))
|
|
else if (d.detail) parts.push(d.detail)
|
|
return parts.join(" — ")
|
|
}
|