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

@@ -10,10 +10,13 @@ import {
Wifi,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
@@ -77,30 +80,59 @@ export default function NetworkingRoute() {
const [floatingIps, setFloatingIps] = useState<FloatingIp[]>([])
const [droplets, setDroplets] = useState<Droplet[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// One error per tab. These endpoints legitimately 503 when DigitalOcean isn't
// configured on a deployment — which must read as "arcadia hit a server
// error", never as the flat lie "No firewalls." A single Promise.all also
// used to mean one 503 wiped all four tabs; allSettled keeps them apart.
const [firewallsError, setFirewallsError] = useState<unknown>(null)
const [vpcsError, setVpcsError] = useState<unknown>(null)
const [domainsError, setDomainsError] = useState<unknown>(null)
const [floatingIpsError, setFloatingIpsError] = useState<unknown>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const [f, v, d, fi, dr] = await Promise.all([
listFirewalls(arcadia),
listVpcs(arcadia),
listDomains(arcadia),
listFloatingIps(arcadia),
listDroplets(arcadia),
])
setFirewalls(f)
setVpcs(v)
setDomains(d)
setFloatingIps(fi)
setDroplets(dr)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load networking.")
} finally {
setLoading(false)
setFirewallsError(null)
setVpcsError(null)
setDomainsError(null)
setFloatingIpsError(null)
const [f, v, d, fi, dr] = await Promise.allSettled([
listFirewalls(arcadia),
listVpcs(arcadia),
listDomains(arcadia),
listFloatingIps(arcadia),
listDroplets(arcadia),
])
if (f.status === "fulfilled") setFirewalls(f.value)
else {
setFirewalls([])
setFirewallsError(f.reason)
}
if (v.status === "fulfilled") setVpcs(v.value)
else {
setVpcs([])
setVpcsError(v.reason)
}
if (d.status === "fulfilled") setDomains(d.value)
else {
setDomains([])
setDomainsError(d.reason)
}
if (fi.status === "fulfilled") setFloatingIps(fi.value)
else {
setFloatingIps([])
setFloatingIpsError(fi.reason)
}
// Droplets only populate the "assign to" picker; the picker already says
// "No droplets" when it's empty, so a failure needs no error surface here.
setDroplets(dr.status === "fulfilled" ? dr.value : [])
setLoading(false)
}, [arcadia])
useEffect(() => {
@@ -137,17 +169,6 @@ export default function NetworkingRoute() {
</Button>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Tabs defaultValue="firewalls">
<TabsList>
<TabsTrigger value="firewalls" data-action="networking-tab-firewalls">
@@ -168,22 +189,25 @@ export default function NetworkingRoute() {
<FirewallsPanel
firewalls={firewalls}
loading={loading}
error={firewallsError}
onChanged={refresh}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
<TabsContent value="vpcs" className="pt-4">
<VpcsPanel vpcs={vpcs} loading={loading} />
<VpcsPanel
vpcs={vpcs}
loading={loading}
error={vpcsError}
onRetry={refresh}
/>
</TabsContent>
<TabsContent value="domains" className="pt-4">
<DomainsPanel
domains={domains}
loading={loading}
onError={setError}
onInfo={setInfo}
error={domainsError}
onChanged={refresh}
/>
</TabsContent>
@@ -193,9 +217,8 @@ export default function NetworkingRoute() {
ips={floatingIps}
droplets={droplets}
loading={loading}
error={floatingIpsError}
onChanged={refresh}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
</Tabs>
@@ -209,48 +232,41 @@ export default function NetworkingRoute() {
function FirewallsPanel({
firewalls,
loading,
error,
onChanged,
onError,
onInfo,
}: {
firewalls: Firewall[]
loading: boolean
error: unknown
onChanged: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [pendingDelete, setPendingDelete] = useState<Firewall | null>(null)
if (loading && firewalls.length === 0) {
return (
<Card>
<CardContent className="relative py-8">
<LoadingOverlay active label="Loading firewalls…" />
</CardContent>
</Card>
)
}
if (firewalls.length === 0) {
return (
<Card>
<CardContent>
<EmptyState
icon={<Shield className="size-6" />}
title="No firewalls."
description="Create a firewall on your provider, or configure DigitalOcean access in arcadia's .env to see existing ones."
className="py-8"
/>
</CardContent>
</Card>
)
}
return (
<>
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{firewalls.map((f) => (
<DataState
loading={loading}
error={error}
isEmpty={firewalls.length === 0}
onRetry={onChanged}
loadingLabel="Loading firewalls…"
empty={
<Card>
<CardContent>
<EmptyState
icon={<Shield className="size-6" />}
title="No firewalls."
description="Create a firewall on your provider, or configure DigitalOcean access in arcadia's .env to see existing ones."
className="py-8"
/>
</CardContent>
</Card>
}
>
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{firewalls.map((f) => (
<Card key={String(f.id)}>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2">
@@ -273,8 +289,9 @@ function FirewallsPanel({
{f.droplet_ids?.length ?? 0}
</CardContent>
</Card>
))}
</ul>
))}
</ul>
</DataState>
<ConfirmDialog
open={pendingDelete !== null}
@@ -289,14 +306,15 @@ function FirewallsPanel({
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const name = pendingDelete.name
try {
await deleteFirewall(arcadia, pendingDelete.id)
setPendingDelete(null)
onInfo("Firewall deleted.")
await onChanged()
toast.success(`Deleted firewall ${name}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete firewall ${name}`))
}
}}
/>
@@ -306,52 +324,59 @@ function FirewallsPanel({
// --- VPCs panel --------------------------------------------------------
function VpcsPanel({ vpcs, loading }: { vpcs: Vpc[]; loading: boolean }) {
if (loading && vpcs.length === 0) {
return (
<Card>
<CardContent className="relative py-8">
<LoadingOverlay active label="Loading VPCs" />
</CardContent>
</Card>
)
}
if (vpcs.length === 0) {
return (
<Card>
<CardContent>
<EmptyState
icon={<Network className="size-6" />}
title="No VPCs."
description="Read-only view; create VPCs on your provider directly."
className="py-8"
/>
</CardContent>
</Card>
)
}
function VpcsPanel({
vpcs,
loading,
error,
onRetry,
}: {
vpcs: Vpc[]
loading: boolean
error: unknown
onRetry: () => void
}) {
return (
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{vpcs.map((v) => (
<Card key={v.id}>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Network className="size-4 text-muted-foreground" />
<CardTitle className="text-base">{v.name}</CardTitle>
{v.default ? <Badge>default</Badge> : null}
</div>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
<div>
Region: <code className="font-mono">{v.region ?? ""}</code>
</div>
<div>
IP range: <code className="font-mono">{v.ip_range ?? ""}</code>
</div>
<DataState
loading={loading}
error={error}
isEmpty={vpcs.length === 0}
onRetry={onRetry}
loadingLabel="Loading VPCs"
empty={
<Card>
<CardContent>
<EmptyState
icon={<Network className="size-6" />}
title="No VPCs."
description="Read-only view; create VPCs on your provider directly."
className="py-8"
/>
</CardContent>
</Card>
))}
</ul>
}
>
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{vpcs.map((v) => (
<Card key={v.id}>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Network className="size-4 text-muted-foreground" />
<CardTitle className="text-base">{v.name}</CardTitle>
{v.default ? <Badge>default</Badge> : null}
</div>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
<div>
Region: <code className="font-mono">{v.region ?? ""}</code>
</div>
<div>
IP range: <code className="font-mono">{v.ip_range ?? ""}</code>
</div>
</CardContent>
</Card>
))}
</ul>
</DataState>
)
}
@@ -360,20 +385,22 @@ function VpcsPanel({ vpcs, loading }: { vpcs: Vpc[]; loading: boolean }) {
function DomainsPanel({
domains,
loading,
onError,
onInfo,
error,
onChanged,
}: {
domains: Domain[]
loading: boolean
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
error: unknown
onChanged: () => Promise<void>
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [selectedName, setSelectedName] = useState<string>(() => domains[0]?.name ?? "")
const [records, setRecords] = useState<DnsRecord[]>([])
const [loadingRecords, setLoadingRecords] = useState(false)
// The record list loads separately from the domain list, so it carries its
// own error: a 500 on records must not claim the domain has no records.
const [recordsError, setRecordsError] = useState<unknown>(null)
const [createOpen, setCreateOpen] = useState(false)
const [pendingDelete, setPendingDelete] = useState<DnsRecord | null>(null)
@@ -387,44 +414,47 @@ function DomainsPanel({
setRecords([])
return
}
setRecordsError(null)
setLoadingRecords(true)
try {
setRecords(await listDnsRecords(arcadia, name))
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Failed to load DNS records.")
setRecords([])
setRecordsError(err)
} finally {
setLoadingRecords(false)
}
},
[arcadia, onError],
[arcadia],
)
useEffect(() => {
loadRecords(selectedName)
}, [selectedName, loadRecords])
if (loading && domains.length === 0) {
if (loading || error || domains.length === 0) {
return (
<Card>
<CardContent className="relative py-8">
<LoadingOverlay active label="Loading domains" />
</CardContent>
</Card>
)
}
if (domains.length === 0) {
return (
<Card>
<CardContent>
<EmptyState
icon={<Globe className="size-6" />}
title="No domains."
description="Add a domain on your provider; arcadia surfaces it here for record management."
className="py-8"
/>
</CardContent>
</Card>
<DataState
loading={loading}
error={error}
isEmpty={domains.length === 0}
onRetry={onChanged}
loadingLabel="Loading domains"
empty={
<Card>
<CardContent>
<EmptyState
icon={<Globe className="size-6" />}
title="No domains."
description="Add a domain on your provider; arcadia surfaces it here for record management."
className="py-8"
/>
</CardContent>
</Card>
}
>
{null}
</DataState>
)
}
@@ -470,14 +500,21 @@ function DomainsPanel({
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{records.length === 0 && !loadingRecords ? (
<EmptyState
icon={<Globe className="size-6" />}
title="No records on this domain."
className="py-8"
/>
) : (
<CardContent className="relative p-0">
<DataState
loading={loadingRecords}
error={recordsError}
isEmpty={records.length === 0}
onRetry={() => loadRecords(selectedName)}
loadingLabel="Loading DNS records"
empty={
<EmptyState
icon={<Globe className="size-6" />}
title="No records on this domain."
className="py-8"
/>
}
>
<ul className="divide-y border-y">
{records.map((r) => (
<li key={String(r.id)} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
@@ -505,20 +542,19 @@ function DomainsPanel({
</li>
))}
</ul>
)}
</DataState>
</CardContent>
<DnsCreateDialog
open={createOpen}
domainName={selectedName}
onClose={() => setCreateOpen(false)}
onCreated={async () => {
onCreated={async (label) => {
setCreateOpen(false)
onInfo("DNS record created.")
await loadRecords(selectedName)
await onChanged()
toast.success(`Created ${label}`)
}}
onError={onError}
/>
<ConfirmDialog
@@ -534,14 +570,15 @@ function DomainsPanel({
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const label = `${pendingDelete.type} ${pendingDelete.name}`
try {
await deleteDnsRecord(arcadia, selectedName, pendingDelete.id)
setPendingDelete(null)
onInfo("Record deleted.")
await loadRecords(selectedName)
toast.success(`Deleted ${label}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${label}`))
}
}}
/>
@@ -554,13 +591,11 @@ function DnsCreateDialog({
domainName,
onClose,
onCreated,
onError,
}: {
open: boolean
domainName: string
onClose: () => void
onCreated: () => Promise<void>
onError: (msg: string | null) => void
onCreated: (label: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [type, setType] = useState("A")
@@ -569,6 +604,7 @@ function DnsCreateDialog({
const [ttl, setTtl] = useState("3600")
const [priority, setPriority] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) {
@@ -577,11 +613,12 @@ function DnsCreateDialog({
setData("")
setTtl("3600")
setPriority("")
setError(null)
}
}, [open])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
await createDnsRecord(arcadia, domainName, {
@@ -591,9 +628,10 @@ function DnsCreateDialog({
ttl: ttl ? Number(ttl) : undefined,
priority: priority ? Number(priority) : undefined,
})
await onCreated()
await onCreated(`${type} ${name} → ${data}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Create failed.")
// A rejected record (bad target, duplicate name) is fixable right here.
setError(err)
} finally {
setSaving(false)
}
@@ -679,8 +717,15 @@ function DnsCreateDialog({
) : null}
</div>
{error ? <DialogError error={error} context="create the record" /> : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
<Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="dns-form-cancel"
>
Cancel
</Button>
<Button onClick={submit} disabled={saving || !data} data-action="dns-form-save">
@@ -699,47 +744,37 @@ function FloatingIpsPanel({
ips,
droplets,
loading,
error,
onChanged,
onError,
onInfo,
}: {
ips: FloatingIp[]
droplets: Droplet[]
loading: boolean
error: unknown
onChanged: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [assigning, setAssigning] = useState<{ ip: string; dropletId: string } | null>(null)
if (loading && ips.length === 0) {
return (
<Card>
<CardContent className="relative py-8">
<LoadingOverlay active label="Loading floating IPs" />
</CardContent>
</Card>
)
}
if (ips.length === 0) {
return (
<Card>
<CardContent>
<EmptyState
icon={<Wifi className="size-6" />}
title="No floating IPs."
description="Reserve a floating IP on your provider to surface it here."
className="py-8"
/>
</CardContent>
</Card>
)
}
return (
<Card>
<CardContent className="p-0">
<CardContent className="relative p-0">
<DataState
loading={loading}
error={error}
isEmpty={ips.length === 0}
onRetry={onChanged}
loadingLabel="Loading floating IPs"
empty={
<EmptyState
icon={<Wifi className="size-6" />}
title="No floating IPs."
description="Reserve a floating IP on your provider to surface it here."
className="py-8"
/>
}
>
<ul className="divide-y border-y">
{ips.map((ip) => {
const region =
@@ -764,12 +799,10 @@ function FloatingIpsPanel({
onClick={async () => {
try {
await unassignFloatingIp(arcadia, ip.ip)
onInfo("Floating IP unassigned.")
await onChanged()
toast.success(`Unassigned ${ip.ip}`)
} catch (err) {
onError(
err instanceof ArcadiaError ? err.message : "Unassign failed.",
)
toast.error(errorMessage(err, `unassign ${ip.ip}`))
}
}}
data-action={`fip-${ip.ip}-unassign`}
@@ -809,14 +842,17 @@ function FloatingIpsPanel({
}
onClick={async () => {
if (!assigning || assigning.ip !== ip.ip) return
const dropletName =
droplets.find((d) => String(d.id) === assigning.dropletId)
?.name ?? assigning.dropletId
try {
await assignFloatingIp(arcadia, ip.ip, assigning.dropletId)
setAssigning(null)
onInfo("Floating IP assigned.")
await onChanged()
toast.success(`Assigned ${ip.ip} to ${dropletName}`)
} catch (err) {
onError(
err instanceof ArcadiaError ? err.message : "Assign failed.",
toast.error(
errorMessage(err, `assign ${ip.ip} to ${dropletName}`),
)
}
}}
@@ -831,7 +867,10 @@ function FloatingIpsPanel({
)
})}
</ul>
</DataState>
</CardContent>
</Card>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"