The mechanism (context surface registry, persona storage + hooks, tool
parser/dispatcher) is now generic and lives in @crema/aifirst-ui/{context,
agents,tools}. This template keeps only the arcadia-shaped configuration:
- agents.ts — owns DEFAULT_AGENTS + legacy/retired migration sets, calls
configureAgents() at module load, re-exports the runtime
- admin-tools.ts — keeps the 19 arcadia tool definitions, binds the
runtime via createToolRuntime(TOOLS), re-exports the bound functions
- admin-context.ts — deleted; 18 routes now import directly from
@crema/aifirst-ui/context
Routes that import from ~/lib/agents and ~/lib/admin-tools are unchanged
(wrapper modules preserve the existing import surface).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1213 lines
39 KiB
TypeScript
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 { useRegisterContext } from "@crema/aifirst-ui/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],
|
|
)
|
|
useRegisterContext("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 & 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"
|
|
}
|