Files
AgentHub/client/src/pages/Dashboard.tsx
T
nntrivi2001 1c6dfea5b5 feat(health): System Health in three groups, a score that sees lag; admin user detail
Dashboard → System Health is grouped into AgentHub, Machine and Agent
activity. The machine (GET /api/host, every signed-in user) is drawn by
components/health/HostHealth.tsx: host card beside the CPU/memory history,
then CPU (threads as compact cells + temperatures/GPUs), disks & network,
busiest processes, and per-user tiles. Concurrency bars each show their own
agent type on hover.

The Health Score (client/src/lib/healthScore.ts) is now 100 minus what each
check costs, between an amber and a red threshold: event-loop p99
(server/lib/loop-delay.js, shared with /api/metrics), Linux PSI pressure for
CPU, memory and disk I/O (/api/host `pressure`; load per core without PSI),
RAM, the database's disk, V8 heap, hooks silent while agents work, and agent
errors in the last 24 h. All-time success rate, API errors and the transcript
cache hit rate no longer count. /api/settings/info adds server.event_loop and
activity {last_event_at, working_agents, agents_24h}.

Administration: a user row opens its detail in place (GET
/api/users/:id/details — spend, account pool share, live panes, workspaces,
newest sessions); the server panel is gone, member tmux folded into the table;
the Sessions list shows each row's owner in server mode.
2026-10-27 16:39:38 +07:00

3391 lines
134 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 in three labelled groups — AgentHub itself (score, storage, concurrency), the machine (`HostSections`, `GET /api/host`, shown to every user) and agent activity (tokens by model, tools, subagents); a recent event's right-click opens its session's `SessionRowMenu`.
* @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.
*
* ## 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,
lazy,
Suspense,
type ReactNode,
} from "react";
import { Link, useSearchParams } 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,
AlertCircle,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { keepIfSame } from "../lib/keepIfSame";
import { StatCard } from "../components/StatCard";
import { AgentCard } from "../components/AgentCard";
import { useContextMenu } from "../components/ContextMenu";
import { SessionRowMenu } from "../components/SessionRowMenu";
import { NeedsYouStrip } from "../components/NeedsYouStrip";
import { RouteErrorBoundary } from "../components/RouteErrorBoundary";
import { AgentStatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { Tip } from "../components/Tip";
import { HostSections, useHostStats } from "../components/health/HostHealth";
import {
healthChecks,
healthScore,
scoreLevel,
type CheckLevel,
type HealthCheck,
} from "../lib/healthScore";
import { Skeleton } from "../components/Skeleton";
import { Select } from "../components/Select";
import { AiToolIcon } from "../components/AiToolIcon";
import { useAiTools } from "../hooks/useAiTools";
import { spendPace } from "../lib/burnRate";
import { RANGE_PRESETS, parsePreset, rangeDates } from "../lib/dateRange";
import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format";
import { statusFromEventType } from "../lib/event-grouping";
import type {
BudgetStatus,
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;
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
heapLimit?: number;
};
cpu_load: number[];
arch: string;
total_mem: number;
free_mem: number;
cpus: number;
/** Event-loop delay over the last minute (ms); absent on an older server. */
event_loop?: { p50: number; p99: number; max: number };
};
/** For the health score; absent on an older server. */
activity?: {
last_event_at: string | null;
working_agents: number;
agents_24h: { completed: number; error: 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`;
}
// ── 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-surface-2 border border-border 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 };
}
/** Heatmap cell fill: the page surface mixed toward the accent on a log
* scale. Built from theme tokens so the ramp reads surface → accent in every
* theme; the old fixed indigo ramp went near-black on light themes. Exported
* for its test. */
export function cellColor(count: number, max: number) {
if (count === 0) return "rgb(var(--surface-0))";
const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1);
// Floor at 20% so a single event still reads as distinct from an empty day.
const pct = Math.round(20 + 80 * t);
return `color-mix(in srgb, rgb(var(--accent)) ${pct}%, rgb(var(--surface-0)))`;
}
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 rgb(var(--border) / 0.4)",
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 rgb(var(--border) / 0.5)",
}}
/>
);
})}
<span>{t("more")}</span>
</div>
</div>
);
}
function AnalyticsSparkline({
data,
color = "rgb(var(--accent))",
}: {
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 = "rgb(var(--status-success))",
}: {
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="rgb(var(--border))"
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>
);
}
/** 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">
<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">
<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 [budgets, setBudgets] = useState<BudgetStatus[]>([]);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
// `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`).
// The URL is the state: `?sub=` is read on every render and written back.
const [params, setParams] = useSearchParams();
const subParam = params.get("sub");
const analyticsSubTab: "tokens" | "cost" | "workflow" | "productivity" =
subParam === "tokens" || subParam === "workflow" || subParam === "productivity"
? subParam
: "cost";
const setAnalyticsSubTab = (sub: typeof analyticsSubTab) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("tab", "analytics");
if (sub === "cost") next.delete("sub");
else next.set("sub", sub);
return next;
},
{ replace: true }
);
// Global data scope; a change re-runs `load` (api injects the `sources` param).
const [scope] = useDataScope();
// One AI tool's sessions only ("" = every tool); a change re-runs `load`.
const [tool, setTool] = useState("");
const aiTools = useAiTools();
// The date range, kept in the URL (`?range=`, plus `from`/`to` when custom).
// Headline numbers, cost/token cards and by-model/by-tool follow it; the
// 52-week and 30-day charts keep their own windows.
const preset = parsePreset(params.get("range"));
const customFrom = params.get("from") ?? "";
const customTo = params.get("to") ?? "";
const { from: rangeFrom, to: rangeTo } = rangeDates(preset, { from: customFrom, to: customTo });
const range = useMemo(() => ({ from: rangeFrom, to: rangeTo }), [rangeFrom, rangeTo]);
const setRangeParam = (patch: Record<string, string>) =>
setParams(
(p) => {
for (const [k, v] of Object.entries(patch)) {
if (v) p.set(k, v);
else p.delete(k);
}
return p;
},
{ replace: true }
);
const [loadError, setLoadError] = useState<string | null>(null);
// Responses can land out of order (a slow poll after a fast refresh); only
// the newest request may write state.
const loadSeq = useRef(0);
const load = useCallback(async () => {
const seq = ++loadSeq.current;
try {
const [result, cost, budgetRes] = await Promise.all([
api.analytics.get({ tool, range }),
api.pricing.totalCost({ tool, range }).catch(() => null),
api.budgets.status().catch(() => null),
]);
if (seq !== loadSeq.current) return;
setData(result);
setCostData(cost);
setLoadError(null);
if (budgetRes) setBudgets(budgetRes.budgets);
setLastUpdate(new Date());
} catch (err) {
if (seq === loadSeq.current)
setLoadError(err instanceof Error ? err.message : t("dashboard:failedLoad"));
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, [scope, tool, range]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
}, [load]);
// Heavy aggregates: event-driven reloads at most every 15 s.
useLiveRefresh(load, {
intervalMs: 15000,
throttleMs: 15000,
match: (msg) =>
msg.type === "session_created" ||
msg.type === "session_updated" ||
msg.type === "new_event" ||
msg.type === "agent_created" ||
isRemoteDataRefreshMessage(msg),
});
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));
// Calendar days, zero-filled: "last 30/7 days" means days, not active days.
const sessionsLast30 = Array.from({ length: 30 }, (_, i) => {
const d = new Date(today);
d.setDate(today.getDate() - (29 - i));
const dateStr = localDateStr(d);
return { date: dateStr, count: sessMap[dateStr] ?? 0 };
});
const maxSessionsDay = Math.max(1, ...sessionsLast30.map((d) => d.count));
// 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 pace = spendPace(dailyCostsLocal, localDateStr(today));
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];
// 2026-01-04 is a Sunday; a LOCAL date, so the label never shifts a day
// west of UTC. 30 days hold some weekdays 5 times and others 4, so the bar
// is the average per occurrence, not the sum.
const weekdayCosts = weekdayOrder.map((dow) => {
const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
new Date(2026, 0, 4 + dow, 12)
);
const days = dailyCostLast30.filter((day) => new Date(day.date + "T12:00:00").getDay() === dow);
const cost = days.length ? days.reduce((sum, day) => sum + day.cost, 0) / days.length : 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: "rgb(var(--syn-builtin))",
},
{
label: t("common:token.output"),
value: data?.tokens.total_output ?? 0,
color: "rgb(var(--status-success))",
},
{
label: t("common:token.cacheRead"),
value: data?.tokens.total_cache_read ?? 0,
color: "rgb(var(--syn-keyword))",
},
{
label: t("common:token.cacheWrite"),
value: data?.tokens.total_cache_write ?? 0,
color: "rgb(var(--status-warning))",
},
].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;
// Hit rate = cache reads over every prompt token (fresh input + cache read +
// cache write); output tokens are never cacheable.
const promptTokens =
(data?.tokens.total_input ?? 0) +
(data?.tokens.total_cache_read ?? 0) +
(data?.tokens.total_cache_write ?? 0);
const cacheHitPct =
promptTokens > 0 ? Math.round(((data?.tokens.total_cache_read ?? 0) / promptTokens) * 100) : 0;
// The headline excludes cache reads, which dwarf everything else and cost a tenth.
const nonCacheReadTokens = totalTokens - (data?.tokens.total_cache_read ?? 0);
const sessionOutcomeSegments = [
{
label: t("common:status.completed"),
value: data?.sessions_by_status?.completed ?? 0,
color: "rgb(var(--syn-keyword))",
},
{
label: t("common:status.active"),
value: data?.sessions_by_status?.active ?? 0,
color: "rgb(var(--status-success))",
},
{
label: t("common:status.error"),
value: data?.sessions_by_status?.error ?? 0,
color: "rgb(var(--status-danger))",
},
{
label: t("common:status.abandoned"),
value: data?.sessions_by_status?.abandoned ?? 0,
color: "rgb(var(--status-warning))",
},
].filter((s) => s.value > 0);
const agentStatusSegments = [
{
label: t("common:status.completed"),
value: data?.agents_by_status?.completed ?? 0,
color: "rgb(var(--syn-keyword))",
},
{
label: t("common:status.working"),
value: data?.agents_by_status?.working ?? 0,
color: "rgb(var(--status-success))",
},
{
label: t("common:status.waiting"),
value: data?.agents_by_status?.waiting ?? 0,
color: "rgb(var(--status-warning))",
},
{
label: t("common:status.error"),
value: data?.agents_by_status?.error ?? 0,
color: "rgb(var(--status-danger))",
},
].filter((s) => s.value > 0);
const EVENT_TYPE_COLORS: Record<string, string> = {
PreToolUse: "bg-status-success",
PostToolUse: "bg-syntax-builtin",
Stop: "bg-syntax-keyword",
SubagentStop: "bg-status-warning",
Notification: "bg-syntax-number",
};
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">
<div className="w-40">
<Select
value={preset}
onChange={(v) =>
setRangeParam(
v === "custom"
? { range: v, from: rangeFrom ?? "", to: rangeTo ?? "" }
: { range: v === "all" ? "" : v, from: "", to: "" }
)
}
label={t("range")}
options={RANGE_PRESETS.map((v) => ({ value: v, label: t(`rangePreset.${v}`) }))}
/>
</div>
{preset === "custom" && (
<>
<input
type="date"
className="input text-sm"
value={customFrom}
max={customTo || undefined}
onChange={(e) => setRangeParam({ from: e.target.value })}
aria-label={t("rangeFrom")}
title={t("rangeFrom")}
/>
<input
type="date"
className="input text-sm"
value={customTo}
min={customFrom || undefined}
onChange={(e) => setRangeParam({ to: e.target.value })}
aria-label={t("rangeTo")}
title={t("rangeTo")}
/>
</>
)}
<div className="w-44">
<Select
value={tool}
onChange={setTool}
label={t("toolFilter")}
options={[
{ value: "", label: t("allTools") },
...aiTools.tools.map((x) => ({ value: x.id, label: x.label })),
]}
/>
</div>
<button
onClick={refresh}
className="btn-ghost"
disabled={loading || spinning}
aria-busy={loading || spinning}
>
<RefreshCw className={`w-4 h-4 ${loading || spinning ? "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>
{loadError && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">{loadError}</span>
<button onClick={refresh} disabled={spinning} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
{/* Key stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<StatCard
label={t("totalSessions")}
help={t("common:insights.help.sessions")}
to="/sessions"
value={data ? fmt(data.overview.total_sessions) : ""}
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
trend={data ? `${data.overview.active_sessions} ${t("common:active")}` : undefined}
icon={FolderOpen}
accentColor="text-syntax-builtin"
loading={!data}
/>
<StatCard
label={t("totalAgents")}
help={t("common:insights.help.agents")}
value={data ? fmt(data.overview.total_agents) : ""}
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
trend={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
icon={Bot}
accentColor="text-status-success"
loading={!data}
/>
<StatCard
label={t("tokensExCacheReads")}
help={t("common:insights.help.tokens")}
value={data ? fmt(nonCacheReadTokens) : ""}
raw={data ? nonCacheReadTokens.toLocaleString() : undefined}
trend={
data
? t("cacheReadsSub", {
reads: fmt(data.tokens.total_cache_read ?? 0),
pct: cacheHitPct,
})
: undefined
}
icon={Cpu}
accentColor="text-syntax-keyword"
loading={!data}
/>
<StatCard
label={t("totalCost")}
help={t("common:insights.help.cost")}
value={costData ? fmtCost(costData.total_cost) : ""}
raw={costData ? fmtCostFull(costData.total_cost) : undefined}
trend={
costData
? `${costData.breakdown.length} ${t("common:cost.model", { count: costData.breakdown.length })}`
: undefined
}
icon={DollarSign}
accentColor="text-status-success"
loading={!costData}
/>
<StatCard
label={t("totalEvents")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={data ? fmt(data.overview.total_events) : ""}
raw={data ? data.overview.total_events.toLocaleString() : undefined}
trend={data ? `~${data.avg_events_per_session}${t("perSession")}` : undefined}
icon={Zap}
accentColor="text-status-warning"
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"
tabIndex={0}
role="region"
aria-label={t("eventActivity")}
>
<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)}
aria-pressed={analyticsSubTab === 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-syntax-builtin",
},
{
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-syntax-keyword",
},
{
label: t("common:token.cacheWrite"),
value: data?.tokens.total_cache_write ?? 0,
color: "bg-status-warning",
},
].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-syntax-keyword 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-syntax-builtin",
},
{
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-syntax-keyword",
},
{
label: t("common:token.cacheWrite"),
value: data?.tokens.total_cache_write ?? 0,
color: "text-status-warning",
},
{ 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 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title={t("common:insights.help.tokenMix")}
>
{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:
[
"rgb(var(--syn-keyword))",
"rgb(var(--syn-builtin))",
"rgb(var(--status-success))",
"rgb(var(--status-warning))",
"rgb(var(--status-danger))",
"rgb(var(--syn-operator))",
][i % 6] ?? "rgb(var(--fg-muted))",
}))}
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>
{/* Spend pace: burn rate, month to date, month-end forecast */}
<div className="card p-5">
<h3
className="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title={t("common:insights.help.spendPace")}
>
{t("spendPace")}
</h3>
<div className="space-y-3">
{[
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
{ label: t("monthToDate"), value: pace.monthToDate },
{
label: t("forecastMonth"),
value: pace.forecastMonth,
hint: t("forecastHint"),
},
].map(({ label, value, hint }) => (
<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" title={hint}>
{label}
</span>
<span className="text-sm font-mono font-medium text-status-success">
<Tip raw={fmtCostFull(value)}>{fmtCost(value)}</Tip>
</span>
</div>
))}
</div>
</div>
{/* Budgets (budget alert rules) */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("budgets")}</h3>
{budgets.length === 0 ? (
<p className="text-sm text-fg-muted">
{t("noBudgets")}{" "}
<Link to="/settings/notifications" className="text-accent hover:underline">
{t("addBudget")}
</Link>
</p>
) : (
<div className="space-y-4">
{budgets.map((b) => {
const over = b.pct >= 100;
const warn = b.crossed.length > 0;
return (
<div key={b.id} className="space-y-1.5">
<div className="flex justify-between text-xs gap-2">
<span className="text-fg-secondary truncate" title={b.name}>
{b.name}
</span>
<span className="font-mono text-fg-secondary flex-shrink-0">
{fmtCost(b.spend)} / {fmtCost(b.config.limit_usd ?? 0)}
</span>
</div>
<div className="bg-surface-3 rounded-full h-2">
<div
className={`h-2 rounded-full ${over ? "bg-status-danger" : warn ? "bg-status-warning" : "bg-status-success"}`}
style={{ width: `${Math.min(100, Math.max(2, b.pct))}%` }}
/>
</div>
<div className="flex justify-between text-[11px] text-fg-muted">
<span>
{b.period_start} → {b.period_end}
</span>
<span title={t("projectedHint")}>
{t("projected")} {fmtCost(b.projected)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Cost by AI tool */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("costByTool")}</h3>
{(costData?.by_tool?.length ?? 0) > 0 ? (
<div className="space-y-3">
{(costData?.by_tool ?? []).map((b) => (
<div key={b.tool} className="flex items-center gap-2">
<AiToolIcon
tool={{ id: b.tool, label: aiTools.label(b.tool) }}
className="h-5 w-5"
/>
<div className="flex-1 min-w-0">
<CostBarRow
label={aiTools.label(b.tool)}
cost={b.cost}
max={Math.max(costData?.by_tool?.[0]?.cost ?? 0, 0.0001)}
color="bg-accent"
/>
</div>
</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("avgPerWeekday30d")}</p>
<div className="space-y-3">
{weekdayCosts.map(({ label, cost }) => (
<CostBarRow
key={label}
label={label}
cost={cost}
max={maxWeekdayCost}
color="bg-syntax-property"
/>
))}
</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-syntax-property font-mono">
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
</span>
</div>
</>
)}
</div>
</div>
)}
{analyticsSubTab === "workflow" && (
<div className="space-y-8">
<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-syntax-keyword"
/>
))}
</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>
{/* The former Workflows page, folded into this sub-tab. */}
<RouteErrorBoundary>
<Suspense fallback={<AnalyticsChartsSkeleton />}>
<Workflows embedded tool={tool || undefined} range={range} />
</Suspense>
</RouteErrorBoundary>
</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-status-warning"
/>
))}
</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={sessionsLast30} color="rgb(var(--accent))" />
<div className="mt-4 space-y-2">
{sessionsLast30
.slice(-7)
.reverse()
.map(({ date, count }) => {
const maxD = maxSessionsDay;
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>
);
}
// The workflow sections the health cards read; the server computes only these.
const HEALTH_SECTIONS = [
"toolFlow",
"effectiveness",
"modelDelegation",
"concurrency",
"compaction",
] as const satisfies readonly (keyof WorkflowData)[];
type HealthWorkflow = Pick<WorkflowData, (typeof HEALTH_SECTIONS)[number]>;
function SystemHealthTab() {
const { t } = useTranslation("dashboard");
// The machine itself (`GET /api/host`): every signed-in user sees it.
const host = useHostStats();
const [info, setInfo] = useState<SystemInfo | null>(null);
const [workflow, setWorkflow] = useState<HealthWorkflow | null>(null);
// The concurrency bar under the pointer: the chart's tip then describes that bar.
const [hoverLane, setHoverLane] = useState<number | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const loadData = useCallback(async () => {
try {
const [infoRes, workflowRes] = await Promise.all([
api.settings.info(),
api.workflows.get(undefined, undefined, undefined, HEALTH_SECTIONS),
]);
setInfo(infoRes as any);
setWorkflow(workflowRes);
setLoadError(null);
} catch (e) {
setLoadError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// 30s is sufficient for health metrics; skipped while the tab is hidden.
useLiveRefresh(loadData, { intervalMs: 30000 });
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);
return {
totalEntries,
sessPct,
agentPct,
eventPct,
modelStats,
totalTokens,
};
}, [info, workflow]);
if (loadError && !info) {
return (
<div role="alert" className="card flex flex-col items-center gap-3 py-12 text-sm">
<AlertCircle className="w-8 h-8 text-status-danger" />
<p className="text-status-danger">
{t("failedLoad")}: {loadError}
</p>
<button onClick={loadData} className="btn-primary text-sm">
{t("common:retry")}
</button>
</div>
);
}
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 } = stats;
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);
const shownLanes = lanes.slice(-Math.min(lanes.length, 40));
// 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);
// The score: what makes the machine or the dashboard lag, then capacity and
// the dashboard's own signals (lib/healthScore.ts).
const checks = healthChecks({
eventLoop: info.server.event_loop,
heap: info.server.memory,
hooksInstalled: info.hooks.installed,
activity: info.activity,
dbPath: info.db.path,
host: host.stats,
});
const healthScoreValue = healthScore(checks);
return (
<div className="space-y-8 animate-fade-in pb-10">
<HealthGroup title={t("health.groupApp")} hint={t("health.groupAppHint")}>
{/* Full width: the ring, then the lag and capacity checks side by side. */}
<HealthScoreCard checks={checks} score={healthScoreValue} cacheHitRate={cacheHitRate} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 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-syntax-builtin" />
<span className="text-xs text-fg-muted uppercase tracking-wider">
{t("health.storage")}
</span>
</div>
<Tip
raw={t("health.writeVelocityTip", {
m5: info.db.load_stats?.m5 ?? 0,
m15: info.db.load_stats?.m15 ?? 0,
h1: 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">
{t("health.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={t("health.recordsTip", {
sessions: (info.db.counts?.sessions ?? 0).toLocaleString(),
sessPct: sessPct.toFixed(1),
agents: (info.db.counts?.agents ?? 0).toLocaleString(),
agentPct: agentPct.toFixed(1),
events: (info.db.counts?.events ?? 0).toLocaleString(),
eventPct: eventPct.toFixed(1),
total: totalEntries.toLocaleString(),
})}
>
<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="rgb(var(--border))"
strokeWidth="14"
/>
{(() => {
const r = 38,
cx = 48,
cy = 48,
circumference = 2 * Math.PI * r;
const segments = [
{ pct: sessPct, color: "rgb(var(--syn-builtin))" },
{ pct: agentPct, color: "rgb(var(--syn-keyword))" },
{ pct: eventPct, color: "rgb(var(--status-success))" },
];
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"
>
{fmt(totalEntries)}
</text>
<text
x="48"
y="60"
textAnchor="middle"
dominantBaseline="middle"
className="fill-fg-muted"
fontSize="8"
>
{t("health.total")}
</text>
</svg>
</Tip>
<div className="space-y-2.5">
{[
{
label: t("health.sessions"),
value: info.db.counts?.sessions ?? 0,
color: "rgb(var(--syn-builtin))",
pct: sessPct,
},
{
label: t("health.agents"),
value: info.db.counts?.agents ?? 0,
color: "rgb(var(--syn-keyword))",
pct: agentPct,
},
{
label: t("health.events"),
value: info.db.counts?.events ?? 0,
color: "rgb(var(--status-success))",
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>
{/* 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-syntax-keyword" />
<span className="text-xs text-fg-muted uppercase tracking-wider">
{t("health.concurrency")}
</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">
{t("health.intervals", { count: lanes.length })}
</span>
</div>
{/* Sparkline-style bar chart matching Analytics sparkline */}
<Tip
block
raw={
hoverLane != null && shownLanes[hoverLane]
? t("health.laneTip", {
name: shownLanes[hoverLane].name,
count: shownLanes[hoverLane].count,
start: Math.round(shownLanes[hoverLane].avgStart * 100),
end: Math.round(shownLanes[hoverLane].avgEnd * 100),
})
: t("health.concurrencyTip", {
peak: maxLaneCount,
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",
})
}
>
<div
className="flex items-end gap-px h-28 cursor-default"
onMouseLeave={() => setHoverLane(null)}
>
{shownLanes.map((lane, i) => {
const barPct =
maxLaneCount > 0
? Math.max(4, Math.round((lane.count / maxLaneCount) * 100))
: 4;
const color =
lane.count > 5
? "rgb(var(--status-danger))"
: lane.count > 2
? "rgb(var(--status-warning))"
: lane.count > 0
? "rgb(var(--status-success))"
: "rgb(var(--border))";
return (
// The whole column is the hover target, not only the bar's height.
<div
key={i}
className="flex-1 h-full flex items-end"
data-testid="concurrency-bar"
onMouseEnter={() => setHoverLane(i)}
>
<div
className="w-full rounded-sm transition-all"
style={{
height: `${barPct}%`,
backgroundColor: color,
opacity:
lane.count === 0
? 0.2
: hoverLane == null || hoverLane === i
? 0.85
: 0.4,
}}
/>
</div>
);
})}
{lanes.length === 0 && (
<p className="text-xs text-fg-muted text-center w-full self-center">
{t("health.noConcurrencyData")}
</p>
)}
</div>
</Tip>
<div className="grid grid-cols-3 gap-3 border-t border-border/40 pt-3">
<Tip block raw={t("health.peakTip", { count: maxLaneCount })}>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">{t("health.peak")}</p>
<p className="text-sm font-mono font-bold text-fg-secondary">{maxLaneCount}</p>
</div>
</Tip>
<Tip
block
raw={t("health.activeTip", {
active: lanes.filter((l) => l.count > 0).length,
total: lanes.length,
})}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">{t("health.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={t("health.avgTip")}>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">{t("health.avg")}</p>
<p className="text-sm font-mono font-bold text-syntax-builtin">
{lanes.length > 0
? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1)
: "0"}
</p>
</div>
</Tip>
</div>
</div>
</div>
</HealthGroup>
<HealthGroup title={t("health.groupMachine")} hint={t("health.groupMachineHint")}>
<HostSections host={host} heap={info.server.memory} />
</HealthGroup>
<HealthGroup title={t("health.groupActivity")} hint={t("health.groupActivityHint")}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 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-syntax-builtin" />
<span className="text-xs text-fg-muted uppercase tracking-wider">
{t("health.tokenUsage")}
</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">
{t("health.totalSuffix", { value: fmt(totalTokens) })}
</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-syntax-builtin",
"bg-syntax-keyword",
"bg-status-success",
"bg-status-warning",
"bg-syntax-operator",
"bg-syntax-property",
];
return (
<Tip
block
key={i}
raw={t("health.modelTip", {
model: formatModelName(m.model) ?? m.model,
input: m.input_tokens.toLocaleString(),
output: m.output_tokens.toLocaleString(),
cacheRead: m.cache_read_tokens.toLocaleString(),
pct: 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">{t("health.noModelData")}</p>
)}
</div>
<div className="grid grid-cols-2 gap-2 border-t border-border/40 pt-3 mt-auto">
<Tip
block
raw={t("health.compactTip", { count: workflow.compaction?.totalCompactions ?? 0 })}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">{t("health.compact")}</p>
<p className="text-xs font-mono font-bold text-syntax-keyword">
{workflow.compaction?.totalCompactions ?? 0}
</p>
</div>
</Tip>
<Tip
block
raw={t("health.savedTip", {
tokens: (workflow.compaction?.tokensRecovered ?? 0).toLocaleString(),
})}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">{t("health.saved")}</p>
<p className="text-xs font-mono font-bold text-status-success">
{fmt(workflow.compaction?.tokensRecovered ?? 0)}
</p>
</div>
</Tip>
</div>
</div>
{/* 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">
{t("health.toolUsage")}
</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">
{t("health.top", { count: 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-syntax-builtin",
"bg-status-success",
"bg-syntax-keyword",
"bg-syntax-operator",
"bg-syntax-property",
"bg-status-danger",
"bg-accent",
];
return (
<Tip
block
key={i}
raw={t("health.invocationsTip", {
tool: tool.tool_name,
count: tool.count,
formatted: tool.count.toLocaleString(),
})}
>
<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">
{fmt(tool.count)}
</span>
</div>
</Tip>
);
})}
{topTools.length === 0 && (
<p className="text-xs text-fg-muted text-center py-6">{t("health.noToolData")}</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-syntax-keyword" />
<span className="text-xs text-fg-muted uppercase tracking-wider">
{t("health.subagentEffectiveness")}
</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={t("health.effectivenessTip", {
type: item.subagent_type || t("health.defaultType"),
pct: item.successRate.toFixed(1),
total: 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 || t("health.defaultType")}
</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">
{t("health.noSubagentData")}
</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">
{t("health.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 ? t("health.hooksActive") : t("common: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={t("health.hookTip", {
path: cwd,
status: active ? t("health.connected") : t("health.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">{t("health.noHooks")}</p>
</div>
)}
<Tip block raw={t("health.wsTip", { count: info.server.ws_connections })}>
<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-dot" />
<div>
<p className="text-[10px] text-status-success font-medium">
{t("health.wsActive")}
</p>
<p className="text-[10px] text-fg-muted">
{t("health.connections", { count: info.server.ws_connections })}
</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-syntax-property" />
<span className="text-xs text-fg-muted uppercase tracking-wider">
{t("health.platform")}
</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">
{info.server.node_version}
</span>
</div>
<div className="space-y-2">
{[
{
label: t("health.journalMode"),
value: info.db.pragmas?.journal_mode?.toUpperCase() || "WAL",
},
{
label: t("health.synchronous"),
value:
info.db.pragmas?.synchronous === 2
? "FULL"
: info.db.pragmas?.synchronous === 1
? "NORMAL"
: "OFF",
},
{
label: t("health.autoVacuum"),
value: info.db.pragmas?.auto_vacuum > 0 ? "FULL" : "OFF",
},
{
label: t("health.foreignKeys"),
value: info.db.pragmas?.foreign_keys ? "ON" : "OFF",
},
{
label: t("health.busyTimeout"),
value: `${info.db.pragmas?.busy_timeout || 5000}ms`,
},
{
label: t("health.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>
</HealthGroup>
</div>
);
}
const LEVEL_DOT: Record<CheckLevel, string> = {
ok: "bg-status-success",
warn: "bg-status-warning",
bad: "bg-status-danger",
na: "bg-surface-4",
};
const LEVEL_TEXT: Record<CheckLevel, string> = {
ok: "text-fg-secondary",
warn: "text-status-warning",
bad: "text-status-danger",
na: "text-fg-muted",
};
const LEVEL_STROKE: Record<Exclude<CheckLevel, "na">, string> = {
ok: "rgb(var(--status-success))",
warn: "rgb(var(--status-warning))",
bad: "rgb(var(--status-danger))",
};
/** The System Health score: a ring, then every check with its level and cost. */
function HealthScoreCard({
checks,
score,
cacheHitRate,
}: {
checks: HealthCheck[];
score: number;
cacheHitRate: number;
}) {
const { t } = useTranslation("dashboard");
const level = scoreLevel(score);
const warn = checks.filter((c) => c.level === "warn").length;
const bad = checks.filter((c) => c.level === "bad").length;
const lost = checks.filter((c) => c.penalty > 0);
const formula = [
t("health.scoreFormula"),
...lost.map((c) => `−${c.penalty} · ${t(`health.check.${c.key}`)}`),
t("health.scoreResult", { score: score.toFixed(0) }),
].join("\n");
const valueText = (c: HealthCheck) => {
if (c.key === "hooks") {
if (c.detail?.state === "missing") return t("health.hooksMissing");
if (c.detail?.state === "idle") return t("health.hooksIdle");
return t("health.minutesAgo", { n: Math.round(c.value ?? 0) });
}
if (c.value == null) return "—";
if (c.unit === "ms") return `${Math.round(c.value)} ms`;
if (c.key === "agents") return `${c.detail?.error ?? 0}/${c.detail?.total ?? 0}`;
return `${c.value < 10 ? c.value.toFixed(1) : Math.round(c.value)}%`;
};
const tipOf = (c: HealthCheck) =>
[
t(`health.checkTip.${c.key}${c.key === "cpu" && c.detail?.source === "load" ? "Load" : ""}`, {
mount: c.detail?.mount ?? "/",
}),
c.key === "loop" ? t("health.cacheTip", { pct: cacheHitRate.toFixed(1) }) : null,
c.level === "na"
? t("health.checkNa")
: t("health.checkThresholds", {
warn: c.warnAt,
bad: c.badAt,
unit: c.unit === "min" ? " min" : c.unit === "ms" ? " ms" : "%",
}),
c.penalty > 0 ? t("health.checkPenalty", { n: c.penalty, max: c.max }) : null,
]
.filter(Boolean)
.join("\n");
const row = (c: HealthCheck) => (
<Tip block key={c.key} raw={tipOf(c)}>
<div
className="flex items-center gap-2 text-xs cursor-default"
data-testid={`health-check-${c.key}`}
>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${LEVEL_DOT[c.level]}`} />
<span className="text-fg-secondary truncate">
{t(`health.check.${c.key}`)}
{c.key === "disk" && c.detail?.mount ? (
<span className="text-fg-muted"> · {c.detail.mount}</span>
) : null}
</span>
<span className={`ml-auto pl-2 font-mono ${LEVEL_TEXT[c.level]}`}>{valueText(c)}</span>
<span className="w-8 text-right font-mono text-[10px] text-fg-muted">
{c.penalty > 0 ? `−${Math.round(c.penalty)}` : ""}
</span>
</div>
</Tip>
);
return (
<div className="card p-5 flex flex-col gap-4" data-testid="health-score">
<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">
{t("health.score")}
</span>
</div>
<Tip raw={formula}>
<span className="text-[10px] text-fg-muted cursor-help border-b border-dashed border-border-light">
ⓘ {t("health.formula")}
</span>
</Tip>
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-4 md:grid-cols-2 lg:grid-cols-[minmax(0,14rem)_1fr_1fr]">
<div className="flex items-center gap-4 md:col-span-2 lg:col-span-1">
<svg width="84" height="84" viewBox="0 0 120 120" className="flex-shrink-0">
<circle
cx="60"
cy="60"
r="48"
fill="none"
stroke="rgb(var(--border))"
strokeWidth="12"
/>
<circle
cx="60"
cy="60"
r="48"
fill="none"
stroke={LEVEL_STROKE[level]}
strokeWidth="12"
strokeLinecap="round"
strokeDasharray={`${score * 3.016} ${301.6 - score * 3.016}`}
strokeDashoffset={301.6 / 4}
className="transition-all duration-1000"
/>
<text
x="60"
y="62"
textAnchor="middle"
dominantBaseline="middle"
className="fill-fg-primary"
fontSize="30"
fontWeight="800"
fontFamily="monospace"
>
{score.toFixed(0)}
</text>
</svg>
<div className="min-w-0 space-y-1">
<p className={`text-sm font-semibold ${LEVEL_TEXT[level]}`}>
{t(`health.verdict.${level}`)}
</p>
<p className="text-[11px] text-fg-muted">
{bad + warn === 0
? t("health.allOk")
: [
bad ? t("health.badCount", { count: bad }) : null,
warn ? t("health.warnCount", { count: warn }) : null,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
</div>
<div className="space-y-1.5">
<p className="text-[9px] text-fg-muted uppercase tracking-wider">
{t("health.lagGroup")}
</p>
{checks.filter((c) => c.group === "lag").map(row)}
</div>
<div className="space-y-1.5">
<p className="text-[9px] text-fg-muted uppercase tracking-wider">
{t("health.systemGroup")}
</p>
{checks.filter((c) => c.group === "system").map(row)}
</div>
</div>
</div>
);
}
/** One labelled group of the System Health tab: a small heading over its cards. */
function HealthGroup({
title,
hint,
children,
}: {
title: string;
hint: string;
children: ReactNode;
}) {
return (
<section className="space-y-3" aria-label={title}>
<div className="flex items-baseline gap-3 border-b border-border/40 pb-1.5">
<h3 className="text-sm font-semibold text-fg-secondary">{title}</h3>
<p className="text-[11px] text-fg-muted truncate">{hint}</p>
</div>
<div className="space-y-4">{children}</div>
</section>
);
}
// The Workflow sub-tab (d3 charts) loads on first open, not with the Dashboard.
const Workflows = lazy(() => import("./Workflows").then((m) => ({ default: m.Workflows })));
/** Rows kept in the Monitor's recent-activity list, on load and on a push alike. */
const RECENT_LIMIT = 30;
/**
* The Monitor's recent-activity column. Its own component (own state, own
* fetch and push subscription) so a new event re-renders this list only, not
* the whole Dashboard and its agent tree.
*/
function RecentActivityPanel({ sessionsById }: { sessionsById: Map<string, Session> }) {
const { t } = useTranslation("dashboard");
const eventCtx = useContextMenu<string>();
const [recentEvents, setRecentEvents] = useState<DashboardEvent[]>([]);
const [scope] = useDataScope();
// Dynamic row count based on the column's available height.
const activityContainerRef = useRef<HTMLDivElement>(null);
const [visibleActivityCount, setVisibleActivityCount] = useState(8);
useEffect(() => {
const ACTIVITY_ROW_H = 44; // ~px per activity row
const HEADER_H = 40; // section header height
function recalc() {
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 (activityContainerRef.current) ro.observe(activityContainerRef.current);
recalc();
return () => ro.disconnect();
}, []);
const load = useCallback(() => {
api.events
.list({ limit: RECENT_LIMIT })
.then((r) => setRecentEvents(r.events))
.catch(() => undefined); // a failed refresh keeps the last list
}, [scope]);
useEffect(() => {
load();
}, [load]);
// New events arrive over the WebSocket (prepended below); a reload is only
// for what the socket cannot carry: a remote source's import, a gap while it
// was down (reconnect) or the tab hidden (shown again), and a slow safety poll.
useLiveRefresh(load, { intervalMs: 60000, match: isRemoteDataRefreshMessage });
// A new event is prepended at once (no reload); the reloads reconcile.
useEffect(
() =>
eventBus.subscribe((msg: WSMessage) => {
if (msg.type !== "new_event") return;
const newEvent = msg.data as DashboardEvent;
setRecentEvents((prev) => {
// 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, RECENT_LIMIT);
});
}),
[]
);
return (
<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>
<Link to="/sessions?view=events" className="btn-ghost text-xs">
{t("viewAll")} <ArrowRight className="w-3 h-3" />
</Link>
</div>
{recentEvents.length === 0 ? (
<EmptyState icon={Activity} title={t("noActivity")} description={t("noActivityDesc")} />
) : (
<div className="card divide-y divide-border">
{eventCtx.menu && (
<SessionRowMenu
session={{
id: eventCtx.menu.item,
lane_id: sessionsById.get(eventCtx.menu.item)?.lane_id,
cwd: sessionsById.get(eventCtx.menu.item)?.cwd,
}}
x={eventCtx.menu.x}
y={eventCtx.menu.y}
onClose={eventCtx.close}
/>
)}
{recentEvents.slice(0, visibleActivityCount).map((event, i) => (
<Link
key={event.id ?? i}
to={`/sessions/${event.session_id}`}
className="px-4 py-3 flex items-center gap-3 hover:bg-surface-4 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
onContextMenu={(e) => eventCtx.open(e, event.session_id)}
>
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
<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 =
(event.session_name ?? 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>
</Link>
))}
</div>
)}
</div>
);
}
type DashboardTab = "monitor" | "analytics" | "health";
function isDashboardTab(v: string | null): v is DashboardTab {
return v === "monitor" || v === "analytics" || v === "health";
}
export function Dashboard() {
const { t } = useTranslation("dashboard");
// Tab state: `?tab=` wins, else the last tab used, else Analytics. Written
// back to the URL (replace) so a reload or a shared link lands on it.
const [tabParams, setTabParams] = useSearchParams();
const urlTab = tabParams.get("tab");
const storedTab = localStorage.getItem("dashboard_tab");
const activeTab: DashboardTab = isDashboardTab(urlTab)
? urlTab
: isDashboardTab(storedTab)
? storedTab
: "analytics";
const setActiveTab = (tab: DashboardTab) => {
localStorage.setItem("dashboard_tab", tab);
setTabParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("tab", tab);
if (tab !== "analytics") next.delete("sub");
return next;
},
{ replace: true }
);
};
const [stats, setStats] = useState<Stats | null>(null);
const [activeAgents, setActiveAgents] = useState<Agent[]>([]);
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());
// Calls that failed on the last load; each panel keeps its last good data.
const [error, setError] = useState<string | null>(null);
// The agents column scrolls, so no main agent is ever dropped.
const agentsContainerRef = useRef<HTMLDivElement>(null);
// Global data scope; a change re-runs `load` (the api layer injects the
// matching `sources` param into the calls below).
const [scope] = useDataScope();
const monitorSeq = useRef(0);
const load = useCallback(async () => {
const seq = ++monitorSeq.current;
const failed: string[] = [];
// One failing endpoint must not blank the others: each call keeps its own
// catch and the panel it feeds keeps its last good value.
const soft = <T,>(p: Promise<T>): Promise<T | null> =>
p.catch((err: unknown) => {
failed.push(err instanceof Error ? err.message : String(err));
return null;
});
const [statsRes, workingRes, waitingRes, costRes, sessionsRes] = await Promise.all([
soft(api.stats.get()),
soft(api.agents.list({ status: "working", limit: 20 })),
soft(api.agents.list({ status: "waiting", limit: 20 })),
soft(api.pricing.totalCost()),
soft(api.sessions.list({ status: "active", limit: 100 })),
]);
if (seq !== monitorSeq.current) return;
if (statsRes) setStats(statsRes);
if (costRes) setTotalCost(costRes.total_cost);
if (sessionsRes) setSessionsById(new Map(sessionsRes.sessions.map((s) => [s.id, s])));
if (workingRes && waitingRes) {
const active = [...workingRes.agents, ...waitingRes.agents];
// keepIfSame: an identical poll keeps the arrays, so the memoized cards
// do not re-render.
setActiveAgents(keepIfSame(active));
// Every subagent of the active main agents' sessions, in ONE call.
const activeSessionIds = [
...new Set(active.filter((a) => a.type === "main").map((a) => a.session_id)),
];
const subsRes = activeSessionIds.length
? await soft(api.agents.list({ session_ids: activeSessionIds, limit: 2000 }))
: { agents: [] };
if (seq !== monitorSeq.current) return;
if (subsRes) setAllSubagents(keepIfSame(subsRes.agents.filter((a) => a.type === "subagent")));
}
setError(failed[0] ?? null);
}, [scope]);
const [spinning, refresh] = useSpin(load);
const monitorOn = activeTab === "monitor";
useEffect(() => {
if (monitorOn) load();
}, [load, monitorOn]);
useLiveRefresh(load, {
enabled: monitorOn,
intervalMs: 10000,
match: (msg) =>
msg.type === "agent_created" ||
msg.type === "agent_updated" ||
msg.type === "session_created" ||
msg.type === "session_updated" ||
isRemoteDataRefreshMessage(msg),
});
// 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]);
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]);
const tabs: Array<{ key: DashboardTab; label: string; icon: typeof BarChart3 }> = [
{ key: "analytics", label: t("tabs.analytics"), icon: BarChart3 },
{ key: "monitor", label: t("tabs.monitor"), icon: Activity },
{ key: "health", label: t("tabs.health"), icon: Server },
];
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">
{/* Monitor's Refresh sits LEFT of the tabs: on their right it pushed
Analytics/Monitor/Health aside every time Monitor opened.
Analytics and Health carry their own Refresh; one per view. */}
{monitorOn && (
<button
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost flex-shrink-0"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
)}
{/* Tabs */}
<div
role="tablist"
aria-label={t("tabs.label")}
className="flex bg-surface-2 rounded-lg p-0.5 border border-border"
>
{tabs.map(({ key, label, icon: Icon }) => (
<button
key={key}
role="tab"
aria-selected={activeTab === key}
onClick={() => setActiveTab(key)}
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
activeTab === key
? "bg-accent/15 text-accent shadow-sm"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
<Icon className="w-3.5 h-3.5" /> {label}
</button>
))}
</div>
</div>
</div>
{activeTab !== "health" && <NeedsYouStrip compact={activeTab === "analytics"} />}
{activeTab === "monitor" ? (
<div className="flex-1 flex flex-col gap-8 min-h-0">
{error && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">
{t("failedLoad")}: {error}
</span>
<button onClick={refresh} disabled={spinning} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
{/* 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")}
help={t("common:insights.help.sessions")}
to="/sessions"
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")}
help={t("common:insights.help.agents")}
value={stats?.active_agents ?? ""}
icon={Bot}
accentColor="text-status-success"
loading={!stats}
/>
<StatCard
label={t("activeSubagents")}
help={t("common:insights.help.subagents")}
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
icon={GitBranch}
accentColor="text-syntax-keyword"
trend={stats ? `${allSubagents.length}${t("totalTrend")}` : undefined}
loading={!stats}
/>
<StatCard
label={t("eventsToday")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={stats ? fmt(stats.events_today) : ""}
raw={stats ? stats.events_today.toLocaleString() : undefined}
icon={Zap}
accentColor="text-status-warning"
loading={!stats}
/>
<StatCard
label={t("totalEvents")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={stats ? fmt(stats.total_events) : ""}
raw={stats ? stats.total_events.toLocaleString() : undefined}
icon={Activity}
accentColor="text-syntax-keyword"
loading={!stats}
/>
<StatCard
label={t("totalCost")}
help={t("common:insights.help.cost")}
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 ? t("collapseSubagents") : t("expandSubagents")
}
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-syntax-keyword/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-syntax-keyword 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-syntax-keyword/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-syntax-keyword hover:text-fg-primary 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.
// Every main agent: the panel scrolls, nothing is dropped.
const visibleMains = activeAgents.filter((a) => a.type === "main");
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 */}
<RecentActivityPanel sessionsById={sessionsById} />
</div>
</div>
) : activeTab === "analytics" ? (
<AnalyticsTab />
) : (
<SystemHealthTab />
)}
</div>
);
}