Admin UX overhaul: P0 fixes, error-UX foundations, nav cleanup, tenant detail page

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>
This commit is contained in:
jules
2026-07-14 13:43:57 +10:00
parent 938143f3f5
commit 7415b40240
51 changed files with 5923 additions and 4575 deletions

View File

@@ -18,15 +18,13 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import {
AlertBanner,
ConfirmDialog,
EmptyState,
LoadingOverlay,
} from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { useToast } from "@crema/notification-ui"
import { KpiTile, formatCompact } from "@crema/dashboard-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError, ErrorState } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
@@ -54,7 +52,6 @@ import {
import { Textarea } from "~/components/ui/textarea"
import {
searchAdmin,
SearchAdminError,
type CorpusSummary,
type TenantSummary,
} from "~/lib/search-admin"
@@ -74,12 +71,16 @@ type EditorState =
export default function SearchRoute() {
const session = useSession()
const toast = useToast()
const [tenants, setTenants] = useState<TenantSummary[]>([])
const [corpora, setCorpora] = useState<Row[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// arcadia-search is a separate sidecar, so this is often a plain Error /
// TypeError (connection refused) rather than an ArcadiaError. Pass it through
// raw — `describeError` already turns that into "Can't reach arcadia".
const [error, setError] = useState<unknown>(null)
const [corporaError, setCorporaError] = useState<unknown>(null)
const [editor, setEditor] = useState<EditorState>(null)
const [pendingDeleteTenant, setPendingDeleteTenant] = useState<string | null>(
null,
@@ -91,44 +92,42 @@ export default function SearchRoute() {
const [restartConfirm, setRestartConfirm] = useState(false)
const [rebuilding, setRebuilding] = useState<string | null>(null)
const reportError = useCallback((err: unknown, fallback: string) => {
setError(
err instanceof SearchAdminError
? `${err.status}: ${err.message}`
: err instanceof Error
? err.message
: fallback,
)
}, [])
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
setCorporaError(null)
try {
const tRes = await searchAdmin.listTenants()
setTenants(tRes.tenants)
// Fan out per-tenant corpus lookups in parallel.
const cByT = await Promise.all(
tRes.tenants.map(async (t) => {
try {
const r = await searchAdmin.listCorpora(t.id)
return r.corpora
} catch {
return []
}
}),
// Fan out per-tenant corpus lookups in parallel. A tenant whose lookup
// fails no longer disappears silently: if every lookup failed we surface
// the failure instead of rendering "No corpora yet."
const settled = await Promise.allSettled(
tRes.tenants.map((t) => searchAdmin.listCorpora(t.id)),
)
const flat: Row[] = cByT.flat().map((c) => ({
...c,
rowId: `${c.tenant}/${c.corpus}`,
}))
setCorpora(flat)
const ok = settled.filter(
(r): r is PromiseFulfilledResult<{ corpora: CorpusSummary[] }> =>
r.status === "fulfilled",
)
const firstFailure = settled.find((r) => r.status === "rejected")
setCorpora(
ok
.flatMap((r) => r.value.corpora)
.map((c) => ({ ...c, rowId: `${c.tenant}/${c.corpus}` })),
)
if (settled.length > 0 && ok.length === 0 && firstFailure) {
setCorporaError((firstFailure as PromiseRejectedResult).reason)
}
} catch (err) {
reportError(err, "Failed to load search admin state.")
setError(err)
setTenants([])
setCorpora([])
} finally {
setLoading(false)
}
}, [reportError])
}, [])
useEffect(() => {
if (!session) return
@@ -163,20 +162,19 @@ export default function SearchRoute() {
async (tenant: string, corpus: string) => {
const id = `${tenant}/${corpus}`
setRebuilding(id)
setError(null)
try {
const out = await searchAdmin.rebuild(tenant, corpus)
setInfo(
`Rebuilt ${tenant}/${corpus}${out.chunk_count} chunks indexed.`,
)
await refresh()
toast.success(`Rebuilt ${id}`, {
description: `${out.chunk_count} chunks indexed.`,
})
} catch (err) {
reportError(err, "Rebuild failed.")
toast.error(errorMessage(err, `rebuild ${id}`))
} finally {
setRebuilding(null)
}
},
[refresh, reportError],
[refresh, toast],
)
return (
@@ -238,61 +236,69 @@ export default function SearchRoute() {
</div>
</header>
{/* A standing configuration fact, not a load failure — so it isn't an
error state and it isn't dismissible. It stays until it's fixed. */}
{!searchAdmin.hasToken ? (
<AlertBanner variant="warning">
VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset. The Search section will
return 401 until the bearer token is configured. Endpoint:{" "}
<code className="font-mono">{searchAdmin.baseUrl}</code>
</AlertBanner>
<div
role="note"
className="rounded-md border bg-muted/40 px-3 py-2.5 text-sm"
data-action="search-token-missing"
>
<p className="font-medium">Admin token not configured</p>
<p className="text-muted-foreground">
VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset, so every call below will
come back 401. Endpoint:{" "}
<code className="font-mono">{searchAdmin.baseUrl}</code>
</p>
</div>
) : null}
{/* The whole screen hangs off one call to the sidecar. If that call
failed, nothing here loaded — say so once, rather than rendering
three cheerful empty states over a dead connection. */}
{error ? (
<AlertBanner
variant="error"
dismissible
onDismiss={() => setError(null)}
>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner
variant="success"
dismissible
onDismiss={() => setInfo(null)}
>
{info}
</AlertBanner>
) : null}
<Card>
<CardContent className="p-0">
<ErrorState error={error} onRetry={refresh} />
</CardContent>
</Card>
) : (
<>
<Card>
<CardHeader className="flex flex-row flex-wrap items-end gap-3">
<div className="grid grid-cols-3 gap-3 min-w-0">
<KpiTile
label="Tenants"
value={formatCompact(tenants.length)}
/>
<KpiTile
label="Corpora indexed"
value={`${totals.indexed} / ${corpora.length}`}
/>
<KpiTile label="Docs" value={formatCompact(totals.docs)} />
</div>
</CardHeader>
</Card>
<Card>
<CardHeader className="flex flex-row flex-wrap items-end gap-3">
<div className="grid grid-cols-3 gap-3 min-w-0">
<KpiTile
label="Tenants"
value={formatCompact(tenants.length)}
/>
<KpiTile
label="Corpora indexed"
value={`${totals.indexed} / ${corpora.length}`}
/>
<KpiTile label="Docs" value={formatCompact(totals.docs)} />
</div>
</CardHeader>
</Card>
<TenantsCard
tenants={tenants}
loading={loading}
onRetry={refresh}
onDelete={(id) => setPendingDeleteTenant(id)}
/>
<TenantsCard
tenants={tenants}
onDelete={(id) => setPendingDeleteTenant(id)}
/>
<CorporaCard
corpora={corpora}
loading={loading}
rebuildingId={rebuilding}
onRebuild={rebuild}
onEdit={(t, c) => setEditor({ kind: "edit-corpus", tenant: t, corpus: c })}
onDelete={(t, c) => setPendingDeleteCorpus({ tenant: t, corpus: c })}
/>
<CorporaCard
corpora={corpora}
loading={loading}
error={corporaError}
onRetry={refresh}
rebuildingId={rebuilding}
onRebuild={rebuild}
onEdit={(t, c) => setEditor({ kind: "edit-corpus", tenant: t, corpus: c })}
onDelete={(t, c) => setPendingDeleteCorpus({ tenant: t, corpus: c })}
/>
</>
)}
</div>
{/* New tenant */}
@@ -301,10 +307,9 @@ export default function SearchRoute() {
onClose={() => setEditor(null)}
onCreated={async (msg) => {
setEditor(null)
if (msg) setInfo(msg)
await refresh()
toast.success(msg)
}}
onError={(msg) => setError(msg)}
/>
{/* New / edit corpus */}
@@ -318,10 +323,9 @@ export default function SearchRoute() {
onClose={() => setEditor(null)}
onSaved={async (msg) => {
setEditor(null)
if (msg) setInfo(msg)
await refresh()
toast.success(msg)
}}
onError={(msg) => setError(msg)}
/>
{/* Delete tenant */}
@@ -334,14 +338,15 @@ export default function SearchRoute() {
variant="danger"
onConfirm={async () => {
if (!pendingDeleteTenant) return
const id = pendingDeleteTenant
try {
await searchAdmin.deleteTenant(pendingDeleteTenant)
setInfo(`Tenant ${pendingDeleteTenant} deleted.`)
await searchAdmin.deleteTenant(id)
setPendingDeleteTenant(null)
await refresh()
toast.success(`Deleted tenant ${id}`)
} catch (err) {
reportError(err, "Delete failed.")
setPendingDeleteTenant(null)
toast.error(errorMessage(err, `delete tenant ${id}`))
}
}}
/>
@@ -363,12 +368,12 @@ export default function SearchRoute() {
const { tenant, corpus } = pendingDeleteCorpus
try {
await searchAdmin.deleteCorpus(tenant, corpus)
setInfo(`Deleted ${tenant}/${corpus}.`)
setPendingDeleteCorpus(null)
await refresh()
toast.success(`Deleted ${tenant}/${corpus}`)
} catch (err) {
reportError(err, "Delete failed.")
setPendingDeleteCorpus(null)
toast.error(errorMessage(err, `delete ${tenant}/${corpus}`))
}
}}
/>
@@ -385,9 +390,9 @@ export default function SearchRoute() {
setRestartConfirm(false)
try {
await searchAdmin.restart()
setInfo("Restart requested.")
toast.success("Requested a restart of arcadia-search")
} catch (err) {
reportError(err, "Restart request failed.")
toast.error(errorMessage(err, "restart arcadia-search"))
}
}}
/>
@@ -399,9 +404,13 @@ export default function SearchRoute() {
function TenantsCard({
tenants,
loading,
onRetry,
onDelete,
}: {
tenants: TenantSummary[]
loading: boolean
onRetry: () => void
onDelete: (id: string) => void
}) {
return (
@@ -409,14 +418,23 @@ function TenantsCard({
<CardHeader>
<h2 className="text-base font-semibold">Tenants</h2>
</CardHeader>
<CardContent className="p-4">
{tenants.length === 0 ? (
<EmptyState
title="No tenants yet."
description="Create one to start adding corpora."
className="py-8"
/>
) : (
<CardContent className="relative p-4">
{/* The sidecar's own failure is rendered once at page level, so by the
time we get here the load succeeded — this empty state is true. */}
<DataState
loading={loading}
error={null}
isEmpty={tenants.length === 0}
onRetry={onRetry}
loadingLabel="Loading tenants…"
empty={
<EmptyState
title="No tenants yet."
description="Create one to start adding corpora."
className="py-8"
/>
}
>
<ul className="flex flex-wrap gap-2">
{tenants.map((t) => (
<li
@@ -439,7 +457,7 @@ function TenantsCard({
</li>
))}
</ul>
)}
</DataState>
</CardContent>
</Card>
)
@@ -450,6 +468,8 @@ function TenantsCard({
function CorporaCard({
corpora,
loading,
error,
onRetry,
rebuildingId,
onRebuild,
onEdit,
@@ -457,6 +477,8 @@ function CorporaCard({
}: {
corpora: Row[]
loading: boolean
error: unknown
onRetry: () => void
rebuildingId: string | null
onRebuild: (tenant: string, corpus: string) => void
onEdit: (tenant: string, corpus: string) => void
@@ -593,39 +615,40 @@ function CorporaCard({
</div>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay
active={loading && corpora.length === 0}
label="Loading corpora…"
/>
{table.total === 0 && !loading ? (
<EmptyState
icon={<Database className="size-6" />}
title={search ? "No matches." : "No corpora yet."}
description={
search ? "Try a different search." : "Create one above."
}
className="py-12"
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={onRetry}
loadingLabel="Loading corpora…"
empty={
<EmptyState
icon={<Database className="size-6" />}
title={search ? "No matches." : "No corpora yet."}
description={
search ? "Try a different search." : "Create one above."
}
className="py-12"
/>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(r) => r.rowId}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && corpora.length > 0}
stickyHeader
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(r) => r.rowId}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && corpora.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</DataState>
</CardContent>
</Card>
)
@@ -637,31 +660,30 @@ function NewTenantDialog({
open,
onClose,
onCreated,
onError,
}: {
open: boolean
onClose: () => void
onCreated: (msg?: string) => Promise<void>
onError: (msg: string) => void
onCreated: (msg: string) => Promise<void>
}) {
const [id, setId] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) setId("")
if (!open) {
setId("")
setError(null)
}
}, [open])
const submit = async () => {
setError(null)
setSaving(true)
try {
await searchAdmin.createTenant(id)
await onCreated(`Tenant ${id} created.`)
await onCreated(`Created tenant ${id}`)
} catch (err) {
onError(
err instanceof SearchAdminError
? `${err.status}: ${err.message}`
: "Create failed.",
)
setError(err)
} finally {
setSaving(false)
}
@@ -691,6 +713,9 @@ function NewTenantDialog({
data-action="tenant-form-id"
/>
</div>
{error ? <DialogError error={error} context="create the tenant" /> : null}
<DialogFooter>
<Button
variant="outline"
@@ -738,7 +763,6 @@ function CorpusEditor({
tenants,
onClose,
onSaved,
onError,
}: {
editor:
| { kind: "new-corpus"; tenant: string }
@@ -746,13 +770,15 @@ function CorpusEditor({
| null
tenants: TenantSummary[]
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string) => void
onSaved: (msg: string) => Promise<void>
}) {
const [tenant, setTenant] = useState("")
const [text, setText] = useState("")
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
// Covers both the hydrate-on-open failure and the save failure. Either way
// the operator is looking at this dialog, so this is where it has to speak.
const [error, setError] = useState<unknown>(null)
const isEdit = editor?.kind === "edit-corpus"
const headerCorpus = isEdit ? editor.corpus : ""
@@ -761,6 +787,7 @@ function CorpusEditor({
useEffect(() => {
if (!editor) return
setTenant(editor.tenant)
setError(null)
if (editor.kind === "edit-corpus") {
setLoading(true)
searchAdmin
@@ -768,22 +795,17 @@ function CorpusEditor({
.then((res) => {
setText(JSON.stringify(res.config, null, 2))
})
.catch((err) => {
onError(
err instanceof SearchAdminError
? `${err.status}: ${err.message}`
: "Load failed.",
)
})
.catch((err) => setError(err))
.finally(() => setLoading(false))
} else {
setText(CORPUS_CONFIG_TEMPLATE)
}
}, [editor, onError])
}, [editor])
if (!editor) return null
const submit = async () => {
setError(null)
setSaving(true)
try {
const parsed = JSON.parse(text)
@@ -796,19 +818,14 @@ function CorpusEditor({
throw new Error('config must have a string "corpus" field')
}
await searchAdmin.createCorpus(tenant, parsed)
await onSaved(`Created ${tenant}/${corpus}.`)
await onSaved(`Created ${tenant}/${corpus}`)
} else {
await searchAdmin.updateCorpus(editor.tenant, editor.corpus, parsed)
await onSaved(`Updated ${editor.tenant}/${editor.corpus}.`)
await onSaved(`Updated ${editor.tenant}/${editor.corpus}`)
}
} catch (err) {
onError(
err instanceof SearchAdminError
? `${err.status}: ${err.message}`
: err instanceof Error
? err.message
: "Save failed.",
)
// The JSON the operator just wrote stays in the textarea.
setError(err)
} finally {
setSaving(false)
}
@@ -865,6 +882,13 @@ function CorpusEditor({
</div>
</div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the corpus" : "create the corpus"}
/>
) : null}
<DialogFooter>
<Button
variant="outline"
@@ -891,3 +915,5 @@ function CorpusEditor({
</Dialog>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"