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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user