Files
arcadia-admin/app/routes/monitoring.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

896 lines
28 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
Activity,
AlertTriangle,
Cpu,
Database,
Globe,
HardDrive,
Loader2,
RefreshCw,
RotateCw,
Server,
Users,
Zap,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { AlertBanner } from "@crema/feedback-ui"
import {
BarChart,
Donut,
Heatmap,
LineChart,
Sparkline,
type ChartDatum,
type SeriesPoint,
} from "@crema/chart-ui"
import { KpiTile, formatCompact, formatPercent } from "@crema/dashboard-ui"
import {
ComponentRow,
IncidentTimeline,
OverallStatus,
type ComponentState,
type StatusComponent,
type StatusIncident,
} from "@crema/status-ui"
import {
ChoroplethMap,
WorldMapSvg,
} from "@crema/map-ui"
import { formatBytes } from "@crema/file-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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
import {
getActiveSessions,
getAuditStats,
getInfrastructureSummary,
getJobStats,
getRateLimits,
getRecentJobs,
getSpaces,
listDroplets,
retryJob,
type ActiveSession,
type AuditStats,
type Droplet,
type InfrastructureSummary,
type JobStats,
type ObanJob,
type RateLimit,
type Space,
} from "~/lib/arcadia/monitoring"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { useRegisterAdminContext } from "~/lib/admin-context"
export const meta = () => pageTitle("Monitoring")
interface DashboardData {
jobStats: JobStats | null
recentJobs: ObanJob[]
sessions: { sessions: ActiveSession[]; count: number } | null
rateLimits: RateLimit[]
infraSummary: InfrastructureSummary | null
spaces: Space[]
droplets: Droplet[]
auditStats: AuditStats | null
}
const EMPTY: DashboardData = {
jobStats: null,
recentJobs: [],
sessions: null,
rateLimits: [],
infraSummary: null,
spaces: [],
droplets: [],
auditStats: null,
}
export default function MonitoringRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const [data, setData] = useState<DashboardData>(EMPTY)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [autoRefresh, setAutoRefresh] = useState(true)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const [
jobStats,
recentJobs,
sessions,
rateLimits,
infraSummary,
spaces,
droplets,
auditStats,
] = await Promise.all([
getJobStats(arcadia).catch(() => null),
getRecentJobs(arcadia, { limit: 50 }).catch(() => []),
getActiveSessions(arcadia).catch(() => null),
getRateLimits(arcadia).catch(() => []),
getInfrastructureSummary(arcadia),
getSpaces(arcadia),
listDroplets(arcadia),
getAuditStats(arcadia, {
from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
}).catch(() => null),
])
setData({
jobStats,
recentJobs,
sessions,
rateLimits,
infraSummary,
spaces,
droplets,
auditStats,
})
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load monitoring data.")
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
// Auto-refresh every 30s for the live feel.
useEffect(() => {
if (!session || !autoRefresh) return
const t = setInterval(refresh, 30000)
return () => clearInterval(t)
}, [session, autoRefresh, refresh])
const components = useMemo(() => buildStatusComponents(data), [data])
const summary = useMemo(
() => ({
jobs: data.jobStats?.counts ?? {},
jobs_executing: data.jobStats?.counts?.executing ?? 0,
jobs_retryable: data.jobStats?.counts?.retryable ?? 0,
sessions_24h: data.sessions?.count ?? 0,
droplets: data.droplets.length,
spaces: data.spaces.length,
audit_total_7d: data.auditStats?.total ?? 0,
}),
[data],
)
useRegisterAdminContext("monitoring", summary)
if (!session) {
return (
<AppShell title="Monitoring">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Monitoring requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/monitoring">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Monitoring">
<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">Server stats &amp; health</h1>
<p className="text-sm text-muted-foreground">
Live view of background jobs, active sessions, infrastructure, and audit
activity. Refreshes every 30s.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={autoRefresh ? "outline" : "ghost"}
size="sm"
onClick={() => setAutoRefresh((v) => !v)}
data-action="monitoring-auto-refresh"
>
<RotateCw className={`size-4 ${autoRefresh ? "animate-pulse" : ""}`} />
Auto-refresh {autoRefresh ? "on" : "off"}
</Button>
<Button
size="sm"
onClick={refresh}
disabled={loading}
data-action="monitoring-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{/* Service status board */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Service health</CardTitle>
<CardDescription>Derived from live signals on each subsystem.</CardDescription>
</div>
<OverallStatus components={components} />
</CardHeader>
<CardContent className="flex flex-col gap-1">
{components.map((c) => (
<ComponentRow key={c.id} component={c} showUptime={false} />
))}
</CardContent>
</Card>
{/* KPI tiles */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<KpiTile
label="Active sessions (24h)"
value={formatCompact(data.sessions?.count ?? 0)}
icon={<Users className="size-4" />}
/>
<KpiTile
label="Jobs executing"
value={String(data.jobStats?.counts?.executing ?? 0)}
icon={<Zap className="size-4" />}
tone={
(data.jobStats?.counts?.executing ?? 0) > 0 ? "info" : "neutral"
}
/>
<KpiTile
label="Retryable jobs"
value={String(data.jobStats?.counts?.retryable ?? 0)}
icon={<AlertTriangle className="size-4" />}
tone={
(data.jobStats?.counts?.retryable ?? 0) > 0 ? "warning" : "neutral"
}
/>
<KpiTile
label="Audit events (7d)"
value={formatCompact(data.auditStats?.total ?? 0)}
icon={<Activity className="size-4" />}
/>
</div>
<Tabs defaultValue="jobs">
<TabsList>
<TabsTrigger value="jobs" data-action="monitoring-tab-jobs">
Background jobs
</TabsTrigger>
<TabsTrigger value="sessions" data-action="monitoring-tab-sessions">
Sessions
</TabsTrigger>
<TabsTrigger value="audit" data-action="monitoring-tab-audit">
Audit activity
</TabsTrigger>
<TabsTrigger value="infra" data-action="monitoring-tab-infra">
Infrastructure
</TabsTrigger>
<TabsTrigger value="rate-limits" data-action="monitoring-tab-rate-limits">
Rate limits
</TabsTrigger>
</TabsList>
<TabsContent value="jobs" className="pt-4">
<JobsPanel
stats={data.jobStats}
recent={data.recentJobs}
onRetry={async (id) => {
try {
await retryJob(arcadia, id)
await refresh()
} catch (err) {
setError(
err instanceof ArcadiaError ? err.message : "Retry failed.",
)
}
}}
/>
</TabsContent>
<TabsContent value="sessions" className="pt-4">
<SessionsPanel sessions={data.sessions} />
</TabsContent>
<TabsContent value="audit" className="pt-4">
<AuditPanel stats={data.auditStats} />
</TabsContent>
<TabsContent value="infra" className="pt-4">
<InfraPanel
summary={data.infraSummary}
spaces={data.spaces}
droplets={data.droplets}
/>
</TabsContent>
<TabsContent value="rate-limits" className="pt-4">
<RateLimitsPanel limits={data.rateLimits} />
</TabsContent>
</Tabs>
</div>
</AppShell>
)
}
// Synthesize a status board from the live signals we have.
function buildStatusComponents(d: DashboardData): StatusComponent[] {
const apiOk = d.rateLimits.length > 0
const dbOk = d.sessions !== null
const workersState: ComponentState = (() => {
if (!d.jobStats) return "partial-outage"
const r = d.jobStats.counts.retryable ?? 0
const x = d.jobStats.counts.discarded ?? 0
if (x > 100) return "major-outage"
if (r > 50 || x > 0) return "degraded"
return "operational"
})()
const storageState: ComponentState =
d.spaces.length > 0 || d.infraSummary ? "operational" : "partial-outage"
return [
{
id: "api",
name: "API",
description: "/api/v1 — auth, REST endpoints",
state: apiOk ? "operational" : "partial-outage",
},
{
id: "db",
name: "Database",
description: "Postgres — sessions, audit log",
state: dbOk ? "operational" : "partial-outage",
},
{
id: "workers",
name: "Background workers",
description: "Oban — webhook delivery, scheduled tasks",
state: workersState,
},
{
id: "storage",
name: "Storage",
description: "DigitalOcean Spaces / S3-compatible object storage",
state: storageState,
},
]
}
// --- Jobs panel --------------------------------------------------------
function JobsPanel({
stats,
recent,
onRetry,
}: {
stats: JobStats | null
recent: ObanJob[]
onRetry: (id: number) => Promise<void>
}) {
if (!stats) {
return <PanelStub icon={<Server className="size-5" />} text="No job stats available." />
}
const stateData: ChartDatum[] = (Object.entries(stats.counts) as [string, number][])
.filter(([, n]) => n > 0)
.map(([state, n]) => ({ label: state, value: n, color: jobStateColor(state) }))
const queueData: ChartDatum[] = stats.queues.map((q) => {
const totals = stats.by_queue[q] ?? {}
const sum = Object.values(totals).reduce<number>((a, n) => a + (n ?? 0), 0)
return { label: q, value: sum }
})
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-base">Jobs by state</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center">
{stateData.length === 0 ? (
<p className="py-6 text-sm text-muted-foreground">No active jobs.</p>
) : (
<Donut data={stateData} size={180} thickness={28} />
)}
<ul className="ml-4 flex flex-col gap-1 text-xs">
{stateData.map((d) => (
<li key={d.label} className="flex items-center gap-2">
<span
className="size-2.5 rounded-full"
style={{ background: d.color }}
aria-hidden
/>
<span className="font-mono">{d.label}</span>
<span className="ml-auto text-muted-foreground">{d.value}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">Active jobs by queue</CardTitle>
</CardHeader>
<CardContent>
{queueData.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No queued or executing jobs.
</p>
) : (
<BarChart data={queueData} width={520} height={180} />
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Recent jobs</CardTitle>
<CardDescription>Latest 50 newest first.</CardDescription>
</CardHeader>
<CardContent className="p-0">
{recent.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No recent jobs.</p>
) : (
<ul className="divide-y border-y">
{recent.map((j) => (
<li key={j.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2 text-sm">
<Badge variant={jobStateVariant(j.state)}>{j.state}</Badge>
<code className="font-mono text-xs">{j.worker}</code>
<span className="text-xs text-muted-foreground">queue: {j.queue}</span>
</span>
<span className="text-[11px] text-muted-foreground">
attempt {j.attempt}/{j.max_attempts} · inserted{" "}
{new Date(j.inserted_at).toLocaleString()}
{j.completed_at
? ` · completed ${new Date(j.completed_at).toLocaleString()}`
: ""}
</span>
{j.errors && j.errors.length > 0 ? (
<span className="text-xs text-destructive">
{j.errors[j.errors.length - 1]?.error ?? "(error)"}
</span>
) : null}
</div>
{j.state === "retryable" || j.state === "discarded" ? (
<Button
variant="outline"
size="sm"
onClick={() => onRetry(j.id)}
data-action={`monitoring-job-${j.id}-retry`}
>
<RotateCw className="size-3.5" />
Retry
</Button>
) : null}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
)
}
// --- Sessions panel ----------------------------------------------------
function SessionsPanel({
sessions,
}: {
sessions: { sessions: ActiveSession[]; count: number } | null
}) {
if (!sessions) {
return <PanelStub icon={<Users className="size-5" />} text="No session data available." />
}
// Bucket sign-ins by hour for a 24h sparkline.
const hourly = useMemo(() => {
const now = Date.now()
const buckets = Array.from({ length: 24 }, (_, i) => ({
x: i,
y: 0,
}))
for (const s of sessions.sessions) {
const t = new Date(s.last_sign_in_at).getTime()
const ago = (now - t) / (60 * 60 * 1000)
const idx = 23 - Math.floor(ago)
if (idx >= 0 && idx < 24) buckets[idx].y++
}
return buckets
}, [sessions])
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base">Sign-ins over the last 24h</CardTitle>
<CardDescription>One bar per hour, latest on the right.</CardDescription>
</div>
<Sparkline data={hourly} width={240} height={48} stroke="var(--primary)" />
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{sessions.count} recent sessions</CardTitle>
</CardHeader>
<CardContent className="p-0">
{sessions.sessions.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No sign-ins in the last 24 hours.
</p>
) : (
<ul className="divide-y border-y">
{sessions.sessions.map((s) => (
<li
key={s.user_id}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{s.email}</span>
<span className="text-xs text-muted-foreground">
{s.first_name || s.last_name
? `${s.first_name ?? ""} ${s.last_name ?? ""}`.trim() + " · "
: ""}
{s.user_type ?? "user"} · status: {s.status}
{s.two_factor_enabled ? " · 2FA" : ""}
</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(s.last_sign_in_at).toLocaleString()}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
)
}
// --- Audit panel -------------------------------------------------------
function AuditPanel({ stats }: { stats: AuditStats | null }) {
if (!stats) {
return <PanelStub icon={<Activity className="size-5" />} text="No audit stats available." />
}
const bySeverity: ChartDatum[] = Object.entries(stats.by_severity ?? {}).map(
([k, v]) => ({ label: k, value: v, color: severityColor(k) }),
)
const byResource: ChartDatum[] = Object.entries(stats.by_resource_type ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([k, v]) => ({ label: k, value: v }))
// Time series — only render if backend supplied it.
const series: SeriesPoint[] | null = stats.over_time
? stats.over_time.map((p, i) => ({ x: i, y: p.total }))
: null
return (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Events by severity (7d)</CardTitle>
</CardHeader>
<CardContent>
{bySeverity.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No events.</p>
) : (
<BarChart data={bySeverity} width={500} height={180} />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Top resource types</CardTitle>
</CardHeader>
<CardContent>
{byResource.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No events.</p>
) : (
<BarChart data={byResource} width={500} height={Math.max(180, byResource.length * 22)} />
)}
</CardContent>
</Card>
{series ? (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">Events over time</CardTitle>
</CardHeader>
<CardContent>
<LineChart data={series} width={900} height={200} />
</CardContent>
</Card>
) : null}
</div>
)
}
// --- Infrastructure panel ----------------------------------------------
function InfraPanel({
summary,
spaces,
droplets,
}: {
summary: InfrastructureSummary | null
spaces: Space[]
droplets: Droplet[]
}) {
const dropletsByRegion = useMemo(() => {
const out: Record<string, number> = {}
for (const d of droplets) {
const r =
typeof d.region === "string"
? d.region
: d.region?.slug ?? d.region?.name ?? "unknown"
out[r] = (out[r] ?? 0) + 1
}
return out
}, [droplets])
if (!summary && spaces.length === 0 && droplets.length === 0) {
return (
<PanelStub
icon={<Globe className="size-5" />}
text="No infrastructure connected. Wire a DigitalOcean token in arcadia's .env to see this section populate."
/>
)
}
return (
<div className="flex flex-col gap-4">
{summary ? (
<Card>
<CardHeader>
<CardTitle className="text-base">DigitalOcean summary</CardTitle>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-md border bg-muted/50 p-3 text-xs">
{JSON.stringify(summary, null, 2)}
</pre>
</CardContent>
</Card>
) : null}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<KpiTile
label="Droplets"
value={String(droplets.length)}
icon={<Server className="size-4" />}
/>
<KpiTile
label="Spaces"
value={String(spaces.length)}
icon={<HardDrive className="size-4" />}
/>
<KpiTile
label="Regions in use"
value={String(Object.keys(dropletsByRegion).length)}
icon={<Globe className="size-4" />}
/>
</div>
{droplets.length > 0 ? (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Droplet regions</CardTitle>
<CardDescription>Coloured continents indicate any droplets in that hemisphere.</CardDescription>
</CardHeader>
<CardContent>
<WorldMapSvg
regionColors={regionColorsFor(dropletsByRegion)}
className="w-full"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Droplets ({droplets.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ul className="divide-y border-y">
{droplets.slice(0, 20).map((d) => (
<li
key={String(d.id)}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2 text-sm font-medium">
<Cpu className="size-3.5 text-muted-foreground" />
{d.name}
</span>
<span className="text-xs text-muted-foreground">
{typeof d.region === "string"
? d.region
: d.region?.slug ?? "—"}
{d.size_slug ? ` · ${d.size_slug}` : ""}
{d.vcpus ? ` · ${d.vcpus} vCPU` : ""}
{d.memory ? ` · ${formatBytes(d.memory * 1024 * 1024)}` : ""}
</span>
</div>
<Badge
variant={d.status === "active" ? "default" : "secondary"}
>
{d.status}
</Badge>
</li>
))}
{droplets.length > 20 ? (
<li className="px-3 py-2 text-center text-xs text-muted-foreground">
+ {droplets.length - 20} more
</li>
) : null}
</ul>
</CardContent>
</Card>
</div>
) : null}
{spaces.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Spaces ({spaces.length})</CardTitle>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-md border bg-muted/50 p-3 text-xs">
{JSON.stringify(spaces, null, 2)}
</pre>
</CardContent>
</Card>
) : null}
</div>
)
}
function regionColorsFor(byRegion: Record<string, number>): Record<string, string> {
// Best-effort mapping from DO region slugs to continent IDs the WorldMapSvg knows.
// The lib exposes regions like "north-america", "europe", "asia", etc.
const colors: Record<string, string> = {}
const continentOf = (r: string): string | null => {
const lc = r.toLowerCase()
if (/^(nyc|sfo|tor|ams_tor)/.test(lc)) return "north-america"
if (/^(lon|ams|fra)/.test(lc)) return "europe"
if (/^(blr|sgp)/.test(lc)) return "asia"
if (/^(syd)/.test(lc)) return "oceania"
return null
}
for (const r of Object.keys(byRegion)) {
const c = continentOf(r)
if (c) colors[c] = "var(--primary)"
}
return colors
}
// --- Rate limits panel -------------------------------------------------
function RateLimitsPanel({ limits }: { limits: RateLimit[] }) {
if (limits.length === 0) {
return (
<PanelStub
icon={<Database className="size-5" />}
text="No rate-limit configuration available."
/>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Configured rate limits</CardTitle>
<CardDescription>
The maximum requests allowed per window for each authenticated bucket.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ul className="divide-y border-y">
{limits.map((l) => (
<li key={l.type} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex flex-col">
<span className="text-sm font-medium capitalize">{l.type}</span>
<span className="text-xs text-muted-foreground">
Window: {l.window_seconds}s
</span>
</div>
<span className="font-mono text-sm">
{l.max_requests.toLocaleString()} req
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)
}
// --- helpers ----------------------------------------------------------
function PanelStub({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<Card>
<CardContent className="flex items-center gap-3 py-8 text-sm text-muted-foreground">
{icon}
{text}
</CardContent>
</Card>
)
}
function jobStateColor(state: string): string {
switch (state) {
case "executing":
return "#3b82f6"
case "available":
case "scheduled":
return "#a3a3a3"
case "retryable":
return "#f59e0b"
case "discarded":
return "#ef4444"
case "cancelled":
return "#737373"
case "completed":
return "#10b981"
default:
return "#9ca3af"
}
}
function jobStateVariant(
state: string,
): "default" | "secondary" | "destructive" | "outline" {
if (state === "executing" || state === "completed") return "default"
if (state === "discarded") return "destructive"
if (state === "retryable" || state === "scheduled") return "secondary"
return "outline"
}
function severityColor(s: string): string {
if (s === "critical" || s === "error") return "#ef4444"
if (s === "warning") return "#f59e0b"
return "#94a3b8"
}