Files
arcadia-admin/app/routes/home.tsx
jules ab116f8465 refactor: rename @crema/arcadia-client → @crema/arcadia-core-client
Disambiguates the Phoenix/auth client lib from lib-arcadia-agents-client.
Dir lib-arcadia-client → lib-arcadia-core-client; alias updated in
tsconfig paths, vite config, app.css @source, imports, CI and docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:31:56 +10:00

431 lines
13 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
Activity,
AlertTriangle,
Building2,
CheckCircle2,
CircleAlert,
HeartPulse,
RefreshCw,
Users as UsersIcon,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header"
import { Button } from "~/components/ui/button"
import { Skeleton } from "~/components/ui/skeleton"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { listAuditLogs, type AuditLog } from "~/lib/arcadia/audit-logs"
import {
getHealth,
SUBSYSTEMS,
type HealthStatus,
type HealthSubsystem,
type OverallHealth,
} from "~/lib/arcadia/health"
import { listTenants, type Tenant } from "~/lib/arcadia/tenants"
import { listUsers, type User } from "~/lib/arcadia/users"
import { useRegisterContext } from "@crema/aifirst-ui/context"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
export const meta = () => pageTitle("Overview")
interface DashboardData {
tenants: Tenant[]
users: User[]
audit: AuditLog[]
health: OverallHealth | null
}
const EMPTY: DashboardData = { tenants: [], users: [], audit: [], health: null }
export default function HomeRoute() {
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 [refreshedAt, setRefreshedAt] = useState<Date | null>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
const [tenants, users, audit, health] = await Promise.all([
listTenants(arcadia).catch((err) => {
throw err
}),
listUsers(arcadia),
listAuditLogs(arcadia, { limit: 10 }),
getHealth(arcadia).catch(() => null),
]).catch((err) => {
setError(err instanceof ArcadiaError ? err.message : "Failed to load overview.")
return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null]
})
setData({ tenants, users, audit, health })
setRefreshedAt(new Date())
setLoading(false)
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
const stats = useMemo(() => {
const activeTenants = data.tenants.filter((t) => t.status === "active").length
const activeUsers = data.users.filter((u) => u.status === "active").length
const errorEvents = data.audit.filter(
(a) => a.severity === "error" || a.severity === "critical",
).length
return {
tenants: { total: data.tenants.length, active: activeTenants },
users: { total: data.users.length, active: activeUsers },
audit: { recent: data.audit.length, errors: errorEvents },
health: data.health?.status ?? "unconfigured",
}
}, [data])
useRegisterContext("overview", stats)
return (
<AppShell>
<PageHeader
title="Overview"
description={
<>
Live snapshot of the platform tenants, users, recent activity, and health.
{refreshedAt ? (
<>
{" "}
Refreshed {refreshedAt.toLocaleTimeString()}.
</>
) : null}
</>
}
actions={
<Button
data-action="overview-refresh"
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
>
<RefreshCw className={loading ? "size-4 animate-spin" : "size-4"} />
Refresh
</Button>
}
/>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatTile
to="/tenants"
dataAction="overview-tile-tenants"
icon={Building2}
label="Tenants"
value={stats.tenants.total}
sub={`${stats.tenants.active} active`}
loading={loading}
/>
<StatTile
to="/users"
dataAction="overview-tile-users"
icon={UsersIcon}
label="Users"
value={stats.users.total}
sub={`${stats.users.active} active`}
loading={loading}
/>
<StatTile
to="/activity"
dataAction="overview-tile-activity"
icon={Activity}
label="Recent events"
value={stats.audit.recent}
sub={
stats.audit.errors > 0
? `${stats.audit.errors} error${stats.audit.errors === 1 ? "" : "s"}`
: "no errors"
}
loading={loading}
tone={stats.audit.errors > 0 ? "warning" : "default"}
/>
<StatTile
to="/monitoring"
dataAction="overview-tile-health"
icon={HeartPulse}
label="Platform health"
value={statusLabel(stats.health)}
sub={data.health ? `as of ${new Date(data.health.checked_at).toLocaleTimeString()}` : "unreachable"}
loading={loading}
tone={statusTone(stats.health)}
/>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="flex-row items-center justify-between gap-2">
<div>
<CardTitle>Recent activity</CardTitle>
<CardDescription>
Latest audit events across the platform.
</CardDescription>
</div>
<Link
to="/activity"
data-action="overview-activity-all"
className="text-xs font-medium text-muted-foreground hover:text-foreground"
>
View all
</Link>
</CardHeader>
<CardContent>
<RecentActivity logs={data.audit} loading={loading} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Subsystems</CardTitle>
<CardDescription>
Live probe of each platform subsystem.
</CardDescription>
</CardHeader>
<CardContent>
<SubsystemList health={data.health} loading={loading} />
</CardContent>
</Card>
</div>
</AppShell>
)
}
function StatTile({
to,
dataAction,
icon: Icon,
label,
value,
sub,
loading,
tone = "default",
}: {
to: string
dataAction: string
icon: React.ComponentType<{ className?: string }>
label: string
value: number | string
sub: string
loading: boolean
tone?: "default" | "warning" | "error" | "ok"
}) {
const accent =
tone === "error"
? "border-destructive/40 bg-destructive/5"
: tone === "warning"
? "border-amber-500/40 bg-amber-500/5"
: tone === "ok"
? "border-emerald-500/40 bg-emerald-500/5"
: ""
return (
<Link
to={to}
data-action={dataAction}
className="group block focus:outline-none"
>
<Card className={`h-full transition-colors hover:border-foreground/20 ${accent}`}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</span>
<Icon className="size-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent className="flex flex-col gap-1">
{loading ? (
<Skeleton className="h-9 w-20" />
) : (
<span className="text-3xl font-semibold tabular-nums">{value}</span>
)}
{loading ? (
<Skeleton className="h-3 w-24" />
) : (
<span className="text-xs text-muted-foreground">{sub}</span>
)}
</CardContent>
</Card>
</Link>
)
}
function RecentActivity({ logs, loading }: { logs: AuditLog[]; loading: boolean }) {
if (loading && logs.length === 0) {
return (
<p className="py-4 text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading
</p>
)
}
if (logs.length === 0) {
return (
<p className="py-4 text-sm text-muted-foreground">No recent events.</p>
)
}
return (
<ul className="flex flex-col divide-y">
{logs.slice(0, 8).map((l) => (
<li
key={l.id}
className="flex items-start justify-between gap-3 py-2.5 text-sm"
>
<div className="flex min-w-0 flex-col">
<span className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{l.action}
</code>
<span className="truncate text-xs text-muted-foreground">
{l.resource_type}
{l.resource_id ? ` · ${l.resource_id.slice(0, 8)}` : ""}
</span>
</span>
<span className="truncate text-xs text-muted-foreground">
{l.user?.email ?? "system"}
</span>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<SeverityDot severity={l.severity} />
<time
className="text-[11px] text-muted-foreground"
dateTime={l.inserted_at}
>
{timeAgo(l.inserted_at)}
</time>
</div>
</li>
))}
</ul>
)
}
function SubsystemList({
health,
loading,
}: {
health: OverallHealth | null
loading: boolean
}) {
if (loading && !health) {
return (
<p className="py-4 text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Probing
</p>
)
}
if (!health) {
return (
<p className="py-4 text-sm text-muted-foreground">
Health endpoint unreachable.
</p>
)
}
return (
<ul className="flex flex-col divide-y">
{SUBSYSTEMS.map((sys) => {
const sub = health.subsystems[sys]
return (
<li
key={sys}
className="flex items-center justify-between gap-3 py-2.5 text-sm"
>
<span className="font-medium capitalize">{labelFor(sys)}</span>
<span className="flex items-center gap-2">
<StatusIcon status={sub?.status ?? "unconfigured"} />
<span className="text-xs text-muted-foreground">
{sub?.message ?? statusLabel(sub?.status ?? "unconfigured")}
</span>
</span>
</li>
)
})}
</ul>
)
}
function SeverityDot({ severity }: { severity: string }) {
const tone =
severity === "critical" || severity === "error"
? "bg-destructive"
: severity === "warning"
? "bg-amber-500"
: "bg-emerald-500"
return (
<span
aria-label={severity}
title={severity}
className={`size-2 rounded-full ${tone}`}
/>
)
}
function StatusIcon({ status }: { status: HealthStatus }) {
if (status === "ok")
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />
if (status === "degraded")
return <AlertTriangle className="size-4 text-amber-500" aria-label="degraded" />
if (status === "error")
return <CircleAlert className="size-4 text-destructive" aria-label="error" />
return <CircleAlert className="size-4 text-muted-foreground" aria-label="unconfigured" />
}
function labelFor(sys: HealthSubsystem): string {
if (sys === "api") return "API"
if (sys === "db") return "Database"
return sys
}
function statusLabel(status: HealthStatus | string): string {
if (status === "ok") return "Healthy"
if (status === "degraded") return "Degraded"
if (status === "error") return "Down"
return "Unknown"
}
function statusTone(status: HealthStatus | string): "default" | "ok" | "warning" | "error" {
if (status === "ok") return "ok"
if (status === "degraded") return "warning"
if (status === "error") return "error"
return "default"
}
function timeAgo(iso: string): string {
const t = new Date(iso).getTime()
if (Number.isNaN(t)) return ""
const diff = Date.now() - t
const sec = Math.round(diff / 1000)
if (sec < 60) return `${sec}s ago`
const min = Math.round(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.round(min / 60)
if (hr < 24) return `${hr}h ago`
const d = Math.round(hr / 24)
return `${d}d ago`
}