/** * @file Dashboard.tsx * @description Main dashboard page showing real-time stats, active agents, recent activity feed, an Analytics tab, and a System Health tab for Claude Code sessions. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../lib/dataScope` * - `../components/StatCard` * - `../components/AgentCard` * - `../components/StatusBadge` * - `../components/EmptyState` * - `../components/Tip` * - `../lib/format` * - `../lib/types` * * ## Public surface * - `Dashboard` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **Dashboard** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useEffect, useState, useCallback, useSyncExternalStore, useMemo, useRef, type ReactNode, } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { LayoutDashboard, FolderOpen, Bot, Zap, DollarSign, Activity, ArrowRight, RefreshCw, GitBranch, ChevronDown, ChevronRight, CircleDot, Server, HardDrive, Plug, Cpu, BarChart3, ShieldCheck, Database, Search, Download, Clock, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { useDataScope } from "../lib/dataScope"; import { StatCard } from "../components/StatCard"; import { AgentCard } from "../components/AgentCard"; import { AgentStatusBadge } from "../components/StatusBadge"; import { EmptyState } from "../components/EmptyState"; import { Tip } from "../components/Tip"; import { Skeleton, StatValueSkeleton, TextSkeleton } from "../components/Skeleton"; import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format"; import type { Stats, Agent, DashboardEvent, WSMessage, WorkflowData, Session, Analytics as AnalyticsData, CostResult, } from "../lib/types"; interface SystemInfo { db: { path: string; size: number; counts: Record; pragmas: { journal_mode: string; synchronous: number; auto_vacuum: number; encoding: string; foreign_keys: number; busy_timeout: number; }; load_stats: { m5: number; m15: number; h1: number }; }; hooks: { installed: boolean; path: string; hooks: Record }; server: { uptime: number; node_version: string; platform: string; ws_connections: number; memory: { rss: number; heapTotal: number; heapUsed: number; external: number }; cpu_load: number[]; arch: string; total_mem: number; free_mem: number; cpus: number; }; transcript_cache: { size: number; maxSize: number; hits: number; misses: number; keys: string[]; }; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function formatUptime(seconds: number): string { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); if (d > 0) return `${d}d ${h}h ${m}m`; if (h > 0) return `${h}h ${m}m`; return `${m}m`; } // ── Analytics tab (merged from the former standalone Analytics page) ───────── function ChartTooltip({ x, y, children }: { x: number; y: number; children: ReactNode }) { const nearRight = x > window.innerWidth - 200; return (
{children}
); } function useTooltip() { const [tooltip, setTooltip] = useState<{ x: number; y: number; content: ReactNode; } | null>(null); const show = (e: React.MouseEvent, content: ReactNode) => { setTooltip({ x: e.clientX, y: e.clientY, content }); }; const move = (e: React.MouseEvent) => { setTooltip((t) => t && { ...t, x: e.clientX, y: e.clientY }); }; const hide = () => setTooltip(null); const node = tooltip ? ( {tooltip.content} ) : null; return { show, move, hide, node }; } function cellColor(count: number, max: number) { if (count === 0) return "#161625"; // Log scale + RGB interpolation across a wide color ramp for maximum perceptual range const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1); // Ramp: near-black indigo → deep indigo → bright indigo → lavender type RGB = [number, number, number]; const stops: RGB[] = [ [22, 20, 60], // near-black indigo [55, 48, 163], // deep indigo [99, 102, 241], // bright indigo [199, 210, 254], // lavender ]; const scaled = t * (stops.length - 1); const lo = Math.min(Math.floor(scaled), stops.length - 2); const frac = scaled - lo; const [r1, g1, b1]: RGB = stops[lo] as RGB; const [r2, g2, b2]: RGB = stops[lo + 1] as RGB; const r = Math.round(r1 + (r2 - r1) * frac); const g = Math.round(g1 + (g2 - g1) * frac); const b = Math.round(b1 + (b2 - b1) * frac); return `rgb(${r},${g},${b})`; } function Heatmap({ weeks }: { weeks: Array> }) { const { show, move, hide, node } = useTooltip(); const { t, i18n } = useTranslation(["analytics", "common"]); const locale = i18n.resolvedLanguage ?? i18n.language; const monthLabels = useMemo( () => Array.from({ length: 12 }, (_, month) => new Intl.DateTimeFormat(locale, { month: "short" }).format(new Date(2026, month, 1)) ), [locale] ); const dayNames = useMemo( () => Array.from({ length: 7 }, (_, day) => new Intl.DateTimeFormat(locale, { weekday: "short" }).format( new Date(2026, 0, 4 + day) // Jan 4, 2026 is Sunday ) ), [locale] ); // Labels on the left: Sun (0), Tue (2), Thu (4) const dayLabels = [dayNames[0], "", dayNames[2], "", dayNames[4], "", ""]; const maxCount = Math.max(...weeks.flatMap((w) => w.map((c) => c.count)), 1); // Compute month label positions accurately. A label is ~3 columns wide (each // column is 16px, a 3-letter month ≈ 22px). The very first month is often a // stub - the 52-week window starts mid-month, so e.g. "May" sits at col 0 and // "Jun" at col 1 and they overlap. Drop the *stub* (the earlier, partial one) // rather than the full month after it, so the next label isn't pushed away. const monthPositions = useMemo(() => { const MIN_LABEL_GAP = 3; // columns a label needs to itself const starts: Array<{ label: string; col: number }> = []; let prevMonth = -1; weeks.forEach((week, wi) => { const firstCell = week[0]; if (!firstCell) return; const parts = firstCell.date.split("-").map(Number); const m = (parts[1] || 1) - 1; // 0-indexed month if (m !== prevMonth) { starts.push({ label: monthLabels[m] ?? "", col: wi }); prevMonth = m; } }); // Drop a label when the *next* month starts too soon after it (it would // collide). Suppressing the earlier label leaves the later, full month in // place - no gap is introduced. return starts.filter((s, i) => { const next = starts[i + 1]; return !next || next.col - s.col >= MIN_LABEL_GAP; }); }, [weeks, monthLabels]); return (
{node} {/* Month labels */}
{monthPositions.map((mp, i) => (
{mp.label}
))}
{/* Day labels */}
{dayLabels.map((d, i) => (
{d}
))}
{/* Cells */} {weeks.map((week, wi) => (
{week.map((cell) => (
{ const parts = cell.date.split("-").map(Number); const y = parts[0] || 0; const m = (parts[1] || 1) - 1; const d = parts[2] || 1; const date = new Date(y, m, d, 12); const dow = date.getDay(); show( e, <> {dayNames[dow] ?? ""}, {cell.date} {t("eventCountLabel", { count: cell.count })} ); }} onMouseMove={move} onMouseLeave={hide} style={{ width: 13, height: 13, borderRadius: 2, backgroundColor: cellColor(cell.count, maxCount), border: "1px solid rgba(255,255,255,0.04)", flexShrink: 0, cursor: "default", }} /> ))}
))}
{/* Legend */}
{t("less")} {[0, 0.25, 0.5, 0.75, 1].map((f) => { const v = Math.round(f * maxCount); return (
); })} {t("more")}
); } function AnalyticsSparkline({ data, color = "#6366f1", }: { data: Array<{ date: string; count: number }>; color?: string; }) { const { t } = useTranslation("analytics"); const { show, move, hide, node } = useTooltip(); const max = Math.max(...data.map((d) => d.count), 1); return (
{node} {data.map(({ date, count }) => (
show( e, <> {date} {t("eventCountLabel", { count })} ) } onMouseMove={move} onMouseLeave={hide} /> ))}
); } function CostTrendLine({ data, color = "#10b981", }: { data: Array<{ date: string; cost: number }>; color?: string; }) { const { show, move, hide, node } = useTooltip(); if (data.length === 0) return null; const width = 320; const height = 88; const padX = 8; const padY = 8; const min = Math.min(...data.map((d) => d.cost), 0); const max = Math.max(...data.map((d) => d.cost), 0); const span = Math.max(max - min, 0.0001); const step = data.length > 1 ? (width - padX * 2) / (data.length - 1) : 0; const points = data.map(({ date, cost }, i) => { const x = padX + i * step; const y = height - padY - ((cost - min) / span) * (height - padY * 2); return { date, cost, x, y }; }); const linePoints = points.map((p) => `${p.x},${p.y}`).join(" "); const firstX = points[0]?.x ?? padX; const lastX = points[points.length - 1]?.x ?? padX; const areaPoints = `${firstX},${height - padY} ${linePoints} ${lastX},${height - padY}`; return (
{node} {points.map((point) => ( show( e, <> {point.date} {fmtCostFull(point.cost)} ) } onMouseMove={move} onMouseLeave={hide} /> ))}
); } function BarRow({ label, count, max, color = "bg-accent", pct, }: { label: string; count: number; max: number; color?: string; pct?: number; }) { const width = pct !== undefined ? pct : max > 0 ? Math.round((count / max) * 100) : 0; return (
{label}
{fmt(count)}
); } function CostBarRow({ label, cost, max, color = "bg-status-success", }: { label: string; cost: number; max: number; color?: string; }) { const width = max > 0 ? Math.max(2, Math.round((cost / max) * 100)) : 0; return (
{label}
{fmtCost(cost)}
); } function DonutChart({ segments, formatTotal, }: { segments: Array<{ label: string; value: number; color: string }>; formatTotal?: (total: number) => string; }) { const { t } = useTranslation(["analytics", "common"]); const { show, move, hide, node } = useTooltip(); const total = segments.reduce((s, g) => s + g.value, 0); if (total === 0) return
{t("common:noData")}
; const r = 52; const cx = 64; const cy = 64; const stroke = 18; const circumference = 2 * Math.PI * r; // offset starts at circumference/4 (top of circle) and decrements by each segment's arc. // strokeDashoffset = offset (not negated) is the correct formula for starting at 12 o'clock. let offset = circumference / 4; return (
{node} {segments.map(({ label, value, color }, i) => { const dash = (value / total) * circumference; const gap = circumference - dash; const pct = Math.round((value / total) * 100); const currentOffset = offset; offset -= dash; return ( show( e, <> {label} {pct}% ) } onMouseMove={move} onMouseLeave={hide} /> ); })} {(formatTotal ?? fmt)(total)} {t("common:total_lower")}
{segments.map(({ label, value, color }) => (
{label} {Math.round((value / total) * 100)}%
))}
); } function StatPill({ label, value, raw, sub, icon: Icon, color = "text-accent", loading = false, }: { label: string; value: string | number; raw?: string; sub?: string; icon: React.ElementType; color?: string; loading?: boolean; }) { return (
{label}
{loading ? ( ) : (

{raw ? {value} : value}

)} {loading ? ( ) : ( sub &&

{sub}

)}
); } /** A single chart-card placeholder: title bar, chart body, two footer lines. * Uses the shared pulsing so the whole grid shimmers while data * loads (mirrors the chart-card layout so nothing jumps when data arrives). */ function ChartCardSkeleton({ className = "", bodyH = "h-32", }: { className?: string; bodyH?: string; }) { return (
); } /** Pulsing placeholder for the Analytics chart region (heatmap + sparkline row, * tab bar, and the three-up chart grid) shown while the analytics payload is * still being fetched. */ function AnalyticsChartsSkeleton() { return (
); } function AnalyticsTab() { const { t, i18n } = useTranslation("analytics"); const [data, setData] = useState(null); const [costData, setCostData] = useState(null); const [lastUpdate, setLastUpdate] = useState(new Date()); const [loading, setLoading] = useState(true); const [analyticsSubTab, setAnalyticsSubTab] = useState< "tokens" | "cost" | "workflow" | "productivity" >("cost"); // Global data scope; a change re-runs `load` (api injects the `sources` param). const [scope] = useDataScope(); const load = useCallback(async () => { try { const [result, cost] = await Promise.all([ api.analytics.get(), api.pricing.totalCost().catch(() => null), ]); setData(result); setCostData(cost); setLastUpdate(new Date()); } finally { setLoading(false); } }, [scope]); useEffect(() => { load(); const interval = setInterval(load, 15000); return () => clearInterval(interval); }, [load]); useEffect(() => { return eventBus.subscribe((msg) => { if ( msg.type === "session_created" || msg.type === "session_updated" || msg.type === "new_event" || msg.type === "agent_created" || isRemoteDataRefreshMessage(msg) ) { load(); } }); }, [load]); function handleExport() { if (!data) return; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `analytics-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); } // Format a local Date object as a YYYY-MM-DD string for heatmap lookups function localDateStr(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } // Server now returns dates grouped by the user's local timezone (via tz_offset), // so we can use them directly without conversion. const dailyMap: Record = {}; for (const d of data?.daily_events ?? []) { dailyMap[d.date] = (dailyMap[d.date] ?? 0) + d.count; } // Build heatmap: 52 weeks × 7 days // Align start to Sunday (day 0) so row indices match day-of-week labels correctly. const today = new Date(); today.setHours(12, 0, 0, 0); // Normalize to noon const startDate = new Date(today); startDate.setDate(today.getDate() - 364); // Exactly 52 weeks ago // Roll back to the previous Sunday const startDow = startDate.getDay(); startDate.setDate(startDate.getDate() - startDow); const weeks: Array> = []; for (let w = 0; w < 53; w++) { const week: Array<{ date: string; count: number }> = []; for (let d = 0; d < 7; d++) { const cell = new Date(startDate); cell.setDate(startDate.getDate() + w * 7 + d); if (cell > today) break; const dateStr = localDateStr(cell); week.push({ date: dateStr, count: dailyMap[dateStr] ?? 0 }); } if (week.length > 0) weeks.push(week); } // Last 30 days for sparkline const last30 = Array.from({ length: 30 }, (_, i) => { const d = new Date(today); d.setDate(today.getDate() - (29 - i)); const dateStr = localDateStr(d); return { date: dateStr, count: dailyMap[dateStr] ?? 0 }; }); // Convert daily_sessions - server already returns local dates const dailySessionsLocal: Array<{ date: string; count: number }> = []; const sessMap: Record = {}; for (const d of data?.daily_sessions ?? []) { sessMap[d.date] = (sessMap[d.date] ?? 0) + d.count; } for (const [date, count] of Object.entries(sessMap)) { dailySessionsLocal.push({ date, count }); } dailySessionsLocal.sort((a, b) => a.date.localeCompare(b.date)); // Convert daily_costs - server already returns local dates const dailyCostsLocal: Array<{ date: string; cost: number }> = []; const costMap: Record = {}; for (const d of costData?.daily_costs ?? []) { costMap[d.date] = (costMap[d.date] ?? 0) + d.cost; } for (const [date, cost] of Object.entries(costMap)) { dailyCostsLocal.push({ date, cost }); } dailyCostsLocal.sort((a, b) => a.date.localeCompare(b.date)); const dailyCostLast30 = Array.from({ length: 30 }, (_, i) => { const d = new Date(today); d.setDate(today.getDate() - (29 - i)); const dateStr = localDateStr(d); return { date: dateStr, cost: costMap[dateStr] ?? 0 }; }); const peakCostDay = dailyCostLast30.reduce( (max, curr) => (curr.cost > max.cost ? curr : max), dailyCostLast30[0] ?? { date: "", cost: 0 } ); const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0); const costBreakdown = [...(costData?.breakdown ?? [])] .filter((b) => b.cost > 0) .sort((a, b) => b.cost - a.cost); const locale = i18n.resolvedLanguage ?? i18n.language; const weekdayOrder = [1, 2, 3, 4, 5, 6, 0]; const weekdayCosts = weekdayOrder.map((dow) => { const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format( new Date(Date.UTC(2026, 0, 4 + dow)) ); const cost = dailyCostLast30 .filter((day) => new Date(day.date + "T12:00:00").getDay() === dow) .reduce((sum, day) => sum + day.cost, 0); return { label, cost }; }); const maxWeekdayCost = Math.max(...weekdayCosts.map((d) => d.cost), 1); const totalTokens = (data?.tokens.total_input ?? 0) + (data?.tokens.total_output ?? 0) + (data?.tokens.total_cache_read ?? 0) + (data?.tokens.total_cache_write ?? 0); const tokenMixSegments = [ { label: t("common:token.input"), value: data?.tokens.total_input ?? 0, color: "#60a5fa" }, { label: t("common:token.output"), value: data?.tokens.total_output ?? 0, color: "#34d399" }, { label: t("common:token.cacheRead"), value: data?.tokens.total_cache_read ?? 0, color: "#a78bfa", }, { label: t("common:token.cacheWrite"), value: data?.tokens.total_cache_write ?? 0, color: "#facc15", }, ].filter((s) => s.value > 0); const maxToolCount = data?.tool_usage[0]?.count ?? 1; const maxAgentTypeCount = data?.agent_types[0]?.count ?? 1; const maxEventTypeCount = data?.event_types[0]?.count ?? 1; const cacheHitPct = totalTokens > 0 ? Math.round(((data?.tokens.total_cache_read ?? 0) / totalTokens) * 100) : 0; const sessionOutcomeSegments = [ { label: t("common:status.completed"), value: data?.sessions_by_status?.completed ?? 0, color: "#8b5cf6", }, { label: t("common:status.active"), value: data?.sessions_by_status?.active ?? 0, color: "#10b981", }, { label: t("common:status.error"), value: data?.sessions_by_status?.error ?? 0, color: "#ef4444", }, { label: t("common:status.abandoned"), value: data?.sessions_by_status?.abandoned ?? 0, color: "#f59e0b", }, ].filter((s) => s.value > 0); const agentStatusSegments = [ { label: t("common:status.completed"), value: data?.agents_by_status?.completed ?? 0, color: "#8b5cf6", }, { label: t("common:status.working"), value: data?.agents_by_status?.working ?? 0, color: "#10b981", }, { label: t("common:status.waiting"), value: data?.agents_by_status?.waiting ?? 0, color: "#eab308", }, { label: t("common:status.error"), value: data?.agents_by_status?.error ?? 0, color: "#ef4444", }, ].filter((s) => s.value > 0); const EVENT_TYPE_COLORS: Record = { PreToolUse: "bg-status-success", PostToolUse: "bg-blue-500", Stop: "bg-violet-400", SubagentStop: "bg-yellow-400", Notification: "bg-orange-400", }; return (
{/* Toolbar */}
{lastUpdate.toLocaleTimeString()}
{/* Key stats */}
{!data ? ( ) : ( <> {/* Activity heatmap + 30-day sparkline */}

{t("eventActivity")}

{t("last30Days")}

{t("dailyEventCount")}

{last30[0]?.date?.slice(5)} {last30[last30.length - 1]?.date?.slice(5)}
{t("peakDay")} d.count)).toLocaleString()}> {fmt(Math.max(...last30.map((d) => d.count)))} {" "} {t("common:events")}
{t("total30d")} s + d.count, 0).toLocaleString()}> {fmt(last30.reduce((s, d) => s + d.count, 0))} {" "} {t("common:events")}
{/* Tabs */}
{( [ { key: "cost" as const, label: t("tabs.costAnalytics") }, { key: "tokens" as const, label: t("tabs.tokenAnalytics") }, { key: "productivity" as const, label: t("tabs.productivityAnalytics") }, { key: "workflow" as const, label: t("tabs.workflowIntelligence") }, ] as const ).map(({ key, label }) => ( ))}
{analyticsSubTab === "tokens" && (
{/* Token bars */}

{t("tokenDistribution")}

{[ { label: t("common:token.input"), value: data?.tokens.total_input ?? 0, color: "bg-blue-500", }, { label: t("common:token.output"), value: data?.tokens.total_output ?? 0, color: "bg-status-success", }, { label: t("common:token.cacheRead"), value: data?.tokens.total_cache_read ?? 0, color: "bg-violet-400", }, { label: t("common:token.cacheWrite"), value: data?.tokens.total_cache_write ?? 0, color: "bg-yellow-400", }, ].map(({ label, value, color }) => ( ))}
{t("common:token.totalTokens")} {fmt(totalTokens)}
{t("cacheEfficiency")} {cacheHitPct}%
{/* Token summary */}

{t("tokenBreakdown")}

{[ { label: t("common:token.input"), value: data?.tokens.total_input ?? 0, color: "text-blue-500", }, { label: t("common:token.output"), value: data?.tokens.total_output ?? 0, color: "text-status-success", }, { label: t("common:token.cacheRead"), value: data?.tokens.total_cache_read ?? 0, color: "text-violet-400", }, { label: t("common:token.cacheWrite"), value: data?.tokens.total_cache_write ?? 0, color: "text-yellow-400", }, { label: t("common:total"), value: totalTokens, color: "text-fg-primary" }, ].map(({ label, value, color }) => (
{label} {value.toLocaleString()}
))}
{totalTokens === 0 && (

{t("tokenInfo")}

)}
{/* Token mix donut */}

{t("tokenMix")}

{tokenMixSegments.length === 0 ? (

{t("common:noData")}

) : ( <> fmt(total)} />
{tokenMixSegments.map((segment) => (
{segment.label} {fmt(segment.value)}
))}
)}
)} {analyticsSubTab === "cost" && (
{/* Daily cost trends */}

{t("dailyCostTrends")}

{dailyCostsLocal.length === 0 ? (

{t("noDailyCostData")}

) : ( <>

{t("costPerDay")}

{dailyCostLast30[0]?.date?.slice(5)} {dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)}
{t("peakCostDay")} {fmtCost(peakCostDay.cost)}
{t("totalCost30d")} {fmtCost(totalCost30d)}
)}
{/* Cost by model */}

{t("costByModel")}

{costBreakdown.length > 0 ? ( <> ({ label: formatModelName(b.model) ?? b.model, value: Math.round(b.cost * 100), color: ["#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#ec4899"][ i % 6 ] ?? "#6b7280", }))} formatTotal={(cents) => fmtCost(cents / 100)} />
{costBreakdown.map((b) => (
{formatModelName(b.model)} {fmtCost(b.cost)}
))}
{t("common:total")} {fmtCost(costData?.total_cost ?? 0)}
) : (

{t("noCostData")}

)}
{/* Cost by weekday */}

{t("costByWeekday")}

{dailyCostsLocal.length === 0 ? (

{t("noDailyCostData")}

) : ( <>

{t("last30Days")}

{weekdayCosts.map(({ label, cost }) => ( ))}
{t("common:total")} {fmtCost(totalCost30d)}
)}
)} {analyticsSubTab === "workflow" && (
{/* Agent type distribution */}

{t("subagentTypes")}

{(data?.agent_types ?? []).length === 0 ? (

{t("noSubagentData")}

) : (
{(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => ( ))}
)}
{/* Agent status donut */}

{t("agentStatus")}

{t("totalAgentsLabel")} {fmt(data?.overview.total_agents ?? 0)}
{agentStatusSegments.map((s) => (
{s.label} {fmt(s.value)}
))}
{/* Event type breakdown */}

{t("eventTypes")}

{(data?.event_types ?? []).length === 0 ? (

{t("noEventData")}

) : (
{(data?.event_types ?? []).map(({ event_type, count }) => ( ))}
)}
)} {analyticsSubTab === "productivity" && (
{/* Top tools */}

{t("toolUsage")}

{(data?.tool_usage ?? []).length === 0 ? (

{t("noToolData")}

) : (
{(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => ( ))}
)}
{/* Session outcomes donut */}

{t("sessionOutcomes")}

{t("totalSessionsLabel")} {fmt(data?.overview.total_sessions ?? 0)}
{sessionOutcomeSegments.map((s) => (
{s.label} {fmt(s.value)}
))}
{/* Daily session trends */}

{t("dailySessionTrends")}

{dailySessionsLocal.length === 0 ? (

{t("noSessionTrendData")}

) : ( <>
{dailySessionsLocal .slice(-7) .reverse() .map(({ date, count }) => { const maxD = Math.max( ...(dailySessionsLocal.length > 0 ? dailySessionsLocal : [{ count: 1 }] ).map((d) => d.count) ); return (
{date.slice(5)}
{count}
); })}

{t("last7Days")}

)}
)}
)}
); } function SystemHealthTab() { const [info, setInfo] = useState(null); const [workflow, setWorkflow] = useState(null); const loadData = useCallback(async () => { try { const [infoRes, workflowRes] = await Promise.all([api.settings.info(), api.workflows.get()]); setInfo(infoRes as any); setWorkflow(workflowRes); } catch (e) { console.error(e); } }, []); useEffect(() => { loadData(); const int = setInterval(loadData, 30000); // 30s is sufficient for health metrics return () => clearInterval(int); }, [loadData]); const stats = useMemo(() => { if (!info || !workflow) return null; const totalEntries = (info.db.counts?.sessions || 0) + (info.db.counts?.agents || 0) + (info.db.counts?.events || 0); const sessPct = totalEntries > 0 ? ((info.db.counts?.sessions || 0) / totalEntries) * 100 : 0; const agentPct = totalEntries > 0 ? ((info.db.counts?.agents || 0) / totalEntries) * 100 : 0; const eventPct = totalEntries > 0 ? ((info.db.counts?.events || 0) / totalEntries) * 100 : 0; const modelStats = (workflow.modelDelegation?.tokensByModel || []) .sort((a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens)) .slice(0, 6); const totalTokens = modelStats.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0); const memUsedPct = info.server.total_mem > 0 ? (1 - info.server.free_mem / info.server.total_mem) * 100 : 0; const heapUsedPct = info.server.memory.heapTotal > 0 ? (info.server.memory.heapUsed / info.server.memory.heapTotal) * 100 : 0; return { totalEntries, sessPct, agentPct, eventPct, modelStats, totalTokens, memUsedPct, heapUsedPct, }; }, [info, workflow]); if (!info || !workflow || !stats) { return (
{[1, 2, 3, 4, 5, 6].map((i) => (
))}
); } const { totalEntries, sessPct, agentPct, eventPct, modelStats, totalTokens, memUsedPct, heapUsedPct, } = stats; const successRate = Math.max(0, Math.min(100, workflow.stats.successRate)); const errorRate = Math.max( 0, Math.min(100, workflow.errorPropagation?.errorRate ?? 100 - successRate) ); const cacheHitRate = Math.max( 0, Math.min( 100, ((info.transcript_cache?.hits ?? 0) / ((info.transcript_cache?.hits ?? 0) + (info.transcript_cache?.misses ?? 0) || 1)) * 100 ) ); // Concurrency histogram data const lanes = workflow.concurrency?.aggregateLanes || []; const maxLaneCount = Math.max(...lanes.map((l) => l.count), 1); // Tool flow top tools const topTools = (workflow.toolFlow?.toolCounts || []).slice(0, 8); const maxToolCount = topTools.length > 0 ? (topTools[0]?.count ?? 1) : 1; // Subagent effectiveness const effectiveness = (workflow.effectiveness || []).slice(0, 6); // Composite health score - clamped to [0, 100] for display safety const healthScore = Math.max( 0, Math.min( 100, successRate * 0.4 + cacheHitRate * 0.25 + Math.max(0, 100 - errorRate) * 0.25 + Math.max(0, 100 - Math.min(100, heapUsedPct)) * 0.1 ) ); return (
{/* Row 1: Runtime + Storage + Health Score */}
{/* Runtime Environment */}
Runtime
{info.server.cpus} cores · {info.server.arch}
Uptime {formatUptime(info.server.uptime)}
CPU (1/5/15m)
{(info.server.cpu_load || []).slice(0, 3).map((load, i) => ( info.server.cpus ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`} > {load.toFixed(2)} ))}
Node RSS {formatBytes(info.server.memory.rss)}
Host Memory {memUsedPct.toFixed(0)}%
90 ? "bg-status-danger" : memUsedPct > 70 ? "bg-status-warning" : "bg-status-success"}`} style={{ width: `${memUsedPct}%` }} />
V8 Heap {heapUsedPct.toFixed(0)}%
85 ? "bg-status-danger" : heapUsedPct > 60 ? "bg-status-warning" : "bg-blue-600"}`} style={{ width: `${heapUsedPct}%` }} />
{/* Storage Engine */}
Storage
⚡ {info.db.load_stats?.m5 ?? 0}/{info.db.load_stats?.m15 ?? 0}/ {info.db.load_stats?.h1 ?? 0}
Database {formatBytes(info.db.size)} · {info.db.pragmas?.journal_mode?.toUpperCase() || "WAL"}
{/* Pie chart + legend using same pattern as Analytics DonutChart */}
{(() => { const r = 38, cx = 48, cy = 48, circumference = 2 * Math.PI * r; const segments = [ { pct: sessPct, color: "#60a5fa" }, { pct: agentPct, color: "#8b5cf6" }, { pct: eventPct, color: "#34d399" }, ]; let offset = circumference / 4; return segments.map((seg, i) => { if (seg.pct <= 0) return null; const dash = (seg.pct / 100) * circumference; const gap = circumference - dash; const currentOffset = offset; offset -= dash; return ( ); }); })()} {totalEntries > 999 ? `${(totalEntries / 1000).toFixed(1)}K` : totalEntries} total
{[ { label: "Sessions", value: info.db.counts?.sessions ?? 0, color: "#60a5fa", pct: sessPct, }, { label: "Agents", value: info.db.counts?.agents ?? 0, color: "#8b5cf6", pct: agentPct, }, { label: "Events", value: info.db.counts?.events ?? 0, color: "#34d399", pct: eventPct, }, ].map((item) => (
{item.label} {Math.round(item.pct)}%
))}
{/* Health Score */}
Health Score
ⓘ Formula
{/* Ring gauge - centered */}
= 90 ? "#34d399" : healthScore >= 70 ? "#fbbf24" : "#f87171"} strokeWidth="10" strokeLinecap="round" strokeDasharray={`${healthScore * 3.016} ${301.6 - healthScore * 3.016}`} strokeDashoffset={301.6 / 4} className="transition-all duration-1000" /> {healthScore.toFixed(0)} / 100
{/* Sub-metrics row */}

Cache

{cacheHitRate.toFixed(0)}%

15% = critical`} >

Errors

{errorRate.toFixed(1)}%

Compact

{workflow.compaction?.totalCompactions ?? 0}

Saved

{((workflow.compaction?.tokensRecovered ?? 0) / 1000).toFixed(1)}K

{/* Row 2: Model Usage + Concurrency */}
{/* Model Token Distribution */}
Token Usage
{(totalTokens / 1000).toFixed(1)}K total
{modelStats.map((m, i) => { const pct = totalTokens > 0 ? ((m.input_tokens + m.output_tokens) / totalTokens) * 100 : 0; const colors = [ "bg-blue-500", "bg-violet-400", "bg-status-success", "bg-status-warning", "bg-pink-400", "bg-cyan-400", ]; return (
{formatModelName(m.model) ?? m.model}
{pct.toFixed(1)}%
); })} {modelStats.length === 0 && (

No model data

)}
{/* Concurrency Timeline */}
Concurrency
{lanes.length} intervals
{/* Sparkline-style bar chart matching Analytics sparkline */} l.count > 0).length}\nAvg: ${lanes.length > 0 ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) : "0"}`} >
{lanes.slice(-Math.min(lanes.length, 40)).map((lane, i) => { const barPct = maxLaneCount > 0 ? Math.max(4, Math.round((lane.count / maxLaneCount) * 100)) : 4; const color = lane.count > 5 ? "#f87171" : lane.count > 2 ? "#fbbf24" : lane.count > 0 ? "#34d399" : "#1e1e2e"; return (
); })} {lanes.length === 0 && (

No concurrency data

)}

Peak

{maxLaneCount}

l.count > 0).length} of ${lanes.length} intervals have active sessions.`} >

Active

{lanes.filter((l) => l.count > 0).length}

Avg

{lanes.length > 0 ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) : "0"}

{/* Row 3: Tool Usage + Subagent Effectiveness */}
{/* Tool Invocations */}
Tool Usage
top {topTools.length}
{topTools.map((tool, i) => { const pct = maxToolCount > 0 ? Math.round((tool.count / maxToolCount) * 100) : 0; const colors = [ "bg-status-warning", "bg-blue-500", "bg-status-success", "bg-violet-400", "bg-pink-400", "bg-cyan-400", "bg-status-danger", "bg-indigo-400", ]; return (
{tool.tool_name}
{tool.count > 999 ? `${(tool.count / 1000).toFixed(1)}K` : tool.count}
); })} {topTools.length === 0 && (

No tool data yet

)}
{/* Subagent Effectiveness */}
Subagent Effectiveness
{effectiveness.map((item, i) => { const color = item.successRate >= 90 ? "bg-status-success" : item.successRate >= 70 ? "bg-status-warning" : "bg-status-danger"; return (
{item.subagent_type || "default"}
= 90 ? "text-status-success" : item.successRate >= 70 ? "text-status-warning" : "text-status-danger"}`} > {item.successRate.toFixed(0)}%
); })} {effectiveness.length === 0 && (

No subagent data yet

)}
{/* Row 4: Integration Gateway + Platform Config */}
{/* Integration Gateway */}
Integration
{info.hooks.installed ? "Active" : "Offline"}
{Object.entries(info.hooks.hooks || {}).length > 0 ? (
{Object.entries(info.hooks.hooks).map(([cwd, active]) => (
{cwd.split("/").pop() || cwd}
))}
) : (

No project hooks registered

)}

WebSocket Active

{info.server.ws_connections} connection {info.server.ws_connections !== 1 ? "s" : ""}

{/* Platform Config */}
Platform
{info.server.node_version}
{[ { label: "Journal Mode", value: info.db.pragmas?.journal_mode?.toUpperCase() || "WAL", }, { label: "Synchronous", value: info.db.pragmas?.synchronous === 2 ? "FULL" : info.db.pragmas?.synchronous === 1 ? "NORMAL" : "OFF", }, { label: "Auto-Vacuum", value: info.db.pragmas?.auto_vacuum > 0 ? "FULL" : "OFF" }, { label: "Foreign Keys", value: info.db.pragmas?.foreign_keys ? "ON" : "OFF" }, { label: "Busy Timeout", value: `${info.db.pragmas?.busy_timeout || 5000}ms` }, { label: "Platform", value: `${info.server.platform} / ${info.server.arch}` }, ].map((row) => (
{row.label} {row.value}
))}
{info.db.path}
); } export function Dashboard() { const navigate = useNavigate(); const { t } = useTranslation("dashboard"); // Persistent Tab State const [activeTab, setActiveTab] = useState<"monitor" | "analytics" | "health">(() => { return ( (localStorage.getItem("dashboard_tab") as "monitor" | "analytics" | "health") || "monitor" ); }); useEffect(() => { localStorage.setItem("dashboard_tab", activeTab); }, [activeTab]); const [stats, setStats] = useState(null); const [activeAgents, setActiveAgents] = useState([]); const [recentEvents, setRecentEvents] = useState([]); const [totalCost, setTotalCost] = useState(null); const [allSubagents, setAllSubagents] = useState([]); const [sessionsById, setSessionsById] = useState>(new Map()); const [expandedAgents, setExpandedAgents] = useState>(new Set()); const [error, setError] = useState(null); // Dynamic item counts based on available container height const agentsContainerRef = useRef(null); const activityContainerRef = useRef(null); const [visibleAgentCount, setVisibleAgentCount] = useState(5); const [visibleActivityCount, setVisibleActivityCount] = useState(8); useEffect(() => { const AGENT_ROW_H = 56; // ~px per agent card row const ACTIVITY_ROW_H = 44; // ~px per activity row const HEADER_H = 40; // section header height function recalc() { if (agentsContainerRef.current) { const h = agentsContainerRef.current.clientHeight; setVisibleAgentCount(Math.max(3, Math.floor((h - HEADER_H) / AGENT_ROW_H))); } if (activityContainerRef.current) { const h = activityContainerRef.current.clientHeight; setVisibleActivityCount(Math.max(3, Math.floor((h - HEADER_H) / ACTIVITY_ROW_H))); } } const ro = new ResizeObserver(recalc); if (agentsContainerRef.current) ro.observe(agentsContainerRef.current); if (activityContainerRef.current) ro.observe(activityContainerRef.current); recalc(); return () => ro.disconnect(); }, [activeTab]); // Global data scope; a change re-runs `load` (the api layer injects the // matching `sources` param into the calls below). const [scope] = useDataScope(); const load = useCallback(async () => { try { const [statsRes, workingRes, waitingRes, eventsRes, costRes, sessionsRes] = await Promise.all( [ api.stats.get(), api.agents.list({ status: "working", limit: 20 }), api.agents.list({ status: "waiting", limit: 20 }), api.events.list({ limit: 30 }), api.pricing.totalCost(), api.sessions.list({ status: "active", limit: 100 }), ] ); setStats(statsRes); const active = [...workingRes.agents, ...waitingRes.agents]; setActiveAgents(active); setRecentEvents(eventsRes.events); setTotalCost(costRes.total_cost); setSessionsById(new Map(sessionsRes.sessions.map((s) => [s.id, s]))); setError(null); // Fetch all subagents for each active main agent's session const activeSessionIds = [ ...new Set(active.filter((a) => a.type === "main").map((a) => a.session_id)), ]; const subagentResults = await Promise.all( activeSessionIds.map((sid) => api.agents.list({ session_id: sid, limit: 100 })) ); const subs = subagentResults.flatMap((r) => r.agents).filter((a) => a.type === "subagent"); setAllSubagents(subs); } catch (err) { setError(err instanceof Error ? err.message : t("failedLoad")); } }, [t, scope]); useEffect(() => { load(); const interval = setInterval(load, 10000); return () => clearInterval(interval); }, [load]); // Auto-expand agents with active subagents (walk up the full parent chain) useEffect(() => { const parentsWithActive = new Set(); for (const a of allSubagents) { if (a.parent_agent_id && a.status === "working") { parentsWithActive.add(a.parent_agent_id); } } if (parentsWithActive.size === 0) return; // No-op: skip state update entirely const subMap = new Map(allSubagents.map((a) => [a.id, a])); const toExpand = new Set(); for (const pid of parentsWithActive) { let cur = pid; while (cur) { toExpand.add(cur); const parent = subMap.get(cur); cur = parent?.parent_agent_id ?? ""; } } setExpandedAgents((prev) => { // Only update if there are genuinely new IDs to add const newIds = [...toExpand].filter((id) => !prev.has(id)); if (newIds.length === 0) return prev; // Stable reference - no re-render return new Set([...prev, ...newIds]); }); }, [allSubagents]); useEffect(() => { const debounceRef = { timer: null as ReturnType | null }; return eventBus.subscribe((msg: WSMessage) => { if ( msg.type === "agent_created" || msg.type === "agent_updated" || msg.type === "session_created" || msg.type === "session_updated" || isRemoteDataRefreshMessage(msg) ) { // Debounce rapid-fire updates (e.g., 5 agents created in 100ms) if (debounceRef.timer) clearTimeout(debounceRef.timer); debounceRef.timer = setTimeout(load, 300); } if (msg.type === "new_event") { setRecentEvents((prev) => { const newEvent = msg.data as DashboardEvent; // Deduplicate by event ID to prevent WS + polling race condition if (newEvent.id && prev.some((e) => e.id === newEvent.id)) return prev; return [newEvent, ...prev.slice(0, 14)]; }); } }); }, [load]); const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); // Memoize agent tree structure to avoid recalculating on every render const agentTree = useMemo(() => { const childrenByParent = new Map(); for (const a of allSubagents) { if (a.parent_agent_id) { const list = childrenByParent.get(a.parent_agent_id) || []; list.push(a); childrenByParent.set(a.parent_agent_id, list); } } // Pre-compute descendant counts with memoization (avoids exponential recursion) const descendantCache = new Map(); function getDescendants(id: string): { total: number; active: number } { if (descendantCache.has(id)) return descendantCache.get(id)!; // Seed a zero sentinel before recursing so a cyclic parent_agent_id // (corrupt data) resolves to the cached value instead of looping forever. descendantCache.set(id, { total: 0, active: 0 }); const kids = childrenByParent.get(id) || []; const result = kids.reduce( (acc, k) => { const child = getDescendants(k.id); return { total: acc.total + 1 + child.total, active: acc.active + (k.status === "working" ? 1 : 0) + child.active, }; }, { total: 0, active: 0 } ); descendantCache.set(id, result); return result; } // Pre-warm cache for all nodes for (const a of allSubagents) getDescendants(a.id); return { childrenByParent, getDescendants }; }, [allSubagents]); if (error) { return (

{t("failedConnect")}

{error}

); } return (

{t("title")}

{wsConnected ? ( {t("common:live")} ) : ( {t("common:offline")} )}

{t("subtitle")}

{/* Tabs */}
{activeTab === "monitor" ? (
{/* Stats grid - 2 rows of 3 avoids the 6-column squeeze */}
a.status === "working").length : ""} icon={GitBranch} accentColor="text-violet-400" trend={stats ? `${allSubagents.length}${t("totalTrend")}` : undefined} loading={!stats} />
{/* Active agents */}

{t("activeAgentsSection")}

{activeAgents.length === 0 ? ( ) : (
{(() => { const { childrenByParent, getDescendants } = agentTree; function renderAgentNode( agent: Agent, depth: number, ancestors: Set = new Set() ): ReactNode { // Guard against a cyclic parent_agent_id (corrupt data) so // the recursive render can't stack-overflow the page. if (ancestors.has(agent.id)) return null; const childAncestors = new Set(ancestors).add(agent.id); const children = childrenByParent.get(agent.id) || []; const isExpanded = expandedAgents.has(agent.id); const hasChildren = children.length > 0; const isSubagent = depth > 0; const { total: totalDesc, active: activeDesc } = hasChildren ? getDescendants(agent.id) : { total: 0, active: 0 }; const toggleExpanded = () => setExpandedAgents((prev) => { const next = new Set(prev); if (next.has(agent.id)) next.delete(agent.id); else next.add(agent.id); return next; }); return (
{hasChildren && ( )} {/* Reserve the chevron column even when this row has no chevron - without this, peer top-level mains would line up at different x positions depending on whether they have subagents, making chevron-having mains look indented like a subagent of the chevron-less main above them. A muted leaf-marker icon fills the slot so the column reads as deliberately empty rather than as a misalignment. */} {!hasChildren && !isSubagent && ( )} {isSubagent && ( )}
{hasChildren && isExpanded && (
{children.map((child) => renderAgentNode(child, depth + 1, childAncestors) )}
)} {hasChildren && !isExpanded && ( )}
); } // Build the set of agent ids that will be rendered as // descendants under the visible main-agent trees, so the // orphan-subagent block below doesn't render them a // second time at the root. Previously the orphan filter // was `a.type === "subagent"` with no parentage check, // which surfaced every nested subagent twice: once // indented under its main, and once flush at root level. const visibleMains = activeAgents .filter((a) => a.type === "main") .slice(0, visibleAgentCount); const renderedInTree = new Set(); for (const m of visibleMains) { const stack: string[] = [m.id]; while (stack.length) { const id = stack.pop()!; if (renderedInTree.has(id)) continue; renderedInTree.add(id); for (const child of childrenByParent.get(id) || []) { stack.push(child.id); } } } return ( <> {visibleMains.map((main) => renderAgentNode(main, 0))} {/* Only true orphans: subagents whose ancestor chain isn't already shown in a tree above. */} {activeAgents .filter((a) => a.type === "subagent" && !renderedInTree.has(a.id)) .map((agent) => (
))} ); })()}
)}
{/* Vertical Divider */}
{/* Recent activity */}

{t("recentActivity")}

{recentEvents.length === 0 ? ( ) : (
{recentEvents.slice(0, visibleActivityCount).map((event, i) => (
navigate(`/sessions/${event.session_id}`)} > {event.summary || event.event_type} {(() => { // Session label: real name when one exists, else the // short ID - keeps every activity row attributable. const sname = sessionsById.get(event.session_id)?.name?.trim() || ""; const isAuto = /^Session [0-9a-f]{8}$/i.test(sname); return ( {sname && !isAuto ? ( sname ) : ( {event.session_id.slice(0, 8)} )} ); })()} {event.tool_name && ( {event.tool_name} )} {timeAgo(event.created_at)}
))}
)}
) : activeTab === "analytics" ? ( ) : ( )}
); }