Files
arcadia-admin/app/routes/status-page.tsx
jules 0fcb9e40f1 Add Buckets, Monitoring, Memberships, Networking, SSO, Announcements, Status page
Full set of admin surfaces on top of /platform/* and /admin/* endpoints,
plus a migration of /assistant onto @crema/llm-providers-ui.

Buckets (/buckets):
  S3-level CRUD over /platform/buckets — list, create, delete (with the
  6-digit confirmation flow the backend enforces), per-bucket configure
  for versioning / CORS rules / policy JSON, plus an object browser
  with FileGrid/FileList from @crema/file-ui and presigned-URL reveal.
  Storage-config picker scopes the view to one credential at a time.

Monitoring (/monitoring):
  Live dashboard. Service health board derived from indirect signals
  (status-ui OverallStatus + ComponentRow). KPI tiles for sessions,
  jobs, audit. Tabs: background jobs (Donut + BarChart + retry recent),
  sessions (Sparkline of last 24h sign-ins), audit activity (BarChart
  of severity / top resource types), infrastructure (DO summary +
  WorldMapSvg coloured by droplet region + droplet list + Spaces),
  rate limits. 30s auto-refresh.

Memberships (/memberships):
  M:N glue between users and tenants over /admin/memberships. Add /
  edit / suspend / activate / remove with role multi-select.

Networking (/networking):
  Tabs over /platform/{firewalls,vpcs,domains,floating_ips}.
  Read/delete on firewalls, read on VPCs, full DNS-record CRUD, and
  inline assign/unassign for floating IPs.

SSO (/sso):
  /sso/identity-providers CRUD with PEM cert as write-only field, plus
  /sso/sessions list with destroy.

Announcements (/announcements):
  /admin/announcements CRUD. Platform-wide vs per-tenant audience,
  schedule windows, dismissible + active toggles.

Status page (/status-page):
  /admin/status-page/{components,incidents,subscribers}. Components
  CRUD, incidents with timeline + post-update + resolve flow,
  subscriber list. Public preview at the top using StatusBoard +
  IncidentTimeline from @crema/status-ui.

Assistant migration:
  /assistant now uses @crema/llm-providers-ui (provider catalog +
  vault key resolution) instead of ~/lib/llm-settings. Same async
  buildAdapter() flow used by /ai. The legacy lib file is now
  unreferenced and can be removed when ready.

New sibling libs wired (cloned from CremaUIStudio):
  lib-file-ui, lib-card-ui, lib-dashboard-ui, lib-chart-ui,
  lib-map-ui, lib-status-ui.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:55:46 +10:00

1049 lines
33 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
AlertTriangle,
CheckCircle2,
Mail,
Plus,
RefreshCw,
Trash2,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import {
IncidentTimeline,
StatusBoard,
type ComponentState,
type Severity,
type StatusComponent as StatusUiComponent,
type StatusIncident,
} from "@crema/status-ui"
import { AppShell } from "~/components/layout/app-shell"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import { Textarea } from "~/components/ui/textarea"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
import {
addIncidentUpdate,
createComponent,
createIncident,
deleteComponent,
listComponents,
listIncidents,
listSubscribers,
resolveIncident,
updateComponent,
updateIncident,
type ComponentInput,
type ComponentStatus,
type Incident,
type IncidentImpact,
type IncidentInput,
type IncidentStatus,
type StatusComponent,
type Subscriber,
} from "~/lib/arcadia/status-page"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { useRegisterAdminContext } from "~/lib/admin-context"
export const meta = () => pageTitle("Status page")
const STATUSES: ComponentStatus[] = [
"operational",
"degraded_performance",
"partial_outage",
"major_outage",
"maintenance",
]
const INCIDENT_STATUSES: IncidentStatus[] = ["investigating", "identified", "monitoring", "resolved"]
const IMPACTS: IncidentImpact[] = ["none", "minor", "major", "critical"]
type ComponentEditor =
| { kind: "create" }
| { kind: "edit"; component: StatusComponent }
| null
type IncidentEditor =
| { kind: "create" }
| { kind: "edit"; incident: Incident }
| { kind: "update"; incident: Incident }
| null
export default function StatusPageRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const [components, setComponents] = useState<StatusComponent[]>([])
const [incidents, setIncidents] = useState<Incident[]>([])
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const [c, i, s] = await Promise.all([
listComponents(arcadia).catch(() => [] as StatusComponent[]),
listIncidents(arcadia).catch(() => [] as Incident[]),
listSubscribers(arcadia).catch(() => [] as Subscriber[]),
])
setComponents(c)
setIncidents(i)
setSubscribers(s)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load status page.")
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
useRegisterAdminContext("status_page", {
components: components.length,
open_incidents: incidents.filter((i) => i.status !== "resolved").length,
subscribers: subscribers.length,
})
// Map arcadia component statuses to status-ui ComponentState.
const uiComponents: StatusUiComponent[] = useMemo(
() =>
components.map((c) => ({
id: c.id,
name: c.name,
description: c.description ?? undefined,
state: arcadiaToUiComponentState(c.status),
})),
[components],
)
const uiIncidents: StatusIncident[] = useMemo(
() =>
incidents.map((i) => ({
id: i.id,
title: i.title,
severity: impactToSeverity(i.impact),
startedAt: new Date(i.inserted_at),
resolvedAt: i.resolved_at ? new Date(i.resolved_at) : undefined,
affectedComponentIds: i.components.map((c) => c.id),
updates: i.updates.map((u) => ({
id: u.id,
at: new Date(u.inserted_at),
status: u.status as StatusIncident["updates"][number]["status"],
body: u.body,
})),
})),
[incidents],
)
const componentsById = useMemo(
() => new Map(uiComponents.map((c) => [c.id, c])),
[uiComponents],
)
if (!session) {
return (
<AppShell title="Status page">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Status page admin requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/status-page">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Status page">
<div className="flex flex-col gap-4 p-6">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Status page</h1>
<p className="text-sm text-muted-foreground">
Public-facing status board: components, incidents, email subscribers.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="status-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
{/* Live preview using the public-facing widget */}
{uiComponents.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Public preview</CardTitle>
<CardDescription>What subscribers see right now.</CardDescription>
</CardHeader>
<CardContent>
<StatusBoard components={uiComponents} />
{uiIncidents.length > 0 ? (
<div className="mt-4">
<IncidentTimeline
incidents={uiIncidents.slice(0, 5)}
componentsById={componentsById}
/>
</div>
) : null}
</CardContent>
</Card>
) : null}
<Tabs defaultValue="components">
<TabsList>
<TabsTrigger value="components" data-action="status-tab-components">
Components ({components.length})
</TabsTrigger>
<TabsTrigger value="incidents" data-action="status-tab-incidents">
Incidents ({incidents.length})
</TabsTrigger>
<TabsTrigger value="subscribers" data-action="status-tab-subscribers">
Subscribers ({subscribers.length})
</TabsTrigger>
</TabsList>
<TabsContent value="components" className="pt-4">
<ComponentsPanel
components={components}
loading={loading}
onChanged={refresh}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
<TabsContent value="incidents" className="pt-4">
<IncidentsPanel
incidents={incidents}
components={components}
loading={loading}
onChanged={refresh}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
<TabsContent value="subscribers" className="pt-4">
<SubscribersPanel subscribers={subscribers} loading={loading} />
</TabsContent>
</Tabs>
</div>
</AppShell>
)
}
function arcadiaToUiComponentState(s: ComponentStatus): ComponentState {
switch (s) {
case "operational":
return "operational"
case "degraded_performance":
return "degraded"
case "partial_outage":
return "partial-outage"
case "major_outage":
return "major-outage"
case "maintenance":
return "maintenance"
default:
return "operational"
}
}
function impactToSeverity(i: IncidentImpact): Severity {
if (i === "critical") return "critical"
if (i === "major") return "major"
return "minor"
}
// --- Components panel --------------------------------------------------
function ComponentsPanel({
components,
loading,
onChanged,
onError,
onInfo,
}: {
components: StatusComponent[]
loading: boolean
onChanged: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const [editor, setEditor] = useState<ComponentEditor>(null)
const [pendingDelete, setPendingDelete] = useState<StatusComponent | null>(null)
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardDescription>
Each component shows its operational state on the public page. Group related ones via
group_name.
</CardDescription>
<Button size="sm" onClick={() => setEditor({ kind: "create" })} data-action="status-component-create">
<Plus className="size-4" />
New component
</Button>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay
active={loading && components.length === 0}
label="Loading components…"
/>
{components.length === 0 && !loading ? (
<EmptyState title="No components yet." description="Add the first one to seed the public board." className="py-8" />
) : (
<ul className="divide-y border-y">
{components.map((c) => (
<li
key={c.id}
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2">
<span className="font-medium">{c.name}</span>
<Badge variant={statusVariant(c.status)}>{c.status}</Badge>
{c.group_name ? (
<span className="text-xs text-muted-foreground">
group: {c.group_name}
</span>
) : null}
</span>
{c.description ? (
<span className="text-xs text-muted-foreground">{c.description}</span>
) : null}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditor({ kind: "edit", component: c })}
data-action={`status-component-${c.id}-edit`}
>
Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setPendingDelete(c)}
data-action={`status-component-${c.id}-delete`}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</CardContent>
<ComponentEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async (msg) => {
setEditor(null)
if (msg) onInfo(msg)
await onChanged()
}}
onError={onError}
/>
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(o) => !o && setPendingDelete(null)}
title="Delete component?"
description={
pendingDelete
? `${pendingDelete.name} will be removed from the public status page.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
try {
await deleteComponent(arcadia, pendingDelete.id)
setPendingDelete(null)
onInfo("Component deleted.")
await onChanged()
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
}
}}
/>
</Card>
)
}
function statusVariant(s: ComponentStatus): "default" | "secondary" | "destructive" | "outline" {
if (s === "operational") return "default"
if (s === "major_outage") return "destructive"
if (s === "partial_outage" || s === "degraded_performance") return "secondary"
return "outline"
}
function ComponentEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: ComponentEditor
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const open = state !== null
const isEdit = state?.kind === "edit"
const initial = isEdit ? state.component : null
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [status, setStatus] = useState<ComponentStatus>("operational")
const [groupName, setGroupName] = useState("")
const [displayOrder, setDisplayOrder] = useState("0")
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
if (initial) {
setName(initial.name)
setDescription(initial.description ?? "")
setStatus(initial.status)
setGroupName(initial.group_name ?? "")
setDisplayOrder(String(initial.display_order))
} else {
setName("")
setDescription("")
setStatus("operational")
setGroupName("")
setDisplayOrder("0")
}
}, [open, initial])
const submit = async () => {
onError(null)
setSaving(true)
try {
const input: ComponentInput = {
name,
description: description || undefined,
status,
group_name: groupName || null,
display_order: Number(displayOrder) || 0,
}
if (isEdit && initial) {
await updateComponent(arcadia, initial.id, input)
await onSaved("Component updated.")
} else {
await createComponent(arcadia, input)
await onSaved("Component created.")
}
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit component" : "New component"}</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="comp-name">Name</Label>
<Input
id="comp-name"
value={name}
onChange={(e) => setName(e.target.value)}
data-action="status-component-form-name"
/>
</div>
<div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="comp-description">Description</Label>
<Textarea
id="comp-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
data-action="status-component-form-description"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger data-action="status-component-form-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s.replace(/_/g, " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="comp-order">Display order</Label>
<Input
id="comp-order"
type="number"
value={displayOrder}
onChange={(e) => setDisplayOrder(e.target.value)}
data-action="status-component-form-order"
/>
</div>
<div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="comp-group">Group (optional)</Label>
<Input
id="comp-group"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
placeholder="API · Storage · Workers"
data-action="status-component-form-group"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={submit} disabled={saving || !name} data-action="status-component-form-save">
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
{isEdit ? "Save" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// --- Incidents panel ---------------------------------------------------
function IncidentsPanel({
incidents,
components,
loading,
onChanged,
onError,
onInfo,
}: {
incidents: Incident[]
components: StatusComponent[]
loading: boolean
onChanged: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const [editor, setEditor] = useState<IncidentEditor>(null)
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardDescription>
Open an incident as soon as you detect impact, then post updates as you investigate and
resolve.
</CardDescription>
<Button size="sm" onClick={() => setEditor({ kind: "create" })} data-action="status-incident-create">
<Plus className="size-4" />
New incident
</Button>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && incidents.length === 0} label="Loading incidents…" />
{incidents.length === 0 && !loading ? (
<EmptyState
icon={<AlertTriangle className="size-6" />}
title="No incidents."
description="No drama is the right state."
className="py-8"
/>
) : (
<ul className="flex flex-col divide-y border-y">
{incidents.map((i) => (
<li key={i.id} className="flex flex-col gap-2 px-3 py-3 text-sm">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2">
<span className="font-medium">{i.title}</span>
<Badge variant={incidentVariant(i.status)}>{i.status}</Badge>
<Badge variant="secondary">impact: {i.impact}</Badge>
</span>
<span className="text-xs text-muted-foreground">
Started {new Date(i.inserted_at).toLocaleString()}
{i.resolved_at
? ` · resolved ${new Date(i.resolved_at).toLocaleString()}`
: ""}
{i.components.length > 0
? ` · affects: ${i.components.map((c) => c.name).join(", ")}`
: ""}
</span>
</div>
<div className="flex flex-col gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setEditor({ kind: "update", incident: i })}
data-action={`status-incident-${i.id}-update`}
>
Post update
</Button>
{i.status !== "resolved" ? (
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await resolveIncident(arcadia, i.id)
onInfo("Incident resolved.")
await onChanged()
} catch (err) {
onError(
err instanceof ArcadiaError ? err.message : "Resolve failed.",
)
}
}}
data-action={`status-incident-${i.id}-resolve`}
>
Resolve
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => setEditor({ kind: "edit", incident: i })}
data-action={`status-incident-${i.id}-edit`}
>
Edit
</Button>
</div>
</div>
{i.updates.length > 0 ? (
<ol className="ml-2 flex flex-col gap-1 border-l-2 border-border pl-3 text-xs">
{i.updates
.slice()
.reverse()
.map((u) => (
<li key={u.id} className="flex flex-col">
<span className="font-medium">
<Badge variant="outline" className="mr-1 text-[10px]">
{u.status}
</Badge>
{new Date(u.inserted_at).toLocaleString()}
</span>
<span className="text-muted-foreground">{u.body}</span>
</li>
))}
</ol>
) : null}
</li>
))}
</ul>
)}
</CardContent>
<IncidentEditorDialog
state={editor}
components={components}
onClose={() => setEditor(null)}
onSaved={async (msg) => {
setEditor(null)
if (msg) onInfo(msg)
await onChanged()
}}
onError={onError}
/>
</Card>
)
}
function incidentVariant(
s: IncidentStatus,
): "default" | "secondary" | "destructive" | "outline" {
if (s === "resolved") return "default"
if (s === "investigating") return "destructive"
return "secondary"
}
function IncidentEditorDialog({
state,
components,
onClose,
onSaved,
onError,
}: {
state: IncidentEditor
components: StatusComponent[]
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const open = state !== null
if (state?.kind === "update") {
return (
<PostUpdateDialog
incident={state.incident}
onClose={onClose}
onSaved={() => onSaved("Update posted.")}
onError={onError}
/>
)
}
const isEdit = state?.kind === "edit"
const initial = isEdit ? state.incident : null
const [title, setTitle] = useState("")
const [status, setStatus] = useState<IncidentStatus>("investigating")
const [impact, setImpact] = useState<IncidentImpact>("minor")
const [componentIds, setComponentIds] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
if (initial) {
setTitle(initial.title)
setStatus(initial.status)
setImpact(initial.impact)
setComponentIds(new Set(initial.components.map((c) => c.id)))
} else {
setTitle("")
setStatus("investigating")
setImpact("minor")
setComponentIds(new Set())
}
}, [open, initial])
const submit = async () => {
onError(null)
setSaving(true)
try {
const input: IncidentInput = {
title,
status,
impact,
component_ids: Array.from(componentIds),
}
if (isEdit && initial) {
await updateIncident(arcadia, initial.id, input)
await onSaved("Incident updated.")
} else {
await createIncident(arcadia, input)
await onSaved("Incident opened.")
}
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open && state?.kind !== "update"} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit incident" : "New incident"}</DialogTitle>
<DialogDescription>
Once created, post updates from the incident row to inform subscribers.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="incident-title">Title</Label>
<Input
id="incident-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
data-action="status-incident-form-title"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger data-action="status-incident-form-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INCIDENT_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Impact</Label>
<Select value={impact} onValueChange={setImpact}>
<SelectTrigger data-action="status-incident-form-impact">
<SelectValue />
</SelectTrigger>
<SelectContent>
{IMPACTS.map((i) => (
<SelectItem key={i} value={i}>
{i}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="col-span-2 flex flex-col gap-1.5">
<Label>Affected components</Label>
{components.length === 0 ? (
<p className="text-xs text-muted-foreground">
Create components first; you'll be able to attach them here.
</p>
) : (
<div className="flex flex-wrap gap-1.5 rounded-md border p-2">
{components.map((c) => {
const active = componentIds.has(c.id)
return (
<button
key={c.id}
type="button"
onClick={() => {
setComponentIds((prev) => {
const next = new Set(prev)
if (next.has(c.id)) next.delete(c.id)
else next.add(c.id)
return next
})
}}
data-action={`status-incident-form-component-${c.id}`}
className={[
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
].join(" ")}
>
{c.name}
</button>
)
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
onClick={submit}
disabled={saving || !title}
data-action="status-incident-form-save"
>
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
{isEdit ? "Save" : "Open incident"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function PostUpdateDialog({
incident,
onClose,
onSaved,
onError,
}: {
incident: Incident
onClose: () => void
onSaved: () => Promise<void>
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const [status, setStatus] = useState<IncidentStatus>(incident.status)
const [body, setBody] = useState("")
const [saving, setSaving] = useState(false)
const submit = async () => {
onError(null)
setSaving(true)
try {
await addIncidentUpdate(arcadia, incident.id, { status, body })
await onSaved()
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Post failed.")
} finally {
setSaving(false)
}
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Post update</DialogTitle>
<DialogDescription>
On <em>{incident.title}</em>. Updates appear on the public timeline.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger data-action="status-incident-update-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INCIDENT_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="update-body">Body</Label>
<Textarea
id="update-body"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={5}
placeholder="What's the latest? Be brief and concrete."
data-action="status-incident-update-body"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={submit} disabled={saving || !body} data-action="status-incident-update-save">
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
Post
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// --- Subscribers panel -------------------------------------------------
function SubscribersPanel({
subscribers,
loading,
}: {
subscribers: Subscriber[]
loading: boolean
}) {
if (loading && subscribers.length === 0) {
return (
<Card>
<CardContent className="relative py-8">
<LoadingOverlay active label="Loading subscribers" />
</CardContent>
</Card>
)
}
return (
<Card>
<CardContent className="p-0">
{subscribers.length === 0 ? (
<EmptyState
icon={<Mail className="size-6" />}
title="No subscribers yet."
description="They appear here once they confirm via the public status page."
className="py-8"
/>
) : (
<ul className="divide-y border-y">
{subscribers.map((s) => (
<li
key={s.id}
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2 font-medium">
<Mail className="size-3.5 text-muted-foreground" />
{s.email}
</span>
<span className="text-xs text-muted-foreground">
Subscribed {new Date(s.inserted_at).toLocaleString()}
{s.confirmed_at
? ` · confirmed ${new Date(s.confirmed_at).toLocaleString()}`
: " · pending confirmation"}
</span>
</div>
{s.confirmed_at ? <Badge>confirmed</Badge> : <Badge variant="secondary">pending</Badge>}
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}