Files
arcadia-admin/app/routes/monitoring.tsx
jules 20c592dfa7 admin: completeness + UI consistency pass
Arcadia wiring:
- home: real Overview dashboard (tenants/users/audit/health probe) replacing the inherited Vibespace welcome tiles; skeleton loaders, refresh button, registers admin context
- profile: split into Account (synced via getUser/updateUser of session user) and local Preferences; updateSessionUser keeps the appbar in sync after edits
- session: drop unused signIn mock, add updateSessionUser, refresh tests
- profile schema: drop redundant Profile.name/email (session is the source of truth)
- routes: delete orphaned resources route + lib

Auth flows that previously 404'd:
- /signup, /login/forgot, /login/reset, /login/2fa wired via @crema/arcadia-auth-ui
- shared AuthShell + AuthBrand wrapper

Assistant tools (admin-tools.ts):
- +10 tools: deactivate_tenant, set_user_status, delete_user, list_memberships, list_roles, revoke_api_key, create_user, update_user, assign_role, remove_role
- list_memberships gains user_id filter for "tenants this user belongs to" queries
- search_kb / read_chunk: new token resolution (window override → VITE_ARCADIA_SEARCH_TOKEN service token → operator session JWT → "dev"); on 401/403 emit a tailored hint based on which token was used

UI consistency:
- new PageHeader component
- AppShell.title was unrendered — dropped; first-child padding on #main-content keeps the floating actions pill from colliding with header content
- removed dead "Sign in required" fallback cards from 14 routes (AppShell already redirects)
- stripped p-6 from outer wrappers across 14 routes (was double-padding under AppShell's own p-6)
- migrated home + tenants to PageHeader

arcadia-search ergonomics:
- scripts/mint-search-token.mjs + `npm run mint:search-token` mints HS512 JWT with required tenant_id claim, upserts VITE_ARCADIA_SEARCH_TOKEN into .env.local
- README/.env document the new VITE_ARCADIA_SEARCH_URL / VITE_ARCADIA_SEARCH_TOKEN knobs
- .env.local now gitignored

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:37:31 +10:00

1213 lines
39 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react"
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 {
getHealth,
getHostStats,
SUBSYSTEMS,
type HealthSubsystem,
type HostStats,
type OverallHealth,
type SubsystemHealth,
} from "~/lib/arcadia/health"
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
health: OverallHealth | null
host: HostStats | null
}
const EMPTY: DashboardData = {
jobStats: null,
recentJobs: [],
sessions: null,
rateLimits: [],
infraSummary: null,
spaces: [],
droplets: [],
auditStats: null,
health: null,
host: 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,
health,
host,
] = 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),
getHealth(arcadia).catch(() => null),
getHostStats(arcadia).catch(() => null),
])
setData({
jobStats,
recentJobs,
sessions,
rateLimits,
infraSummary,
spaces,
droplets,
auditStats,
health,
host,
})
} 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)
return (
<AppShell>
<div className="flex flex-col gap-4">
<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>
{data.health
? `Live probes from /api/v1/health · checked ${new Date(
data.health.checked_at,
).toLocaleTimeString()}`
: "Live probes from /api/v1/health (unavailable — backend may be down or older than the per-subsystem probe rollout)."}
</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>
{data.host ? (
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<KpiTile
label="CPU usage"
value={
data.host.cpu.util_pct != null
? formatPercent(data.host.cpu.util_pct / 100)
: "—"
}
icon={<Cpu className="size-4" />}
tone={
(data.host.cpu.util_pct ?? 0) > 90
? "negative"
: (data.host.cpu.util_pct ?? 0) > 70
? "warning"
: "neutral"
}
/>
<KpiTile
label="Load avg (1m)"
value={
data.host.cpu.load_avg_1 != null
? data.host.cpu.load_avg_1.toFixed(2)
: "—"
}
icon={<Activity className="size-4" />}
tone={
data.host.cpu.load_avg_1 != null &&
data.host.cpu.num_cpus &&
data.host.cpu.load_avg_1 > data.host.cpu.num_cpus
? "warning"
: "neutral"
}
/>
<KpiTile
label="Memory used"
value={memoryUsedLabel(data.host.memory)}
icon={<HardDrive className="size-4" />}
tone={
memoryUsedPct(data.host.memory) > 90
? "negative"
: memoryUsedPct(data.host.memory) > 75
? "warning"
: "neutral"
}
/>
<KpiTile
label="Disk (busiest mount)"
value={busiestDiskLabel(data.host.disks)}
icon={<Database className="size-4" />}
tone={
busiestDiskPct(data.host.disks) > 90
? "negative"
: busiestDiskPct(data.host.disks) > 75
? "warning"
: "neutral"
}
/>
</div>
) : null}
<Tabs defaultValue="host">
<TabsList>
<TabsTrigger value="host" data-action="monitoring-tab-host">
Host
</TabsTrigger>
<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="host" className="pt-4">
<HostPanel host={data.host} />
</TabsContent>
<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>
)
}
// Map arcadia /health probe results onto the status-ui component model.
// "ok" → operational, "degraded" → degraded, "error" → partial-outage,
// "unconfigured" → operational (storage with no configured backend is ok).
function buildStatusComponents(d: DashboardData): StatusComponent[] {
const subsystems = d.health?.subsystems
const meta: Record<HealthSubsystem, { name: string; description: string }> = {
api: { name: "API", description: "/api/v1 — auth, REST endpoints" },
db: { name: "Database", description: "Postgres — sessions, audit log" },
workers: {
name: "Background workers",
description: "Oban — webhook delivery, scheduled tasks",
},
storage: {
name: "Storage",
description: "S3-compatible object storage (per platform default)",
},
}
return SUBSYSTEMS.map((id) => {
const probe = subsystems?.[id]
return {
id,
name: meta[id].name,
description: probe?.message ?? meta[id].description,
state: probe ? mapHealthState(probe) : "partial-outage",
} satisfies StatusComponent
})
}
function mapHealthState(probe: SubsystemHealth): ComponentState {
switch (probe.status) {
case "ok":
case "unconfigured":
return "operational"
case "degraded":
return "degraded"
case "error":
return "major-outage"
default:
return "partial-outage"
}
}
// --- Host panel --------------------------------------------------------
function HostPanel({ host }: { host: HostStats | null }) {
if (!host) {
return (
<PanelStub
icon={<Cpu className="size-5" />}
text="Host stats unavailable. The /api/v1/health/host endpoint may not be deployed yet, or os_mon daemons aren't reachable."
/>
)
}
const memUsed = memoryUsedBytes(host.memory)
const memTotal = host.memory.total_bytes ?? null
const memPct = memoryUsedPct(host.memory)
const swapTotal = host.memory.swap_total_bytes ?? null
const swapUsed =
swapTotal != null && host.memory.swap_free_bytes != null
? swapTotal - host.memory.swap_free_bytes
: null
return (
<div className="flex flex-col gap-4">
{/* CPU + load */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">CPU</CardTitle>
<CardDescription>
{host.cpu.num_cpus
? `${host.cpu.num_cpus} cores · ${host.cpu.schedulers_online} BEAM schedulers online`
: `${host.cpu.schedulers_online} BEAM schedulers online`}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<UsageBar
label="Overall utilisation"
pct={host.cpu.util_pct ?? null}
valueText={
host.cpu.util_pct != null ? `${host.cpu.util_pct.toFixed(1)}%` : "—"
}
/>
{host.cpu.per_cpu_pct.length > 0 ? (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Per core</span>
<div className="flex flex-wrap gap-1.5">
{host.cpu.per_cpu_pct.map((p, i) => (
<div
key={i}
className="flex h-6 w-12 items-center justify-center rounded text-[11px] font-mono"
style={{
background: `linear-gradient(to right, var(--primary) ${p}%, var(--muted) ${p}%)`,
color: p > 50 ? "var(--primary-foreground)" : "var(--foreground)",
}}
title={`Core ${i}: ${p.toFixed(1)}%`}
>
{p.toFixed(0)}
</div>
))}
</div>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Load average</CardTitle>
<CardDescription>
Unix-style load average. A value above the core count means the
run-queue is saturated.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3">
<LoadAvgCell label="1 min" value={host.cpu.load_avg_1} cores={host.cpu.num_cpus} />
<LoadAvgCell label="5 min" value={host.cpu.load_avg_5} cores={host.cpu.num_cpus} />
<LoadAvgCell label="15 min" value={host.cpu.load_avg_15} cores={host.cpu.num_cpus} />
</div>
</CardContent>
</Card>
</div>
{/* Memory */}
<Card>
<CardHeader>
<CardTitle className="text-base">Memory</CardTitle>
<CardDescription>
{memTotal != null ? `${formatBytes(memTotal)} total` : "Total memory unknown"}
{host.memory.available_bytes != null
? ` · ${formatBytes(host.memory.available_bytes)} available`
: ""}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<UsageBar
label="Used"
pct={memPct}
valueText={
memUsed != null && memTotal != null
? `${formatBytes(memUsed)} / ${formatBytes(memTotal)} (${memPct.toFixed(1)}%)`
: "—"
}
/>
{(host.memory.buffered_bytes != null || host.memory.cached_bytes != null) && (
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
{host.memory.buffered_bytes != null && (
<span>Buffered: {formatBytes(host.memory.buffered_bytes)}</span>
)}
{host.memory.cached_bytes != null && (
<span>Cached: {formatBytes(host.memory.cached_bytes)}</span>
)}
</div>
)}
{swapTotal != null && swapTotal > 0 ? (
<UsageBar
label="Swap"
pct={swapUsed != null ? (swapUsed / swapTotal) * 100 : null}
valueText={
swapUsed != null
? `${formatBytes(swapUsed)} / ${formatBytes(swapTotal)}`
: "—"
}
/>
) : null}
</CardContent>
</Card>
{/* Disks */}
<Card>
<CardHeader>
<CardTitle className="text-base">Disks</CardTitle>
<CardDescription>One row per mount point.</CardDescription>
</CardHeader>
<CardContent>
{host.disks.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">No disks reported.</p>
) : (
<div className="flex flex-col gap-2">
{host.disks.map((d) => (
<UsageBar
key={d.mount}
label={d.mount}
pct={d.used_pct}
valueText={`${d.used_pct}% of ${formatBytes(d.total_kb * 1024)}`}
/>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}
function UsageBar({
label,
pct,
valueText,
}: {
label: string
pct: number | null
valueText: string
}) {
const clamped = pct == null ? 0 : Math.max(0, Math.min(100, pct))
const tone = pct == null ? "var(--muted-foreground)" : barColor(pct)
return (
<div className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className="font-medium">{label}</span>
<span className="font-mono text-muted-foreground">{valueText}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full transition-[width] duration-500"
style={{ width: `${clamped}%`, background: tone }}
/>
</div>
</div>
)
}
function LoadAvgCell({
label,
value,
cores,
}: {
label: string
value: number | null
cores: number | null
}) {
const saturated = value != null && cores != null && value > cores
return (
<div className="flex flex-col items-start gap-0.5 rounded-md border bg-card p-3">
<span className="text-xs text-muted-foreground">{label}</span>
<span
className="font-mono text-2xl font-semibold tabular-nums"
style={{ color: saturated ? "var(--destructive)" : "var(--foreground)" }}
>
{value != null ? value.toFixed(2) : "—"}
</span>
{cores ? (
<span className="text-[11px] text-muted-foreground">/ {cores} cores</span>
) : null}
</div>
)
}
function memoryUsedBytes(m: HostStats["memory"]): number | null {
if (m.total_bytes == null) return null
// Prefer "available" over "free" — on Linux, free excludes reclaimable
// buffer/cache memory and overstates pressure.
const available = m.available_bytes ?? m.free_bytes
if (available == null) return null
return Math.max(0, m.total_bytes - available)
}
function memoryUsedPct(m: HostStats["memory"]): number {
const used = memoryUsedBytes(m)
if (used == null || m.total_bytes == null || m.total_bytes === 0) return 0
return (used / m.total_bytes) * 100
}
function memoryUsedLabel(m: HostStats["memory"]): string {
const used = memoryUsedBytes(m)
if (used == null || m.total_bytes == null) return "—"
return `${formatBytes(used)} / ${formatBytes(m.total_bytes)}`
}
function busiestDiskPct(disks: HostStats["disks"]): number {
return disks.reduce((m, d) => Math.max(m, d.used_pct), 0)
}
function busiestDiskLabel(disks: HostStats["disks"]): string {
if (disks.length === 0) return "—"
const busiest = disks.reduce((a, b) => (b.used_pct > a.used_pct ? b : a))
return `${busiest.used_pct}% (${busiest.mount})`
}
function barColor(pct: number): string {
if (pct >= 90) return "var(--destructive)"
if (pct >= 75) return "#f59e0b"
return "var(--primary)"
}
// --- 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"
}