Add Buckets, Monitoring, Memberships, Networking, SSO, Announcements, Status page
Full set of admin surfaces on top of /platform/* and /admin/* endpoints,
plus a migration of /assistant onto @crema/llm-providers-ui.
Buckets (/buckets):
S3-level CRUD over /platform/buckets — list, create, delete (with the
6-digit confirmation flow the backend enforces), per-bucket configure
for versioning / CORS rules / policy JSON, plus an object browser
with FileGrid/FileList from @crema/file-ui and presigned-URL reveal.
Storage-config picker scopes the view to one credential at a time.
Monitoring (/monitoring):
Live dashboard. Service health board derived from indirect signals
(status-ui OverallStatus + ComponentRow). KPI tiles for sessions,
jobs, audit. Tabs: background jobs (Donut + BarChart + retry recent),
sessions (Sparkline of last 24h sign-ins), audit activity (BarChart
of severity / top resource types), infrastructure (DO summary +
WorldMapSvg coloured by droplet region + droplet list + Spaces),
rate limits. 30s auto-refresh.
Memberships (/memberships):
M:N glue between users and tenants over /admin/memberships. Add /
edit / suspend / activate / remove with role multi-select.
Networking (/networking):
Tabs over /platform/{firewalls,vpcs,domains,floating_ips}.
Read/delete on firewalls, read on VPCs, full DNS-record CRUD, and
inline assign/unassign for floating IPs.
SSO (/sso):
/sso/identity-providers CRUD with PEM cert as write-only field, plus
/sso/sessions list with destroy.
Announcements (/announcements):
/admin/announcements CRUD. Platform-wide vs per-tenant audience,
schedule windows, dismissible + active toggles.
Status page (/status-page):
/admin/status-page/{components,incidents,subscribers}. Components
CRUD, incidents with timeline + post-update + resolve flow,
subscriber list. Public preview at the top using StatusBoard +
IncidentTimeline from @crema/status-ui.
Assistant migration:
/assistant now uses @crema/llm-providers-ui (provider catalog +
vault key resolution) instead of ~/lib/llm-settings. Same async
buildAdapter() flow used by /ai. The legacy lib file is now
unreferenced and can be removed when ready.
New sibling libs wired (cloned from CremaUIStudio):
lib-file-ui, lib-card-ui, lib-dashboard-ui, lib-chart-ui,
lib-map-ui, lib-status-ui.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
895
app/routes/monitoring.tsx
Normal file
895
app/routes/monitoring.tsx
Normal file
@@ -0,0 +1,895 @@
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user