Files
AgentHub/client/src/pages/Dashboard.tsx
T
nntrivi2001 8ce20e8163 feat(dashboard): merge Analytics into Dashboard, remove Kanban Board
Closes issue #5 (Dashboard | Update menu & page):
- Analytics content (heatmap, sparkline, token/cost/workflow/productivity
  charts) moves into a new "Analytics" tab on the Dashboard page, alongside
  the existing Monitor/Health tabs. The standalone Analytics page, its
  /analytics route, and its sidebar menu entry are removed.
- The Kanban Board page, its /kanban route, and its sidebar menu entry are
  removed entirely (page content deleted, not just hidden).
- Updated ARCHITECTURE.md, README.md, client/README.md, docs/API.md, and
  the affected client tests/snapshots to match.
2026-08-25 11:45:39 +07:00

2897 lines
115 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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ĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* 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<string, number>;
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<string, boolean> };
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 (
<div
className="fixed z-50 px-2 py-1.5 text-xs bg-[#12121f] border border-[#2a2a4a] rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
style={{
left: nearRight ? x - 14 : x + 14,
top: y - 10,
transform: nearRight ? "translateX(-100%)" : undefined,
}}
>
{children}
</div>
);
}
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 ? (
<ChartTooltip x={tooltip.x} y={tooltip.y}>
{tooltip.content}
</ChartTooltip>
) : 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<Array<{ date: string; count: number }>> }) {
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 (
<div className="relative">
{node}
{/* Month labels */}
<div className="flex mb-2 ml-[32px] relative h-4">
{monthPositions.map((mp, i) => (
<div
key={i}
className="absolute text-[10px] text-fg-muted font-medium whitespace-nowrap"
style={{ left: mp.col * 16 }}
>
{mp.label}
</div>
))}
</div>
<div className="flex" style={{ gap: "3px" }}>
{/* Day labels */}
<div className="flex flex-col mr-1 w-7" style={{ gap: "3px" }}>
{dayLabels.map((d, i) => (
<div
key={i}
className="text-[9px] text-fg-muted flex items-center justify-end pr-1.5"
style={{ height: 13 }}
>
{d}
</div>
))}
</div>
{/* Cells */}
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col" style={{ gap: "3px" }}>
{week.map((cell) => (
<div
key={cell.date}
onMouseEnter={(e) => {
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,
<>
<span className="text-fg-secondary">
{dayNames[dow] ?? ""}, {cell.date}
</span>
<span className="ml-2 font-medium">
{t("eventCountLabel", { count: cell.count })}
</span>
</>
);
}}
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",
}}
/>
))}
</div>
))}
</div>
{/* Legend */}
<div className="flex items-center gap-2 mt-3 text-[11px] text-fg-muted">
<span>{t("less")}</span>
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
const v = Math.round(f * maxCount);
return (
<div
key={f}
style={{
width: 13,
height: 13,
borderRadius: 2,
backgroundColor: cellColor(v, maxCount),
border: "1px solid rgba(255,255,255,0.06)",
}}
/>
);
})}
<span>{t("more")}</span>
</div>
</div>
);
}
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 (
<div className="relative flex items-end gap-px h-16">
{node}
{data.map(({ date, count }) => (
<div
key={date}
className="flex-1 rounded-sm transition-all cursor-default"
style={{
height: `${Math.max(4, Math.round((count / max) * 100))}%`,
backgroundColor: color,
opacity: count === 0 ? 0.15 : 0.85,
}}
onMouseEnter={(e) =>
show(
e,
<>
<span className="text-fg-secondary">{date}</span>
<span className="ml-2 font-medium">{t("eventCountLabel", { count })}</span>
</>
)
}
onMouseMove={move}
onMouseLeave={hide}
/>
))}
</div>
);
}
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 (
<div className="relative">
{node}
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[88px] overflow-visible">
<defs>
<linearGradient id="daily-cost-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.35} />
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
</linearGradient>
</defs>
<polyline points={areaPoints} fill="url(#daily-cost-fill)" stroke="none" />
<polyline
points={linePoints}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{points.map((point) => (
<g key={point.date}>
<circle
cx={point.x}
cy={point.y}
r={2.5}
fill={color}
style={{ pointerEvents: "none" }}
/>
<circle
cx={point.x}
cy={point.y}
r={8}
fill="transparent"
onMouseEnter={(e) =>
show(
e,
<>
<span className="text-fg-secondary">{point.date}</span>
<span className="ml-2 font-medium">{fmtCostFull(point.cost)}</span>
</>
)
}
onMouseMove={move}
onMouseLeave={hide}
/>
</g>
))}
</svg>
</div>
);
}
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 (
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 truncate flex-shrink-0" title={label}>
{label}
</span>
<div className="flex-1 bg-surface-3 rounded-full h-2">
<div
className={`${color} h-2 rounded-full transition-all`}
style={{ width: `${width}%` }}
/>
</div>
<Tip raw={count.toLocaleString()}>
<span className="text-xs text-fg-muted w-10 text-right flex-shrink-0">{fmt(count)}</span>
</Tip>
</div>
);
}
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 (
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-24 truncate flex-shrink-0" title={label}>
{label}
</span>
<div className="flex-1 bg-surface-3 rounded-full h-2">
<div
className={`${color} h-2 rounded-full transition-all`}
style={{ width: `${width}%` }}
/>
</div>
<span className="text-xs text-status-success font-mono w-16 text-right flex-shrink-0">
<Tip raw={fmtCostFull(cost)}>{fmtCost(cost)}</Tip>
</span>
</div>
);
}
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 <div className="text-xs text-fg-muted">{t("common:noData")}</div>;
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 (
<div className="flex items-center justify-center gap-6 w-full">
{node}
<svg width={128} height={128} viewBox="0 0 128 128" className="flex-shrink-0">
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#1e1e2e" strokeWidth={stroke} />
{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 (
<circle
key={i}
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeDasharray={`${dash} ${gap}`}
strokeDashoffset={currentOffset}
style={{ cursor: "default" }}
onMouseEnter={(e) =>
show(
e,
<>
<span style={{ color }}>{label}</span>
<span className="ml-2 font-medium">{pct}%</span>
</>
)
}
onMouseMove={move}
onMouseLeave={hide}
/>
);
})}
<text x={cx} y={cy - 6} textAnchor="middle" className="fill-fg-secondary" fontSize={11}>
{(formatTotal ?? fmt)(total)}
</text>
<text x={cx} y={cy + 10} textAnchor="middle" className="fill-fg-muted" fontSize={9}>
{t("common:total_lower")}
</text>
</svg>
<div className="space-y-2">
{segments.map(({ label, value, color }) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-fg-secondary">{label}</span>
<span className="text-fg-muted ml-auto pl-4">{Math.round((value / total) * 100)}%</span>
</div>
))}
</div>
</div>
);
}
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 (
<div className="card p-5 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-fg-muted uppercase tracking-wider">{label}</span>
<Icon className={`w-4 h-4 ${color}`} />
</div>
{loading ? (
<StatValueSkeleton />
) : (
<p className={`text-2xl font-bold ${color}`}>
{raw ? <Tip raw={raw}>{value}</Tip> : value}
</p>
)}
{loading ? (
<TextSkeleton width="w-20" />
) : (
sub && <p className="text-[11px] text-fg-muted">{sub}</p>
)}
</div>
);
}
/** A single chart-card placeholder: title bar, chart body, two footer lines.
* Uses the shared pulsing <Skeleton> 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 (
<div className={`card p-5 space-y-4 ${className}`} aria-busy="true" aria-label="Loading">
<Skeleton className="h-4 w-1/3" />
<Skeleton className={`block w-full ${bodyH}`} rounded="lg" />
<div className="space-y-2 pt-1">
<Skeleton className="block h-3 w-2/3" />
<Skeleton className="block h-3 w-1/2" />
</div>
</div>
);
}
/** 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 (
<div className="space-y-6" aria-busy="true" aria-label="Loading analytics charts">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<ChartCardSkeleton className="lg:col-span-2" bodyH="h-32" />
<ChartCardSkeleton bodyH="h-32" />
</div>
<Skeleton className="block h-9 w-80" rounded="lg" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<ChartCardSkeleton bodyH="h-44" />
<ChartCardSkeleton bodyH="h-44" />
<ChartCardSkeleton bodyH="h-44" />
</div>
</div>
);
}
function AnalyticsTab() {
const { t, i18n } = useTranslation("analytics");
const [data, setData] = useState<AnalyticsData | null>(null);
const [costData, setCostData] = useState<CostResult | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date>(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<string, number> = {};
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<Array<{ date: string; count: number }>> = [];
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<string, number> = {};
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<string, number> = {};
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<string, string> = {
PreToolUse: "bg-status-success",
PostToolUse: "bg-blue-500",
Stop: "bg-violet-400",
SubagentStop: "bg-yellow-400",
Notification: "bg-orange-400",
};
return (
<div className="animate-fade-in space-y-8">
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="inline-flex items-center gap-1.5 text-[11px] text-fg-muted bg-surface-2 border border-border px-2 py-0.5 rounded-md font-mono">
<Clock className="w-3 h-3" />
{lastUpdate.toLocaleTimeString()}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={load} className="btn-ghost" disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
{t("common:refresh")}
</button>
<button onClick={handleExport} className="btn-ghost" disabled={!data}>
<Download className="w-4 h-4" />
{t("export")}
</button>
</div>
</div>
{/* Key stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<StatPill
label={t("totalSessions")}
value={data ? fmt(data.overview.total_sessions) : ""}
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
sub={data ? `${data.overview.active_sessions} ${t("common:active")}` : undefined}
icon={FolderOpen}
color="text-blue-500"
loading={!data}
/>
<StatPill
label={t("totalAgents")}
value={data ? fmt(data.overview.total_agents) : ""}
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
sub={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
icon={Bot}
color="text-status-success"
loading={!data}
/>
<StatPill
label={t("totalTokens")}
value={data ? fmt(totalTokens) : ""}
raw={data ? totalTokens.toLocaleString() : undefined}
sub={data ? `${cacheHitPct}${t("cacheHitRate")}` : undefined}
icon={Cpu}
color="text-violet-400"
loading={!data}
/>
<StatPill
label={t("totalCost")}
value={costData ? fmtCost(costData.total_cost) : ""}
raw={costData ? fmtCostFull(costData.total_cost) : undefined}
sub={
costData
? `${costData.breakdown.length} ${t("common:cost.model", { count: costData.breakdown.length })}`
: undefined
}
icon={DollarSign}
color="text-status-success"
loading={!costData}
/>
<StatPill
label={t("totalEvents")}
value={data ? fmt(data.overview.total_events) : ""}
raw={data ? data.overview.total_events.toLocaleString() : undefined}
sub={data ? `~${data.avg_events_per_session}${t("perSession")}` : undefined}
icon={Zap}
color="text-yellow-400"
loading={!data}
/>
</div>
{!data ? (
<AnalyticsChartsSkeleton />
) : (
<>
{/* Activity heatmap + 30-day sparkline */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="card p-5 lg:col-span-2">
<h3 className="text-sm font-medium text-fg-secondary mb-4">{t("eventActivity")}</h3>
<div className="overflow-x-auto">
<div className="w-fit min-w-max mx-auto">
<Heatmap weeks={weeks} />
</div>
</div>
</div>
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-1">{t("last30Days")}</h3>
<p className="text-[11px] text-fg-muted mb-4">{t("dailyEventCount")}</p>
<AnalyticsSparkline data={last30} />
<div className="flex justify-between text-[11px] text-fg-muted mt-2">
<span>{last30[0]?.date?.slice(5)}</span>
<span>{last30[last30.length - 1]?.date?.slice(5)}</span>
</div>
<div className="mt-4 pt-4 border-t border-border space-y-1">
<div className="flex justify-between text-xs">
<span className="text-fg-muted">{t("peakDay")}</span>
<span className="text-fg-secondary font-mono">
<Tip raw={Math.max(...last30.map((d) => d.count)).toLocaleString()}>
{fmt(Math.max(...last30.map((d) => d.count)))}
</Tip>{" "}
{t("common:events")}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-fg-muted">{t("total30d")}</span>
<span className="text-fg-secondary font-mono">
<Tip raw={last30.reduce((s, d) => s + d.count, 0).toLocaleString()}>
{fmt(last30.reduce((s, d) => s + d.count, 0))}
</Tip>{" "}
{t("common:events")}
</span>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div>
<div className="flex gap-1 bg-surface-2 rounded-lg p-1 mb-6 w-fit">
{(
[
{ 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 }) => (
<button
key={key}
onClick={() => setAnalyticsSubTab(key)}
className={`px-4 py-1.5 text-xs font-medium rounded-md transition-colors ${
analyticsSubTab === key
? "bg-surface-4 text-fg-secondary"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{label}
</button>
))}
</div>
{analyticsSubTab === "tokens" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Token bars */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("tokenDistribution")}
</h3>
<div className="space-y-4">
{[
{
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 }) => (
<BarRow
key={label}
label={label}
count={value}
max={Math.max(totalTokens, 1)}
color={color}
/>
))}
</div>
<div className="mt-6 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("common:token.totalTokens")}</span>
<Tip raw={totalTokens.toLocaleString()}>
<span className="text-fg-secondary font-mono">{fmt(totalTokens)}</span>
</Tip>
</div>
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("cacheEfficiency")}</span>
<span className="text-violet-400 font-mono">{cacheHitPct}%</span>
</div>
</div>
</div>
{/* Token summary */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("tokenBreakdown")}
</h3>
<div className="space-y-3">
{[
{
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 }) => (
<div
key={label}
className="flex justify-between items-center py-2 border-b border-border last:border-0"
>
<span className="text-xs text-fg-secondary">{label}</span>
<span className={`text-sm font-mono font-medium ${color}`}>
{value.toLocaleString()}
</span>
</div>
))}
</div>
{totalTokens === 0 && (
<p className="text-[11px] text-fg-muted mt-4">{t("tokenInfo")}</p>
)}
</div>
{/* Token mix donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("tokenMix")}</h3>
{tokenMixSegments.length === 0 ? (
<p className="text-sm text-fg-muted">{t("common:noData")}</p>
) : (
<>
<DonutChart segments={tokenMixSegments} formatTotal={(total) => fmt(total)} />
<div className="mt-4 pt-4 border-t border-border space-y-2">
{tokenMixSegments.map((segment) => (
<div key={segment.label} className="flex justify-between text-xs">
<span className="text-fg-secondary">{segment.label}</span>
<span className="text-fg-secondary font-mono">
<Tip raw={segment.value.toLocaleString()}>{fmt(segment.value)}</Tip>
</span>
</div>
))}
</div>
</>
)}
</div>
</div>
)}
{analyticsSubTab === "cost" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Daily cost trends */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-1">
{t("dailyCostTrends")}
</h3>
{dailyCostsLocal.length === 0 ? (
<p className="text-sm text-fg-muted">{t("noDailyCostData")}</p>
) : (
<>
<p className="text-[11px] text-fg-muted mb-4">{t("costPerDay")}</p>
<CostTrendLine data={dailyCostLast30} />
<div className="flex justify-between text-[11px] text-fg-muted mt-2">
<span>{dailyCostLast30[0]?.date?.slice(5)}</span>
<span>{dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)}</span>
</div>
<div className="mt-4 pt-4 border-t border-border space-y-1">
<div className="flex justify-between text-xs">
<span className="text-fg-muted">{t("peakCostDay")}</span>
<span className="text-status-success font-mono">
<Tip raw={`${peakCostDay.date} • ${fmtCostFull(peakCostDay.cost)}`}>
{fmtCost(peakCostDay.cost)}
</Tip>
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-fg-muted">{t("totalCost30d")}</span>
<span className="text-status-success font-mono">
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
</span>
</div>
</div>
</>
)}
</div>
{/* Cost by model */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("costByModel")}</h3>
{costBreakdown.length > 0 ? (
<>
<DonutChart
segments={costBreakdown.map((b, i) => ({
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)}
/>
<div className="mt-4 pt-4 border-t border-border space-y-2">
{costBreakdown.map((b) => (
<div key={b.model} className="flex justify-between text-xs">
<span className="text-fg-secondary font-mono truncate">
{formatModelName(b.model)}
</span>
<span className="text-status-success font-mono font-medium ml-2">
<Tip raw={fmtCostFull(b.cost)}>{fmtCost(b.cost)}</Tip>
</span>
</div>
))}
<div className="flex justify-between text-xs pt-2 border-t border-border">
<span className="text-fg-secondary font-medium">{t("common:total")}</span>
<span className="text-status-success font-mono font-semibold">
<Tip raw={fmtCostFull(costData?.total_cost ?? 0)}>
{fmtCost(costData?.total_cost ?? 0)}
</Tip>
</span>
</div>
</div>
</>
) : (
<p className="text-sm text-fg-muted">{t("noCostData")}</p>
)}
</div>
{/* Cost by weekday */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-1">
{t("costByWeekday")}
</h3>
{dailyCostsLocal.length === 0 ? (
<p className="text-sm text-fg-muted">{t("noDailyCostData")}</p>
) : (
<>
<p className="text-[11px] text-fg-muted mb-4">{t("last30Days")}</p>
<div className="space-y-3">
{weekdayCosts.map(({ label, cost }) => (
<CostBarRow
key={label}
label={label}
cost={cost}
max={maxWeekdayCost}
color="bg-cyan-400"
/>
))}
</div>
<div className="mt-4 pt-4 border-t border-border text-xs flex justify-between">
<span className="text-fg-muted">{t("common:total")}</span>
<span className="text-cyan-400 font-mono">
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
</span>
</div>
</>
)}
</div>
</div>
)}
{analyticsSubTab === "workflow" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Agent type distribution */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("subagentTypes")}
</h3>
{(data?.agent_types ?? []).length === 0 ? (
<p className="text-sm text-fg-muted">{t("noSubagentData")}</p>
) : (
<div className="space-y-3">
{(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => (
<BarRow
key={subagent_type}
label={subagent_type}
count={count}
max={maxAgentTypeCount}
color="bg-violet-400"
/>
))}
</div>
)}
</div>
{/* Agent status donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("agentStatus")}</h3>
<DonutChart segments={agentStatusSegments} />
<div className="mt-4 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("totalAgentsLabel")}</span>
<Tip raw={(data?.overview.total_agents ?? 0).toLocaleString()}>
<span className="text-fg-secondary font-mono">
{fmt(data?.overview.total_agents ?? 0)}
</span>
</Tip>
</div>
{agentStatusSegments.map((s) => (
<div
key={s.label}
className="flex items-center justify-between text-xs text-fg-muted"
>
<span className="flex items-center gap-1.5">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: s.color }}
/>
{s.label}
</span>
<Tip raw={s.value.toLocaleString()}>
<span className="text-fg-secondary font-mono">{fmt(s.value)}</span>
</Tip>
</div>
))}
</div>
</div>
{/* Event type breakdown */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("eventTypes")}</h3>
{(data?.event_types ?? []).length === 0 ? (
<p className="text-sm text-fg-muted">{t("noEventData")}</p>
) : (
<div className="space-y-3">
{(data?.event_types ?? []).map(({ event_type, count }) => (
<BarRow
key={event_type}
label={event_type}
count={count}
max={maxEventTypeCount}
color={EVENT_TYPE_COLORS[event_type] ?? "bg-surface-4"}
/>
))}
</div>
)}
</div>
</div>
)}
{analyticsSubTab === "productivity" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Top tools */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("toolUsage")}</h3>
{(data?.tool_usage ?? []).length === 0 ? (
<p className="text-sm text-fg-muted">{t("noToolData")}</p>
) : (
<div className="space-y-3">
{(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => (
<BarRow
key={tool_name}
label={tool_name}
count={count}
max={maxToolCount}
color="bg-yellow-400"
/>
))}
</div>
)}
</div>
{/* Session outcomes donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("sessionOutcomes")}
</h3>
<DonutChart segments={sessionOutcomeSegments} />
<div className="mt-4 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("totalSessionsLabel")}</span>
<Tip raw={(data?.overview.total_sessions ?? 0).toLocaleString()}>
<span className="text-fg-secondary font-mono">
{fmt(data?.overview.total_sessions ?? 0)}
</span>
</Tip>
</div>
{sessionOutcomeSegments.map((s) => (
<div
key={s.label}
className="flex items-center justify-between text-xs text-fg-muted"
>
<span className="flex items-center gap-1.5">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: s.color }}
/>
{s.label}
</span>
<Tip raw={s.value.toLocaleString()}>
<span className="text-fg-secondary font-mono">{fmt(s.value)}</span>
</Tip>
</div>
))}
</div>
</div>
{/* Daily session trends */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("dailySessionTrends")}
</h3>
{dailySessionsLocal.length === 0 ? (
<p className="text-sm text-fg-muted">{t("noSessionTrendData")}</p>
) : (
<>
<AnalyticsSparkline data={dailySessionsLocal.slice(-30)} color="#6366f1" />
<div className="mt-4 space-y-2">
{dailySessionsLocal
.slice(-7)
.reverse()
.map(({ date, count }) => {
const maxD = Math.max(
...(dailySessionsLocal.length > 0
? dailySessionsLocal
: [{ count: 1 }]
).map((d) => d.count)
);
return (
<div key={date} className="flex items-center gap-3">
<span className="text-[11px] text-fg-muted font-mono w-20 flex-shrink-0">
{date.slice(5)}
</span>
<div className="flex-1 bg-surface-3 rounded-full h-1.5">
<div
className="bg-accent h-1.5 rounded-full"
style={{
width: `${Math.round((count / Math.max(maxD, 1)) * 100)}%`,
}}
/>
</div>
<span className="text-[11px] text-fg-muted w-4 text-right">
{count}
</span>
</div>
);
})}
</div>
<p className="text-[11px] text-fg-muted mt-3">{t("last7Days")}</p>
</>
)}
</div>
</div>
)}
</div>
</>
)}
</div>
);
}
function SystemHealthTab() {
const [info, setInfo] = useState<SystemInfo | null>(null);
const [workflow, setWorkflow] = useState<WorkflowData | null>(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 (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="animate-pulse h-48 bg-surface-2 rounded-2xl" />
))}
</div>
);
}
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 (
<div className="space-y-6 animate-fade-in pb-10">
{/* Row 1: Runtime + Storage + Health Score */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Runtime Environment */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Server className="w-4 h-4 text-status-success" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Runtime</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">
{info.server.cpus} cores · {info.server.arch}
</span>
</div>
<div className="space-y-2.5">
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">Uptime</span>
<span className="text-xs text-fg-secondary font-mono ml-auto">
{formatUptime(info.server.uptime)}
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">CPU (1/5/15m)</span>
<div className="flex gap-1 ml-auto">
{(info.server.cpu_load || []).slice(0, 3).map((load, i) => (
<span
key={i}
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > info.server.cpus ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
>
{load.toFixed(2)}
</span>
))}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">Node RSS</span>
<span className="text-xs text-fg-secondary font-mono ml-auto">
{formatBytes(info.server.memory.rss)}
</span>
</div>
</div>
<div className="border-t border-border/40 pt-3 space-y-3">
<Tip
block
raw={`Host Memory: ${memUsedPct.toFixed(1)}% used\n${formatBytes(info.server.total_mem - info.server.free_mem)} / ${formatBytes(info.server.total_mem)}`}
>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-fg-muted">Host Memory</span>
<span className="text-fg-secondary font-mono">{memUsedPct.toFixed(0)}%</span>
</div>
<div className="w-full bg-surface-3 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-700 ${memUsedPct > 90 ? "bg-status-danger" : memUsedPct > 70 ? "bg-status-warning" : "bg-status-success"}`}
style={{ width: `${memUsedPct}%` }}
/>
</div>
</div>
</Tip>
<Tip
block
raw={`V8 Heap: ${heapUsedPct.toFixed(1)}% used\n${formatBytes(info.server.memory.heapUsed)} / ${formatBytes(info.server.memory.heapTotal)}\nExternal: ${formatBytes(info.server.memory.external)}`}
>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-fg-muted">V8 Heap</span>
<span className="text-fg-secondary font-mono">{heapUsedPct.toFixed(0)}%</span>
</div>
<div className="w-full bg-surface-3 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-700 ${heapUsedPct > 85 ? "bg-status-danger" : heapUsedPct > 60 ? "bg-status-warning" : "bg-blue-600"}`}
style={{ width: `${heapUsedPct}%` }}
/>
</div>
</div>
</Tip>
</div>
</div>
{/* Storage Engine */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Database className="w-4 h-4 text-blue-500" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Storage</span>
</div>
<Tip
raw={`Write velocity (events):\n5 min: ${info.db.load_stats?.m5 ?? 0}\n15 min: ${info.db.load_stats?.m15 ?? 0}\n1 hr: ${info.db.load_stats?.h1 ?? 0}`}
>
<span className="text-[10px] font-mono text-status-success bg-status-success/5 px-2 py-0.5 rounded border border-status-success/10 cursor-default">
⚡ {info.db.load_stats?.m5 ?? 0}/{info.db.load_stats?.m15 ?? 0}/
{info.db.load_stats?.h1 ?? 0}
</span>
</Tip>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">Database</span>
<span className="text-xs text-fg-secondary font-mono ml-auto">
{formatBytes(info.db.size)} · {info.db.pragmas?.journal_mode?.toUpperCase() || "WAL"}
</span>
</div>
{/* Pie chart + legend using same pattern as Analytics DonutChart */}
<div className="flex items-center justify-center gap-5 flex-1">
<Tip
raw={`Record Distribution\n\nSessions: ${(info.db.counts?.sessions ?? 0).toLocaleString()} (${sessPct.toFixed(1)}%)\nAgents: ${(info.db.counts?.agents ?? 0).toLocaleString()} (${agentPct.toFixed(1)}%)\nEvents: ${(info.db.counts?.events ?? 0).toLocaleString()} (${eventPct.toFixed(1)}%)\n\nTotal: ${totalEntries.toLocaleString()} records`}
>
<svg
width={96}
height={96}
viewBox="0 0 96 96"
className="flex-shrink-0 cursor-default"
>
<circle cx="48" cy="48" r="38" fill="none" stroke="#1e1e2e" strokeWidth="14" />
{(() => {
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 (
<circle
key={i}
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={seg.color}
strokeWidth="14"
strokeDasharray={`${dash} ${gap}`}
strokeDashoffset={currentOffset}
/>
);
});
})()}
<text
x="48"
y="46"
textAnchor="middle"
dominantBaseline="middle"
className="fill-fg-secondary"
fontSize="12"
fontWeight="700"
fontFamily="monospace"
>
{totalEntries > 999 ? `${(totalEntries / 1000).toFixed(1)}K` : totalEntries}
</text>
<text
x="48"
y="60"
textAnchor="middle"
dominantBaseline="middle"
className="fill-fg-muted"
fontSize="8"
>
total
</text>
</svg>
</Tip>
<div className="space-y-2.5">
{[
{
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) => (
<Tip
block
key={item.label}
raw={`${item.label}: ${item.value.toLocaleString()} (${item.pct.toFixed(1)}%)`}
>
<div className="flex items-center gap-2 text-xs cursor-default">
<span
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-fg-secondary">{item.label}</span>
<span className="text-fg-muted ml-auto pl-3 font-mono">
{Math.round(item.pct)}%
</span>
</div>
</Tip>
))}
</div>
</div>
</div>
{/* Health Score */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ShieldCheck className="w-4 h-4 text-status-success" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Health Score</span>
</div>
<Tip
raw={`Composite Index formula:\n0.4 × Success Rate (${successRate.toFixed(1)}%)\n+ 0.25 × Cache Hit (${cacheHitRate.toFixed(1)}%)\n+ 0.25 × (100 − Error Rate) (${(100 - errorRate).toFixed(1)}%)\n+ 0.10 × (100 − Heap%) (${(100 - heapUsedPct).toFixed(1)}%)\n= ${healthScore.toFixed(1)}`}
>
<span className="text-[10px] text-fg-muted cursor-help border-b border-dashed border-border-light">
ⓘ Formula
</span>
</Tip>
</div>
{/* Ring gauge - centered */}
<div className="flex items-center justify-center flex-1">
<Tip
raw={`Score: ${healthScore.toFixed(1)} / 100\n\n• Success Rate (40%): ${successRate.toFixed(1)}%\n• Cache Hit (25%): ${cacheHitRate.toFixed(1)}%\n• Error Avoidance (25%): ${(100 - errorRate).toFixed(1)}%\n• Memory Health (10%): ${(100 - heapUsedPct).toFixed(1)}%`}
>
<svg width="120" height="120" viewBox="0 0 120 120" className="cursor-default">
<circle cx="60" cy="60" r="48" fill="none" stroke="#1e1e2e" strokeWidth="10" />
<circle
cx="60"
cy="60"
r="48"
fill="none"
stroke={healthScore >= 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"
/>
<text
x="60"
y="57"
textAnchor="middle"
dominantBaseline="middle"
className="fill-fg-primary"
fontSize="24"
fontWeight="800"
fontFamily="monospace"
>
{healthScore.toFixed(0)}
</text>
<text
x="60"
y="76"
textAnchor="middle"
dominantBaseline="middle"
className="fill-fg-muted"
fontSize="9"
fontWeight="500"
>
/ 100
</text>
</svg>
</Tip>
</div>
{/* Sub-metrics row */}
<div className="grid grid-cols-4 gap-2 border-t border-border/40 pt-3">
<Tip
block
raw={`Cache Hit Rate: ${cacheHitRate.toFixed(1)}%\nHits: ${info.transcript_cache?.hits ?? 0}\nMisses: ${info.transcript_cache?.misses ?? 0}`}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">Cache</p>
<p className="text-xs font-mono font-bold text-blue-500">
{cacheHitRate.toFixed(0)}%
</p>
</div>
</Tip>
<Tip
block
raw={`Error Rate: ${errorRate.toFixed(2)}%\n<5% = healthy, 5-15% = warning, >15% = critical`}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">Errors</p>
<p
className={`text-xs font-mono font-bold ${errorRate < 5 ? "text-status-success" : errorRate < 15 ? "text-status-warning" : "text-status-danger"}`}
>
{errorRate.toFixed(1)}%
</p>
</div>
</Tip>
<Tip
block
raw={`Transcript compactions: ${workflow.compaction?.totalCompactions ?? 0}\nReduces context window by summarizing turns.`}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">Compact</p>
<p className="text-xs font-mono font-bold text-violet-400">
{workflow.compaction?.totalCompactions ?? 0}
</p>
</div>
</Tip>
<Tip
block
raw={`Tokens recovered: ${(workflow.compaction?.tokensRecovered ?? 0).toLocaleString()}\nFreed by compaction.`}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">Saved</p>
<p className="text-xs font-mono font-bold text-status-success">
{((workflow.compaction?.tokensRecovered ?? 0) / 1000).toFixed(1)}K
</p>
</div>
</Tip>
</div>
</div>
</div>
{/* Row 2: Model Usage + Concurrency */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Model Token Distribution */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Bot className="w-4 h-4 text-blue-500" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Token Usage</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">
{(totalTokens / 1000).toFixed(1)}K total
</span>
</div>
<div className="space-y-2.5">
{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 (
<Tip
block
key={i}
raw={`${formatModelName(m.model) ?? m.model}\nInput: ${m.input_tokens.toLocaleString()}\nOutput: ${m.output_tokens.toLocaleString()}\nCache Read: ${m.cache_read_tokens.toLocaleString()}\nShare: ${pct.toFixed(1)}%`}
>
<div className="flex items-center gap-3 cursor-default">
<span
className="text-xs text-fg-secondary w-28 truncate flex-shrink-0"
title={formatModelName(m.model) ?? m.model}
>
{formatModelName(m.model) ?? m.model}
</span>
<div className="flex-1 bg-surface-3 rounded-full h-2">
<div
className={`${colors[i % colors.length]} h-2 rounded-full transition-all duration-700`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-fg-muted w-12 text-right flex-shrink-0 font-mono">
{pct.toFixed(1)}%
</span>
</div>
</Tip>
);
})}
{modelStats.length === 0 && (
<p className="text-xs text-fg-muted text-center py-4">No model data</p>
)}
</div>
</div>
{/* Concurrency Timeline */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<BarChart3 className="w-4 h-4 text-violet-400" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Concurrency</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">{lanes.length} intervals</span>
</div>
{/* Sparkline-style bar chart matching Analytics sparkline */}
<Tip
block
raw={`Peak: ${maxLaneCount} parallel sessions\nActive intervals: ${lanes.filter((l) => l.count > 0).length}\nAvg: ${lanes.length > 0 ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) : "0"}`}
>
<div className="flex items-end gap-px h-20 cursor-default">
{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 (
<div
key={i}
className="flex-1 rounded-sm transition-all"
style={{
height: `${barPct}%`,
backgroundColor: color,
opacity: lane.count === 0 ? 0.2 : 0.85,
}}
/>
);
})}
{lanes.length === 0 && (
<p className="text-xs text-fg-muted text-center w-full self-center">
No concurrency data
</p>
)}
</div>
</Tip>
<div className="grid grid-cols-3 gap-3 border-t border-border/40 pt-3">
<Tip block raw={`Peak: ${maxLaneCount} sessions running simultaneously.`}>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">Peak</p>
<p className="text-sm font-mono font-bold text-fg-secondary">{maxLaneCount}</p>
</div>
</Tip>
<Tip
block
raw={`${lanes.filter((l) => l.count > 0).length} of ${lanes.length} intervals have active sessions.`}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">Active</p>
<p className="text-sm font-mono font-bold text-status-success">
{lanes.filter((l) => l.count > 0).length}
</p>
</div>
</Tip>
<Tip block raw={`Average concurrency across all intervals.`}>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">Avg</p>
<p className="text-sm font-mono font-bold text-blue-500">
{lanes.length > 0
? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1)
: "0"}
</p>
</div>
</Tip>
</div>
</div>
</div>
{/* Row 3: Tool Usage + Subagent Effectiveness */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Tool Invocations */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Zap className="w-4 h-4 text-status-warning" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Tool Usage</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">top {topTools.length}</span>
</div>
<div className="space-y-2">
{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 (
<Tip
block
key={i}
raw={`${tool.tool_name}: ${tool.count.toLocaleString()} invocations`}
>
<div className="flex items-center gap-3 cursor-default">
<span
className="text-xs text-fg-secondary w-28 truncate flex-shrink-0"
title={tool.tool_name}
>
{tool.tool_name}
</span>
<div className="flex-1 bg-surface-3 rounded-full h-2">
<div
className={`${colors[i % colors.length]} h-2 rounded-full transition-all duration-500`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-fg-muted w-10 text-right flex-shrink-0 font-mono">
{tool.count > 999 ? `${(tool.count / 1000).toFixed(1)}K` : tool.count}
</span>
</div>
</Tip>
);
})}
{topTools.length === 0 && (
<p className="text-xs text-fg-muted text-center py-6">No tool data yet</p>
)}
</div>
</div>
{/* Subagent Effectiveness */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<GitBranch className="w-4 h-4 text-violet-400" />
<span className="text-xs text-fg-muted uppercase tracking-wider">
Subagent Effectiveness
</span>
</div>
</div>
<div className="space-y-2">
{effectiveness.map((item, i) => {
const color =
item.successRate >= 90
? "bg-status-success"
: item.successRate >= 70
? "bg-status-warning"
: "bg-status-danger";
return (
<Tip
block
key={i}
raw={`${item.subagent_type || "default"}\nSuccess: ${item.successRate.toFixed(1)}%\nTotal: ${item.total} · OK: ${item.completed} · Errors: ${item.errors}`}
>
<div className="flex items-center gap-3 cursor-default">
<span className="text-xs text-fg-secondary w-28 truncate flex-shrink-0">
{item.subagent_type || "default"}
</span>
<div className="flex-1 bg-surface-3 rounded-full h-2">
<div
className={`${color} h-2 rounded-full transition-all duration-500`}
style={{ width: `${item.successRate}%` }}
/>
</div>
<span
className={`text-xs w-12 text-right flex-shrink-0 font-mono ${item.successRate >= 90 ? "text-status-success" : item.successRate >= 70 ? "text-status-warning" : "text-status-danger"}`}
>
{item.successRate.toFixed(0)}%
</span>
</div>
</Tip>
);
})}
{effectiveness.length === 0 && (
<p className="text-xs text-fg-muted text-center py-6">No subagent data yet</p>
)}
</div>
</div>
</div>
{/* Row 4: Integration Gateway + Platform Config */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Integration Gateway */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Plug className="w-4 h-4 text-status-warning" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Integration</span>
</div>
<span
className={`text-[10px] font-mono px-2 py-0.5 rounded ${info.hooks.installed ? "bg-status-success/10 text-status-success border border-status-success/20" : "bg-surface-3 text-fg-muted border border-border"}`}
>
{info.hooks.installed ? "Active" : "Offline"}
</span>
</div>
{Object.entries(info.hooks.hooks || {}).length > 0 ? (
<div className="space-y-2">
{Object.entries(info.hooks.hooks).map(([cwd, active]) => (
<Tip
block
key={cwd}
raw={`Path: ${cwd}\nStatus: ${active ? "Connected" : "Disconnected"}`}
>
<div className="flex items-center gap-3 bg-surface-2/50 px-3 py-2 rounded-lg border border-border/30 cursor-default">
<div
className={`w-2 h-2 rounded-full flex-shrink-0 ${active ? "bg-status-success" : "bg-surface-4"}`}
/>
<span className="text-xs text-fg-secondary truncate font-mono">
{cwd.split("/").pop() || cwd}
</span>
</div>
</Tip>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-6 border border-dashed border-border/40 rounded-lg">
<Search className="w-4 h-4 text-fg-muted mb-2" />
<p className="text-xs text-fg-muted">No project hooks registered</p>
</div>
)}
<Tip
block
raw={`WebSocket connections: ${info.server.ws_connections}\nProtocol: RFC 6455`}
>
<div className="flex items-center gap-3 bg-status-success/5 px-3 py-2.5 rounded-lg border border-status-success/10 cursor-default">
<Activity className="w-3.5 h-3.5 text-status-success animate-pulse" />
<div>
<p className="text-[10px] text-status-success font-medium">WebSocket Active</p>
<p className="text-[10px] text-fg-muted">
{info.server.ws_connections} connection
{info.server.ws_connections !== 1 ? "s" : ""}
</p>
</div>
</div>
</Tip>
</div>
{/* Platform Config */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Cpu className="w-4 h-4 text-cyan-400" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Platform</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">{info.server.node_version}</span>
</div>
<div className="space-y-2">
{[
{
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) => (
<div key={row.label} className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{row.label}</span>
<span className="text-xs text-fg-secondary font-mono ml-auto">{row.value}</span>
</div>
))}
</div>
<Tip block raw={info.db.path}>
<div className="flex items-center gap-2 bg-surface-2/50 px-3 py-2 rounded-lg border border-border/30 cursor-default">
<HardDrive className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="text-[10px] text-fg-secondary font-mono truncate">
{info.db.path}
</span>
</div>
</Tip>
</div>
</div>
</div>
);
}
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<Stats | null>(null);
const [activeAgents, setActiveAgents] = useState<Agent[]>([]);
const [recentEvents, setRecentEvents] = useState<DashboardEvent[]>([]);
const [totalCost, setTotalCost] = useState<number | null>(null);
const [allSubagents, setAllSubagents] = useState<Agent[]>([]);
const [sessionsById, setSessionsById] = useState<Map<string, Session>>(new Map());
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
// Dynamic item counts based on available container height
const agentsContainerRef = useRef<HTMLDivElement>(null);
const activityContainerRef = useRef<HTMLDivElement>(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<string>();
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<string>();
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<typeof setTimeout> | 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<string, Agent[]>();
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<string, { total: number; active: number }>();
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 (
<div className="text-center py-20">
<p className="text-status-danger mb-2">{t("failedConnect")}</p>
<p className="text-sm text-fg-muted">{error}</p>
<button onClick={load} className="btn-primary mt-4">
{t("common:retry")}
</button>
</div>
);
}
return (
<div className="flex flex-col gap-8 animate-fade-in min-h-[calc(100vh-4rem)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<LayoutDashboard className="w-4.5 h-4.5 text-accent" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{t("common:live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted">{t("subtitle")}</p>
</div>
</div>
<div className="flex items-center gap-3">
{/* Tabs */}
<div className="flex bg-surface-2 rounded-lg p-0.5 border border-border">
<button
onClick={() => setActiveTab("monitor")}
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
activeTab === "monitor"
? "bg-accent/15 text-accent shadow-sm"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
<Activity className="w-3.5 h-3.5" /> Monitor
</button>
<button
onClick={() => setActiveTab("analytics")}
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
activeTab === "analytics"
? "bg-accent/15 text-accent shadow-sm"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
<BarChart3 className="w-3.5 h-3.5" /> Analytics
</button>
<button
onClick={() => setActiveTab("health")}
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
activeTab === "health"
? "bg-accent/15 text-accent shadow-sm"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
<Server className="w-3.5 h-3.5" /> Health
</button>
</div>
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
</div>
</div>
{activeTab === "monitor" ? (
<div className="flex-1 flex flex-col gap-8 min-h-0">
{/* Stats grid - 2 rows of 3 avoids the 6-column squeeze */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<StatCard
label={t("totalSessions")}
value={stats ? fmt(stats.total_sessions) : ""}
raw={stats ? stats.total_sessions.toLocaleString() : undefined}
icon={FolderOpen}
trend={stats ? `${stats.active_sessions}${t("activeTrend")}` : undefined}
loading={!stats}
/>
<StatCard
label={t("activeAgents")}
value={stats?.active_agents ?? ""}
icon={Bot}
accentColor="text-status-success"
loading={!stats}
/>
<StatCard
label={t("activeSubagents")}
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
icon={GitBranch}
accentColor="text-violet-400"
trend={stats ? `${allSubagents.length}${t("totalTrend")}` : undefined}
loading={!stats}
/>
<StatCard
label={t("eventsToday")}
value={stats ? fmt(stats.events_today) : ""}
raw={stats ? stats.events_today.toLocaleString() : undefined}
icon={Zap}
accentColor="text-yellow-400"
loading={!stats}
/>
<StatCard
label={t("totalEvents")}
value={stats ? fmt(stats.total_events) : ""}
raw={stats ? stats.total_events.toLocaleString() : undefined}
icon={Activity}
accentColor="text-violet-400"
loading={!stats}
/>
<StatCard
label={t("totalCost")}
value={totalCost !== null ? fmtCost(totalCost) : ""}
raw={
totalCost !== null
? `$${totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: undefined
}
icon={DollarSign}
accentColor="text-status-success"
loading={totalCost === null}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-0 min-w-0 flex-1 min-h-0">
{/* Active agents */}
<div ref={agentsContainerRef} className="min-w-0 overflow-y-auto pr-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-fg-secondary">
{t("activeAgentsSection")}
</h3>
</div>
{activeAgents.length === 0 ? (
<EmptyState icon={Bot} title={t("noAgents")} description={t("noAgentsDesc")} />
) : (
<div className="space-y-2">
{(() => {
const { childrenByParent, getDescendants } = agentTree;
function renderAgentNode(
agent: Agent,
depth: number,
ancestors: Set<string> = 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 (
<div key={agent.id}>
<div className="flex items-center gap-1 min-w-0">
{hasChildren && (
<button
onClick={toggleExpanded}
className="p-1 text-fg-muted hover:text-fg-secondary transition-colors flex-shrink-0"
aria-label={isExpanded ? "Collapse subagents" : "Expand subagents"}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
)}
{/* 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 && (
<span
className="w-6 h-6 flex-shrink-0 flex items-center justify-center text-violet-400/70"
aria-hidden="true"
title={t("common:noSubagents", "No subagents")}
>
<CircleDot className="w-4 h-4" strokeWidth={2} />
</span>
)}
{isSubagent && (
<GitBranch className="w-3 h-3 text-violet-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<AgentCard
agent={agent}
session={sessionsById.get(agent.session_id)}
// Card click always navigates (AgentCard's
// default → session details), whether or not it
// has children. Expand/collapse is handled solely
// by the chevron button, so clicking a parent
// (incl. the main agent) no longer toggles.
onClick={undefined}
/>
</div>
</div>
{hasChildren && isExpanded && (
<div className="ml-6 mt-1 space-y-1 border-l-2 border-violet-500/20 pl-3">
{children.map((child) =>
renderAgentNode(child, depth + 1, childAncestors)
)}
</div>
)}
{hasChildren && !isExpanded && (
<button
onClick={() =>
setExpandedAgents((prev) => new Set([...prev, agent.id]))
}
className="ml-7 mt-1 text-[11px] text-violet-400 hover:text-violet-300 transition-colors"
>
{t("common:subagent_label", { count: totalDesc })}
{activeDesc > 0 && (
<span className="text-status-success ml-1">
({activeDesc} {t("common:active")})
</span>
)}
</button>
)}
</div>
);
}
// 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<string>();
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) => (
<div key={agent.id}>
<AgentCard
agent={agent}
session={sessionsById.get(agent.session_id)}
/>
</div>
))}
</>
);
})()}
</div>
)}
</div>
{/* Vertical Divider */}
<div className="hidden lg:block w-px bg-border self-stretch" />
{/* Recent activity */}
<div ref={activityContainerRef} className="min-w-0 overflow-y-auto pl-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-fg-secondary">{t("recentActivity")}</h3>
<button onClick={() => navigate("/activity")} className="btn-ghost text-xs">
{t("viewAll")} <ArrowRight className="w-3 h-3" />
</button>
</div>
{recentEvents.length === 0 ? (
<EmptyState
icon={Activity}
title={t("noActivity")}
description={t("noActivityDesc")}
/>
) : (
<div className="card divide-y divide-border">
{recentEvents.slice(0, visibleActivityCount).map((event, i) => (
<div
key={event.id ?? i}
className="px-4 py-3 flex items-center gap-3 hover:bg-surface-4 transition-colors cursor-pointer"
onClick={() => navigate(`/sessions/${event.session_id}`)}
>
<AgentStatusBadge
status={
event.event_type === "Stop"
? event.summary?.toLowerCase().includes("error")
? "error"
: "completed"
: event.event_type === "APIError" ||
event.summary?.toLowerCase().includes("error")
? "error"
: event.event_type === "PreToolUse"
? "working"
: "waiting"
}
/>
<span className="text-sm text-fg-secondary truncate flex-1">
{event.summary || event.event_type}
</span>
{(() => {
// 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 (
<span
className="text-[11px] text-fg-muted truncate max-w-[9rem] flex-shrink-0"
title={event.session_id}
>
{sname && !isAuto ? (
sname
) : (
<span className="font-mono">{event.session_id.slice(0, 8)}</span>
)}
</span>
);
})()}
{event.tool_name && (
<span className="text-[11px] text-fg-muted font-mono">
{event.tool_name}
</span>
)}
<span className="text-[11px] text-fg-muted flex-shrink-0">
{timeAgo(event.created_at)}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
) : activeTab === "analytics" ? (
<AnalyticsTab />
) : (
<SystemHealthTab />
)}
</div>
);
}