Files
AgentHub/client/src/pages/Dashboard.tsx
T
nntrivi2001 4aae61297f feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
2026-10-06 17:12:28 +07:00

3341 lines
131 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; 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 { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
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 { 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;
};
transcript_cache: {
size: number;
maxSize: number;
hits: number;
misses: number;
keys: string[];
};
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
// ── Analytics tab (merged from the former standalone Analytics page) ─────────
function ChartTooltip({ x, y, children }: { x: number; y: number; children: ReactNode }) {
const nearRight = x > window.innerWidth - 200;
return (
<div
className="fixed z-50 px-2 py-1.5 text-xs bg-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]);
useEffect(() => {
load();
}, [load]);
useLiveRefresh(load, {
intervalMs: 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={load} className="btn-ghost" disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
{t("common:refresh")}
</button>
<button onClick={handleExport} className="btn-ghost" disabled={!data}>
<Download className="w-4 h-4" />
{t("export")}
</button>
</div>
</div>
{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={load} 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")}
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")}
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")}
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")}
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")}
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">{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">{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 = [
"stats",
"toolFlow",
"effectiveness",
"modelDelegation",
"errorPropagation",
"concurrency",
"compaction",
] as const satisfies readonly (keyof WorkflowData)[];
type HealthWorkflow = Pick<WorkflowData, (typeof HEALTH_SECTIONS)[number]>;
function SystemHealthTab() {
const { t } = useTranslation("dashboard");
const [info, setInfo] = useState<SystemInfo | null>(null);
const [workflow, setWorkflow] = useState<HealthWorkflow | 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);
const memUsedPct =
info.server.total_mem > 0 ? (1 - info.server.free_mem / info.server.total_mem) * 100 : 0;
// Against the heap LIMIT: heapTotal is only what V8 has grown to so far, so
// used/total reads 70-90% on a healthy process and dragged the health score.
const heapMax = info.server.memory.heapLimit || info.server.memory.heapTotal;
const heapUsedPct = heapMax > 0 ? (info.server.memory.heapUsed / heapMax) * 100 : 0;
return {
totalEntries,
sessPct,
agentPct,
eventPct,
modelStats,
totalTokens,
memUsedPct,
heapUsedPct,
};
}, [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,
memUsedPct,
heapUsedPct,
} = stats;
const successRate = Math.max(0, Math.min(100, workflow.stats.successRate));
const errorRate = Math.max(
0,
Math.min(100, workflow.errorPropagation?.errorRate ?? 100 - successRate)
);
const cacheHitRate = Math.max(
0,
Math.min(
100,
((info.transcript_cache?.hits ?? 0) /
((info.transcript_cache?.hits ?? 0) + (info.transcript_cache?.misses ?? 0) || 1)) *
100
)
);
// Concurrency histogram data
const lanes = workflow.concurrency?.aggregateLanes || [];
const maxLaneCount = Math.max(...lanes.map((l) => l.count), 1);
// Tool flow top tools
const topTools = (workflow.toolFlow?.toolCounts || []).slice(0, 8);
const maxToolCount = topTools.length > 0 ? (topTools[0]?.count ?? 1) : 1;
// Subagent effectiveness
const effectiveness = (workflow.effectiveness || []).slice(0, 6);
// Composite health score - clamped to [0, 100] for display safety
const healthScore = Math.max(
0,
Math.min(
100,
successRate * 0.4 +
cacheHitRate * 0.25 +
Math.max(0, 100 - errorRate) * 0.25 +
Math.max(0, 100 - Math.min(100, heapUsedPct)) * 0.1
)
);
return (
<div className="space-y-6 animate-fade-in pb-10">
{/* Row 1: Runtime + Storage + Health Score */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Runtime Environment */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Server className="w-4 h-4 text-status-success" />
<span className="text-xs text-fg-muted uppercase tracking-wider">
{t("health.runtime")}
</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">
{t("health.cores", { count: info.server.cpus })} · {info.server.arch}
</span>
</div>
<div className="space-y-2.5">
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
{t("health.uptime")}
</span>
<span className="text-xs text-fg-secondary font-mono ml-auto">
{formatUptime(info.server.uptime)}
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
{t("health.cpu")}
</span>
<div className="flex gap-1 ml-auto">
{(info.server.cpu_load || []).slice(0, 3).map((load, i) => (
<span
key={i}
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > info.server.cpus ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
>
{load.toFixed(2)}
</span>
))}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
{t("health.nodeRss")}
</span>
<span className="text-xs text-fg-secondary font-mono ml-auto">
{formatBytes(info.server.memory.rss)}
</span>
</div>
</div>
<div className="border-t border-border/40 pt-3 space-y-3">
<Tip
block
raw={t("health.hostMemoryTip", {
pct: memUsedPct.toFixed(1),
used: formatBytes(info.server.total_mem - info.server.free_mem),
total: formatBytes(info.server.total_mem),
})}
>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-fg-muted">{t("health.hostMemory")}</span>
<span className="text-fg-secondary font-mono">{memUsedPct.toFixed(0)}%</span>
</div>
<div className="w-full bg-surface-3 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-700 ${memUsedPct > 90 ? "bg-status-danger" : memUsedPct > 70 ? "bg-status-warning" : "bg-status-success"}`}
style={{ width: `${memUsedPct}%` }}
/>
</div>
</div>
</Tip>
<Tip
block
raw={t("health.heapTip", {
pct: heapUsedPct.toFixed(1),
used: formatBytes(info.server.memory.heapUsed),
limit: formatBytes(info.server.memory.heapLimit || info.server.memory.heapTotal),
allocated: formatBytes(info.server.memory.heapTotal),
external: formatBytes(info.server.memory.external),
})}
>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-fg-muted">{t("health.heap")}</span>
<span className="text-fg-secondary font-mono">{heapUsedPct.toFixed(0)}%</span>
</div>
<div className="w-full bg-surface-3 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-700 ${heapUsedPct > 85 ? "bg-status-danger" : heapUsedPct > 60 ? "bg-status-warning" : "bg-syntax-builtin"}`}
style={{ width: `${heapUsedPct}%` }}
/>
</div>
</div>
</Tip>
</div>
</div>
{/* Storage Engine */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Database className="w-4 h-4 text-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>
{/* Health Score */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ShieldCheck className="w-4 h-4 text-status-success" />
<span className="text-xs text-fg-muted uppercase tracking-wider">
{t("health.score")}
</span>
</div>
<Tip
raw={t("health.formulaTip", {
success: successRate.toFixed(1),
cache: cacheHitRate.toFixed(1),
noError: (100 - errorRate).toFixed(1),
noHeap: (100 - heapUsedPct).toFixed(1),
score: healthScore.toFixed(1),
})}
>
<span className="text-[10px] text-fg-muted cursor-help border-b border-dashed border-border-light">
ⓘ {t("health.formula")}
</span>
</Tip>
</div>
{/* Ring gauge - centered */}
<div className="flex items-center justify-center flex-1">
<Tip
raw={t("health.scoreTip", {
score: healthScore.toFixed(1),
success: successRate.toFixed(1),
cache: cacheHitRate.toFixed(1),
noError: (100 - errorRate).toFixed(1),
noHeap: (100 - heapUsedPct).toFixed(1),
})}
>
<svg width="120" height="120" viewBox="0 0 120 120" className="cursor-default">
<circle
cx="60"
cy="60"
r="48"
fill="none"
stroke="rgb(var(--border))"
strokeWidth="10"
/>
<circle
cx="60"
cy="60"
r="48"
fill="none"
stroke={
healthScore >= 90
? "rgb(var(--status-success))"
: healthScore >= 70
? "rgb(var(--status-warning))"
: "rgb(var(--status-danger))"
}
strokeWidth="10"
strokeLinecap="round"
strokeDasharray={`${healthScore * 3.016} ${301.6 - healthScore * 3.016}`}
strokeDashoffset={301.6 / 4}
className="transition-all duration-1000"
/>
<text
x="60"
y="57"
textAnchor="middle"
dominantBaseline="middle"
className="fill-fg-primary"
fontSize="24"
fontWeight="800"
fontFamily="monospace"
>
{healthScore.toFixed(0)}
</text>
<text
x="60"
y="76"
textAnchor="middle"
dominantBaseline="middle"
className="fill-fg-muted"
fontSize="9"
fontWeight="500"
>
/ 100
</text>
</svg>
</Tip>
</div>
{/* Sub-metrics row */}
<div className="grid grid-cols-4 gap-2 border-t border-border/40 pt-3">
<Tip
block
raw={t("health.cacheTip", {
pct: cacheHitRate.toFixed(1),
hits: info.transcript_cache?.hits ?? 0,
misses: info.transcript_cache?.misses ?? 0,
})}
>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">{t("health.cache")}</p>
<p className="text-xs font-mono font-bold text-syntax-builtin">
{cacheHitRate.toFixed(0)}%
</p>
</div>
</Tip>
<Tip block raw={t("health.errorsTip", { pct: errorRate.toFixed(2) })}>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">{t("health.errors")}</p>
<p
className={`text-xs font-mono font-bold ${errorRate < 5 ? "text-status-success" : errorRate < 15 ? "text-status-warning" : "text-status-danger"}`}
>
{errorRate.toFixed(1)}%
</p>
</div>
</Tip>
<Tip
block
raw={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>
</div>
{/* Row 2: Model Usage + Concurrency */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Model Token Distribution */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Bot className="w-4 h-4 text-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>
{/* 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={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-20 cursor-default">
{lanes.slice(-Math.min(lanes.length, 40)).map((lane, i) => {
const barPct =
maxLaneCount > 0 ? Math.max(4, Math.round((lane.count / maxLaneCount) * 100)) : 4;
const color =
lane.count > 5
? "rgb(var(--status-danger))"
: lane.count > 2
? "rgb(var(--status-warning))"
: lane.count > 0
? "rgb(var(--status-success))"
: "rgb(var(--border))";
return (
<div
key={i}
className="flex-1 rounded-sm transition-all"
style={{
height: `${barPct}%`,
backgroundColor: color,
opacity: lane.count === 0 ? 0.2 : 0.85,
}}
/>
);
})}
{lanes.length === 0 && (
<p className="text-xs text-fg-muted text-center w-full self-center">
{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>
{/* Row 3: Tool Usage + Subagent Effectiveness */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Tool Invocations */}
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Zap className="w-4 h-4 text-status-warning" />
<span className="text-xs text-fg-muted uppercase tracking-wider">
{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" />
<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>
</div>
);
}
// 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]);
useLiveRefresh(load, { intervalMs: 10000 });
// A new event is prepended at once (no reload); the poll reconciles.
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];
setActiveAgents(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(subsRes.agents.filter((a) => a.type === "subagent"));
}
setError(failed[0] ?? null);
}, [scope]);
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={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {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={load} 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")}
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")}
value={stats?.active_agents ?? ""}
icon={Bot}
accentColor="text-status-success"
loading={!stats}
/>
<StatCard
label={t("activeSubagents")}
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
icon={GitBranch}
accentColor="text-syntax-keyword"
trend={stats ? `${allSubagents.length}${t("totalTrend")}` : undefined}
loading={!stats}
/>
<StatCard
label={t("eventsToday")}
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")}
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")}
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>
);
}