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>
896 lines
28 KiB
TypeScript
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 & 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"
|
|
}
|