Files
AgentHub/client/src/pages/Analytics.tsx
T
nntrivi2001 7357070fb9 chore: remove unused desktop app, cloud deployment infra, and monitoring stack
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/
Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana
stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml,
their npm scripts, and every dangling reference across README,
ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file
MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics
endpoint itself is untouched — it's the dashboard's own route, not
part of the removed monitoring stack.
2026-08-11 12:16:54 +07:00

1478 lines
58 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 Analytics.tsx
* @description Provides a comprehensive analytics dashboard for monitoring Claude Code sessions, agents, token usage, and events in real-time. Features include an activity heatmap, token distribution charts, session outcome breakdowns, and more, all with interactive tooltips and live updates via WebSocket.
* @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`
* - `../lib/format`
* - `../components/Tip`
* - `../components/Skeleton`
* - `../lib/types`
*
* ## Public surface
* - `Analytics` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **Analytics**
* 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, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import {
RefreshCw,
Download,
Zap,
Bot,
FolderOpen,
Cpu,
DollarSign,
Clock,
BarChart3,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format";
import { Tip } from "../components/Tip";
import { Skeleton, StatValueSkeleton, TextSkeleton } from "../components/Skeleton";
import type { Analytics as AnalyticsData, CostResult } from "../lib/types";
// ── Tooltip ───────────────────────────────────────────────────────────────────
function ChartTooltip({ x, y, children }: { x: number; y: number; children: React.ReactNode }) {
const nearRight = x > window.innerWidth - 200;
return (
<div
className="fixed z-50 px-2 py-1.5 text-xs bg-[#12121f] border border-[#2a2a4a] rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
style={{
left: nearRight ? x - 14 : x + 14,
top: y - 10,
transform: nearRight ? "translateX(-100%)" : undefined,
}}
>
{children}
</div>
);
}
function useTooltip() {
const [tooltip, setTooltip] = useState<{
x: number;
y: number;
content: React.ReactNode;
} | null>(null);
const show = (e: React.MouseEvent, content: React.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 ──────────────────────────────────────────────────────────────────
function cellColor(count: number, max: number) {
if (count === 0) return "#161625";
// Log scale + RGB interpolation across a wide color ramp for maximum perceptual range
const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1);
// Ramp: near-black indigo → deep indigo → bright indigo → lavender
type RGB = [number, number, number];
const stops: RGB[] = [
[22, 20, 60], // near-black indigo
[55, 48, 163], // deep indigo
[99, 102, 241], // bright indigo
[199, 210, 254], // lavender
];
const scaled = t * (stops.length - 1);
const lo = Math.min(Math.floor(scaled), stops.length - 2);
const frac = scaled - lo;
const [r1, g1, b1]: RGB = stops[lo] as RGB;
const [r2, g2, b2]: RGB = stops[lo + 1] as RGB;
const r = Math.round(r1 + (r2 - r1) * frac);
const g = Math.round(g1 + (g2 - g1) * frac);
const b = Math.round(b1 + (b2 - b1) * frac);
return `rgb(${r},${g},${b})`;
}
function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }>> }) {
const { show, move, hide, node } = useTooltip();
const { t, i18n } = useTranslation(["analytics", "common"]);
const locale = i18n.resolvedLanguage ?? i18n.language;
const monthLabels = useMemo(
() =>
Array.from({ length: 12 }, (_, month) =>
new Intl.DateTimeFormat(locale, { month: "short" }).format(new Date(2026, month, 1))
),
[locale]
);
const dayNames = useMemo(
() =>
Array.from({ length: 7 }, (_, day) =>
new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
new Date(2026, 0, 4 + day) // Jan 4, 2026 is Sunday
)
),
[locale]
);
// Labels on the left: Sun (0), Tue (2), Thu (4)
const dayLabels = [dayNames[0], "", dayNames[2], "", dayNames[4], "", ""];
const maxCount = Math.max(...weeks.flatMap((w) => w.map((c) => c.count)), 1);
// Compute month label positions accurately. A label is ~3 columns wide (each
// column is 16px, a 3-letter month ≈ 22px). The very first month is often a
// stub - the 52-week window starts mid-month, so e.g. "May" sits at col 0 and
// "Jun" at col 1 and they overlap. Drop the *stub* (the earlier, partial one)
// rather than the full month after it, so the next label isn't pushed away.
const monthPositions = useMemo(() => {
const MIN_LABEL_GAP = 3; // columns a label needs to itself
const starts: Array<{ label: string; col: number }> = [];
let prevMonth = -1;
weeks.forEach((week, wi) => {
const firstCell = week[0];
if (!firstCell) return;
const parts = firstCell.date.split("-").map(Number);
const m = (parts[1] || 1) - 1; // 0-indexed month
if (m !== prevMonth) {
starts.push({ label: monthLabels[m] ?? "", col: wi });
prevMonth = m;
}
});
// Drop a label when the *next* month starts too soon after it (it would
// collide). Suppressing the earlier label leaves the later, full month in
// place - no gap is introduced.
return starts.filter((s, i) => {
const next = starts[i + 1];
return !next || next.col - s.col >= MIN_LABEL_GAP;
});
}, [weeks, monthLabels]);
return (
<div className="relative">
{node}
{/* Month labels */}
<div className="flex mb-2 ml-[32px] relative h-4">
{monthPositions.map((mp, i) => (
<div
key={i}
className="absolute text-[10px] text-fg-muted font-medium whitespace-nowrap"
style={{ left: mp.col * 16 }}
>
{mp.label}
</div>
))}
</div>
<div className="flex" style={{ gap: "3px" }}>
{/* Day labels */}
<div className="flex flex-col mr-1 w-7" style={{ gap: "3px" }}>
{dayLabels.map((d, i) => (
<div
key={i}
className="text-[9px] text-fg-muted flex items-center justify-end pr-1.5"
style={{ height: 13 }}
>
{d}
</div>
))}
</div>
{/* Cells */}
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col" style={{ gap: "3px" }}>
{week.map((cell) => (
<div
key={cell.date}
onMouseEnter={(e) => {
const parts = cell.date.split("-").map(Number);
const y = parts[0] || 0;
const m = (parts[1] || 1) - 1;
const d = parts[2] || 1;
const date = new Date(y, m, d, 12);
const dow = date.getDay();
show(
e,
<>
<span className="text-fg-secondary">
{dayNames[dow] ?? ""}, {cell.date}
</span>
<span className="ml-2 font-medium">
{t("eventCountLabel", { count: cell.count })}
</span>
</>
);
}}
onMouseMove={move}
onMouseLeave={hide}
style={{
width: 13,
height: 13,
borderRadius: 2,
backgroundColor: cellColor(cell.count, maxCount),
border: "1px solid rgba(255,255,255,0.04)",
flexShrink: 0,
cursor: "default",
}}
/>
))}
</div>
))}
</div>
{/* Legend */}
<div className="flex items-center gap-2 mt-3 text-[11px] text-fg-muted">
<span>{t("less")}</span>
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
const v = Math.round(f * maxCount);
return (
<div
key={f}
style={{
width: 13,
height: 13,
borderRadius: 2,
backgroundColor: cellColor(v, maxCount),
border: "1px solid rgba(255,255,255,0.06)",
}}
/>
);
})}
<span>{t("more")}</span>
</div>
</div>
);
}
// ── Sparkline bar chart ───────────────────────────────────────────────────────
function Sparkline({
data,
color = "#6366f1",
}: {
data: Array<{ date: string; count: number }>;
color?: string;
}) {
const { t } = useTranslation("analytics");
const { show, move, hide, node } = useTooltip();
const max = Math.max(...data.map((d) => d.count), 1);
return (
<div className="relative flex items-end gap-px h-16">
{node}
{data.map(({ date, count }) => (
<div
key={date}
className="flex-1 rounded-sm transition-all cursor-default"
style={{
height: `${Math.max(4, Math.round((count / max) * 100))}%`,
backgroundColor: color,
opacity: count === 0 ? 0.15 : 0.85,
}}
onMouseEnter={(e) =>
show(
e,
<>
<span className="text-fg-secondary">{date}</span>
<span className="ml-2 font-medium">{t("eventCountLabel", { count })}</span>
</>
)
}
onMouseMove={move}
onMouseLeave={hide}
/>
))}
</div>
);
}
function CostTrendLine({
data,
color = "#10b981",
}: {
data: Array<{ date: string; cost: number }>;
color?: string;
}) {
const { show, move, hide, node } = useTooltip();
if (data.length === 0) return null;
const width = 320;
const height = 88;
const padX = 8;
const padY = 8;
const min = Math.min(...data.map((d) => d.cost), 0);
const max = Math.max(...data.map((d) => d.cost), 0);
const span = Math.max(max - min, 0.0001);
const step = data.length > 1 ? (width - padX * 2) / (data.length - 1) : 0;
const points = data.map(({ date, cost }, i) => {
const x = padX + i * step;
const y = height - padY - ((cost - min) / span) * (height - padY * 2);
return { date, cost, x, y };
});
const linePoints = points.map((p) => `${p.x},${p.y}`).join(" ");
const firstX = points[0]?.x ?? padX;
const lastX = points[points.length - 1]?.x ?? padX;
const areaPoints = `${firstX},${height - padY} ${linePoints} ${lastX},${height - padY}`;
return (
<div className="relative">
{node}
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[88px] overflow-visible">
<defs>
<linearGradient id="daily-cost-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.35} />
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
</linearGradient>
</defs>
<polyline points={areaPoints} fill="url(#daily-cost-fill)" stroke="none" />
<polyline
points={linePoints}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{points.map((point) => (
<g key={point.date}>
<circle
cx={point.x}
cy={point.y}
r={2.5}
fill={color}
style={{ pointerEvents: "none" }}
/>
<circle
cx={point.x}
cy={point.y}
r={8}
fill="transparent"
onMouseEnter={(e) =>
show(
e,
<>
<span className="text-fg-secondary">{point.date}</span>
<span className="ml-2 font-medium">{fmtCostFull(point.cost)}</span>
</>
)
}
onMouseMove={move}
onMouseLeave={hide}
/>
</g>
))}
</svg>
</div>
);
}
// ── Bar row ───────────────────────────────────────────────────────────────────
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>
);
}
// ── Donut segment via SVG ─────────────────────────────────────────────────────
function DonutChart({
segments,
formatTotal,
}: {
segments: Array<{ label: string; value: number; color: string }>;
formatTotal?: (total: number) => string;
}) {
const { t } = useTranslation(["analytics", "common"]);
const { show, move, hide, node } = useTooltip();
const total = segments.reduce((s, g) => s + g.value, 0);
if (total === 0) return <div className="text-xs text-fg-muted">{t("common:noData")}</div>;
const r = 52;
const cx = 64;
const cy = 64;
const stroke = 18;
const circumference = 2 * Math.PI * r;
// offset starts at circumference/4 (top of circle) and decrements by each segment's arc.
// strokeDashoffset = offset (not negated) is the correct formula for starting at 12 o'clock.
let offset = circumference / 4;
return (
<div className="flex items-center justify-center gap-6 w-full">
{node}
<svg width={128} height={128} viewBox="0 0 128 128" className="flex-shrink-0">
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#1e1e2e" strokeWidth={stroke} />
{segments.map(({ label, value, color }, i) => {
const dash = (value / total) * circumference;
const gap = circumference - dash;
const pct = Math.round((value / total) * 100);
const currentOffset = offset;
offset -= dash;
return (
<circle
key={i}
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeDasharray={`${dash} ${gap}`}
strokeDashoffset={currentOffset}
style={{ cursor: "default" }}
onMouseEnter={(e) =>
show(
e,
<>
<span style={{ color }}>{label}</span>
<span className="ml-2 font-medium">{pct}%</span>
</>
)
}
onMouseMove={move}
onMouseLeave={hide}
/>
);
})}
<text x={cx} y={cy - 6} textAnchor="middle" className="fill-fg-secondary" fontSize={11}>
{(formatTotal ?? fmt)(total)}
</text>
<text x={cx} y={cy + 10} textAnchor="middle" className="fill-fg-muted" fontSize={9}>
{t("common:total_lower")}
</text>
</svg>
<div className="space-y-2">
{segments.map(({ label, value, color }) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-fg-secondary">{label}</span>
<span className="text-fg-muted ml-auto pl-4">{Math.round((value / total) * 100)}%</span>
</div>
))}
</div>
</div>
);
}
// ── StatPill ──────────────────────────────────────────────────────────────────
function StatPill({
label,
value,
raw,
sub,
icon: Icon,
color = "text-accent",
loading = false,
}: {
label: string;
value: string | number;
raw?: string;
sub?: string;
icon: React.ElementType;
color?: string;
loading?: boolean;
}) {
return (
<div className="card p-5 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-fg-muted uppercase tracking-wider">{label}</span>
<Icon className={`w-4 h-4 ${color}`} />
</div>
{loading ? (
<StatValueSkeleton />
) : (
<p className={`text-2xl font-bold ${color}`}>
{raw ? <Tip raw={raw}>{value}</Tip> : value}
</p>
)}
{loading ? (
<TextSkeleton width="w-20" />
) : (
sub && <p className="text-[11px] text-fg-muted">{sub}</p>
)}
</div>
);
}
// ── Loading skeleton ───────────────────────────────────────────────────────────
/** A single chart-card placeholder: title bar, chart body, two footer lines.
* Uses the shared pulsing <Skeleton> so the whole grid shimmers while data
* loads (mirrors the chart-card layout so nothing jumps when data arrives). */
function ChartCardSkeleton({
className = "",
bodyH = "h-32",
}: {
className?: string;
bodyH?: string;
}) {
return (
<div className={`card p-5 space-y-4 ${className}`} aria-busy="true" aria-label="Loading">
<Skeleton className="h-4 w-1/3" />
<Skeleton className={`block w-full ${bodyH}`} rounded="lg" />
<div className="space-y-2 pt-1">
<Skeleton className="block h-3 w-2/3" />
<Skeleton className="block h-3 w-1/2" />
</div>
</div>
);
}
/** Pulsing placeholder for the Analytics chart region (heatmap + sparkline row,
* tab bar, and the three-up chart grid) shown while the analytics payload is
* still being fetched. */
function AnalyticsChartsSkeleton() {
return (
<div className="space-y-6" aria-busy="true" aria-label="Loading analytics charts">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<ChartCardSkeleton className="lg:col-span-2" bodyH="h-32" />
<ChartCardSkeleton bodyH="h-32" />
</div>
<Skeleton className="block h-9 w-80" rounded="lg" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<ChartCardSkeleton bodyH="h-44" />
<ChartCardSkeleton bodyH="h-44" />
<ChartCardSkeleton bodyH="h-44" />
</div>
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export function Analytics() {
const { t, i18n } = useTranslation("analytics");
const [data, setData] = useState<AnalyticsData | null>(null);
const [costData, setCostData] = useState<CostResult | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"tokens" | "cost" | "workflow" | "productivity">(
"cost"
);
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
// Global data scope; a change re-runs `load` (api injects the `sources` param).
const [scope] = useDataScope();
const load = useCallback(async () => {
try {
const [result, cost] = await Promise.all([
api.analytics.get(),
api.pricing.totalCost().catch(() => null),
]);
setData(result);
setCostData(cost);
setLastUpdate(new Date());
} finally {
setLoading(false);
}
}, [scope]);
useEffect(() => {
load();
const interval = setInterval(load, 15000);
return () => clearInterval(interval);
}, [load]);
useEffect(() => {
return eventBus.subscribe((msg) => {
if (
msg.type === "session_created" ||
msg.type === "session_updated" ||
msg.type === "new_event" ||
msg.type === "agent_created" ||
isRemoteDataRefreshMessage(msg)
) {
load();
}
});
}, [load]);
function handleExport() {
if (!data) return;
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `analytics-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
// Format a local Date object as a YYYY-MM-DD string for heatmap lookups
function localDateStr(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
// Server now returns dates grouped by the user's local timezone (via tz_offset),
// so we can use them directly without conversion.
const dailyMap: Record<string, number> = {};
for (const d of data?.daily_events ?? []) {
dailyMap[d.date] = (dailyMap[d.date] ?? 0) + d.count;
}
// Build heatmap: 52 weeks × 7 days
// Align start to Sunday (day 0) so row indices match day-of-week labels correctly.
const today = new Date();
today.setHours(12, 0, 0, 0); // Normalize to noon
const startDate = new Date(today);
startDate.setDate(today.getDate() - 364); // Exactly 52 weeks ago
// Roll back to the previous Sunday
const startDow = startDate.getDay();
startDate.setDate(startDate.getDate() - startDow);
const weeks: Array<Array<{ date: string; count: number }>> = [];
for (let w = 0; w < 53; w++) {
const week: Array<{ date: string; count: number }> = [];
for (let d = 0; d < 7; d++) {
const cell = new Date(startDate);
cell.setDate(startDate.getDate() + w * 7 + d);
if (cell > today) break;
const dateStr = localDateStr(cell);
week.push({ date: dateStr, count: dailyMap[dateStr] ?? 0 });
}
if (week.length > 0) weeks.push(week);
}
// Last 30 days for sparkline
const last30 = Array.from({ length: 30 }, (_, i) => {
const d = new Date(today);
d.setDate(today.getDate() - (29 - i));
const dateStr = localDateStr(d);
return { date: dateStr, count: dailyMap[dateStr] ?? 0 };
});
// Convert daily_sessions - server already returns local dates
const dailySessionsLocal: Array<{ date: string; count: number }> = [];
const sessMap: Record<string, number> = {};
for (const d of data?.daily_sessions ?? []) {
sessMap[d.date] = (sessMap[d.date] ?? 0) + d.count;
}
for (const [date, count] of Object.entries(sessMap)) {
dailySessionsLocal.push({ date, count });
}
dailySessionsLocal.sort((a, b) => a.date.localeCompare(b.date));
// Convert daily_costs - server already returns local dates
const dailyCostsLocal: Array<{ date: string; cost: number }> = [];
const costMap: Record<string, number> = {};
for (const d of costData?.daily_costs ?? []) {
costMap[d.date] = (costMap[d.date] ?? 0) + d.cost;
}
for (const [date, cost] of Object.entries(costMap)) {
dailyCostsLocal.push({ date, cost });
}
dailyCostsLocal.sort((a, b) => a.date.localeCompare(b.date));
const dailyCostLast30 = Array.from({ length: 30 }, (_, i) => {
const d = new Date(today);
d.setDate(today.getDate() - (29 - i));
const dateStr = localDateStr(d);
return { date: dateStr, cost: costMap[dateStr] ?? 0 };
});
const peakCostDay = dailyCostLast30.reduce(
(max, curr) => (curr.cost > max.cost ? curr : max),
dailyCostLast30[0] ?? { date: "", cost: 0 }
);
const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0);
const costBreakdown = [...(costData?.breakdown ?? [])]
.filter((b) => b.cost > 0)
.sort((a, b) => b.cost - a.cost);
const locale = i18n.resolvedLanguage ?? i18n.language;
const weekdayOrder = [1, 2, 3, 4, 5, 6, 0];
const weekdayCosts = weekdayOrder.map((dow) => {
const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
new Date(Date.UTC(2026, 0, 4 + dow))
);
const cost = dailyCostLast30
.filter((day) => new Date(day.date + "T12:00:00").getDay() === dow)
.reduce((sum, day) => sum + day.cost, 0);
return { label, cost };
});
const maxWeekdayCost = Math.max(...weekdayCosts.map((d) => d.cost), 1);
const totalTokens =
(data?.tokens.total_input ?? 0) +
(data?.tokens.total_output ?? 0) +
(data?.tokens.total_cache_read ?? 0) +
(data?.tokens.total_cache_write ?? 0);
const tokenMixSegments = [
{ label: t("common:token.input"), value: data?.tokens.total_input ?? 0, color: "#60a5fa" },
{ label: t("common:token.output"), value: data?.tokens.total_output ?? 0, color: "#34d399" },
{
label: t("common:token.cacheRead"),
value: data?.tokens.total_cache_read ?? 0,
color: "#a78bfa",
},
{
label: t("common:token.cacheWrite"),
value: data?.tokens.total_cache_write ?? 0,
color: "#facc15",
},
].filter((s) => s.value > 0);
const maxToolCount = data?.tool_usage[0]?.count ?? 1;
const maxAgentTypeCount = data?.agent_types[0]?.count ?? 1;
const maxEventTypeCount = data?.event_types[0]?.count ?? 1;
const cacheHitPct =
totalTokens > 0 ? Math.round(((data?.tokens.total_cache_read ?? 0) / totalTokens) * 100) : 0;
const sessionOutcomeSegments = [
{
label: t("common:status.completed"),
value: data?.sessions_by_status?.completed ?? 0,
color: "#8b5cf6",
},
{
label: t("common:status.active"),
value: data?.sessions_by_status?.active ?? 0,
color: "#10b981",
},
{
label: t("common:status.error"),
value: data?.sessions_by_status?.error ?? 0,
color: "#ef4444",
},
{
label: t("common:status.abandoned"),
value: data?.sessions_by_status?.abandoned ?? 0,
color: "#f59e0b",
},
].filter((s) => s.value > 0);
const agentStatusSegments = [
{
label: t("common:status.completed"),
value: data?.agents_by_status?.completed ?? 0,
color: "#8b5cf6",
},
{
label: t("common:status.working"),
value: data?.agents_by_status?.working ?? 0,
color: "#10b981",
},
{
label: t("common:status.waiting"),
value: data?.agents_by_status?.waiting ?? 0,
color: "#eab308",
},
{
label: t("common:status.error"),
value: data?.agents_by_status?.error ?? 0,
color: "#ef4444",
},
].filter((s) => s.value > 0);
const EVENT_TYPE_COLORS: Record<string, string> = {
PreToolUse: "bg-status-success",
PostToolUse: "bg-blue-500",
Stop: "bg-violet-400",
SubagentStop: "bg-yellow-400",
Notification: "bg-orange-400",
};
return (
<div className="animate-fade-in space-y-8">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-3 mb-1">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<BarChart3 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 flex items-center gap-2">
{t("subtitle")}
<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 ml-2">
<Clock className="w-3 h-3" />
{lastUpdate.toLocaleTimeString()}
</span>
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={load} className="btn-ghost" disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
{t("common:refresh")}
</button>
<button onClick={handleExport} className="btn-ghost" disabled={!data}>
<Download className="w-4 h-4" />
{t("export")}
</button>
</div>
</div>
{/* Key stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<StatPill
label={t("totalSessions")}
value={data ? fmt(data.overview.total_sessions) : ""}
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
sub={data ? `${data.overview.active_sessions} ${t("common:active")}` : undefined}
icon={FolderOpen}
color="text-blue-500"
loading={!data}
/>
<StatPill
label={t("totalAgents")}
value={data ? fmt(data.overview.total_agents) : ""}
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
sub={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
icon={Bot}
color="text-status-success"
loading={!data}
/>
<StatPill
label={t("totalTokens")}
value={data ? fmt(totalTokens) : ""}
raw={data ? totalTokens.toLocaleString() : undefined}
sub={data ? `${cacheHitPct}${t("cacheHitRate")}` : undefined}
icon={Cpu}
color="text-violet-400"
loading={!data}
/>
<StatPill
label={t("totalCost")}
value={costData ? fmtCost(costData.total_cost) : ""}
raw={costData ? fmtCostFull(costData.total_cost) : undefined}
sub={
costData
? `${costData.breakdown.length} ${t("common:cost.model", { count: costData.breakdown.length })}`
: undefined
}
icon={DollarSign}
color="text-status-success"
loading={!costData}
/>
<StatPill
label={t("totalEvents")}
value={data ? fmt(data.overview.total_events) : ""}
raw={data ? data.overview.total_events.toLocaleString() : undefined}
sub={data ? `~${data.avg_events_per_session}${t("perSession")}` : undefined}
icon={Zap}
color="text-yellow-400"
loading={!data}
/>
</div>
{!data ? (
<AnalyticsChartsSkeleton />
) : (
<>
{/* Activity heatmap + 30-day sparkline */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="card p-5 lg:col-span-2">
<h3 className="text-sm font-medium text-fg-secondary mb-4">{t("eventActivity")}</h3>
<div className="overflow-x-auto">
<div className="w-fit min-w-max mx-auto">
<Heatmap weeks={weeks} />
</div>
</div>
</div>
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-1">{t("last30Days")}</h3>
<p className="text-[11px] text-fg-muted mb-4">{t("dailyEventCount")}</p>
<Sparkline 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={() => setActiveTab(key)}
className={`px-4 py-1.5 text-xs font-medium rounded-md transition-colors ${
activeTab === key
? "bg-surface-4 text-fg-secondary"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{label}
</button>
))}
</div>
{activeTab === "tokens" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Token bars */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("tokenDistribution")}
</h3>
<div className="space-y-4">
{[
{
label: t("common:token.input"),
value: data?.tokens.total_input ?? 0,
color: "bg-blue-500",
},
{
label: t("common:token.output"),
value: data?.tokens.total_output ?? 0,
color: "bg-status-success",
},
{
label: t("common:token.cacheRead"),
value: data?.tokens.total_cache_read ?? 0,
color: "bg-violet-400",
},
{
label: t("common:token.cacheWrite"),
value: data?.tokens.total_cache_write ?? 0,
color: "bg-yellow-400",
},
].map(({ label, value, color }) => (
<BarRow
key={label}
label={label}
count={value}
max={Math.max(totalTokens, 1)}
color={color}
/>
))}
</div>
<div className="mt-6 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("common:token.totalTokens")}</span>
<Tip raw={totalTokens.toLocaleString()}>
<span className="text-fg-secondary font-mono">{fmt(totalTokens)}</span>
</Tip>
</div>
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("cacheEfficiency")}</span>
<span className="text-violet-400 font-mono">{cacheHitPct}%</span>
</div>
</div>
</div>
{/* Token summary */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("tokenBreakdown")}
</h3>
<div className="space-y-3">
{[
{
label: t("common:token.input"),
value: data?.tokens.total_input ?? 0,
color: "text-blue-500",
},
{
label: t("common:token.output"),
value: data?.tokens.total_output ?? 0,
color: "text-status-success",
},
{
label: t("common:token.cacheRead"),
value: data?.tokens.total_cache_read ?? 0,
color: "text-violet-400",
},
{
label: t("common:token.cacheWrite"),
value: data?.tokens.total_cache_write ?? 0,
color: "text-yellow-400",
},
{ label: t("common:total"), value: totalTokens, color: "text-fg-primary" },
].map(({ label, value, color }) => (
<div
key={label}
className="flex justify-between items-center py-2 border-b border-border last:border-0"
>
<span className="text-xs text-fg-secondary">{label}</span>
<span className={`text-sm font-mono font-medium ${color}`}>
{value.toLocaleString()}
</span>
</div>
))}
</div>
{totalTokens === 0 && (
<p className="text-[11px] text-fg-muted mt-4">{t("tokenInfo")}</p>
)}
</div>
{/* Token mix donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("tokenMix")}</h3>
{tokenMixSegments.length === 0 ? (
<p className="text-sm text-fg-muted">{t("common:noData")}</p>
) : (
<>
<DonutChart segments={tokenMixSegments} formatTotal={(total) => fmt(total)} />
<div className="mt-4 pt-4 border-t border-border space-y-2">
{tokenMixSegments.map((segment) => (
<div key={segment.label} className="flex justify-between text-xs">
<span className="text-fg-secondary">{segment.label}</span>
<span className="text-fg-secondary font-mono">
<Tip raw={segment.value.toLocaleString()}>{fmt(segment.value)}</Tip>
</span>
</div>
))}
</div>
</>
)}
</div>
</div>
)}
{activeTab === "cost" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Daily cost trends */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-1">
{t("dailyCostTrends")}
</h3>
{dailyCostsLocal.length === 0 ? (
<p className="text-sm text-fg-muted">{t("noDailyCostData")}</p>
) : (
<>
<p className="text-[11px] text-fg-muted mb-4">{t("costPerDay")}</p>
<CostTrendLine data={dailyCostLast30} />
<div className="flex justify-between text-[11px] text-fg-muted mt-2">
<span>{dailyCostLast30[0]?.date?.slice(5)}</span>
<span>{dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)}</span>
</div>
<div className="mt-4 pt-4 border-t border-border space-y-1">
<div className="flex justify-between text-xs">
<span className="text-fg-muted">{t("peakCostDay")}</span>
<span className="text-status-success font-mono">
<Tip raw={`${peakCostDay.date} • ${fmtCostFull(peakCostDay.cost)}`}>
{fmtCost(peakCostDay.cost)}
</Tip>
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-fg-muted">{t("totalCost30d")}</span>
<span className="text-status-success font-mono">
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
</span>
</div>
</div>
</>
)}
</div>
{/* Cost by model */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("costByModel")}</h3>
{costBreakdown.length > 0 ? (
<>
<DonutChart
segments={costBreakdown.map((b, i) => ({
label: formatModelName(b.model) ?? b.model,
value: Math.round(b.cost * 100),
color:
["#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#ec4899"][
i % 6
] ?? "#6b7280",
}))}
formatTotal={(cents) => fmtCost(cents / 100)}
/>
<div className="mt-4 pt-4 border-t border-border space-y-2">
{costBreakdown.map((b) => (
<div key={b.model} className="flex justify-between text-xs">
<span className="text-fg-secondary font-mono truncate">
{formatModelName(b.model)}
</span>
<span className="text-status-success font-mono font-medium ml-2">
<Tip raw={fmtCostFull(b.cost)}>{fmtCost(b.cost)}</Tip>
</span>
</div>
))}
<div className="flex justify-between text-xs pt-2 border-t border-border">
<span className="text-fg-secondary font-medium">{t("common:total")}</span>
<span className="text-status-success font-mono font-semibold">
<Tip raw={fmtCostFull(costData?.total_cost ?? 0)}>
{fmtCost(costData?.total_cost ?? 0)}
</Tip>
</span>
</div>
</div>
</>
) : (
<p className="text-sm text-fg-muted">{t("noCostData")}</p>
)}
</div>
{/* Cost by weekday */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-1">
{t("costByWeekday")}
</h3>
{dailyCostsLocal.length === 0 ? (
<p className="text-sm text-fg-muted">{t("noDailyCostData")}</p>
) : (
<>
<p className="text-[11px] text-fg-muted mb-4">{t("last30Days")}</p>
<div className="space-y-3">
{weekdayCosts.map(({ label, cost }) => (
<CostBarRow
key={label}
label={label}
cost={cost}
max={maxWeekdayCost}
color="bg-cyan-400"
/>
))}
</div>
<div className="mt-4 pt-4 border-t border-border text-xs flex justify-between">
<span className="text-fg-muted">{t("common:total")}</span>
<span className="text-cyan-400 font-mono">
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
</span>
</div>
</>
)}
</div>
</div>
)}
{activeTab === "workflow" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Agent type distribution */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("subagentTypes")}
</h3>
{(data?.agent_types ?? []).length === 0 ? (
<p className="text-sm text-fg-muted">{t("noSubagentData")}</p>
) : (
<div className="space-y-3">
{(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => (
<BarRow
key={subagent_type}
label={subagent_type}
count={count}
max={maxAgentTypeCount}
color="bg-violet-400"
/>
))}
</div>
)}
</div>
{/* Agent status donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("agentStatus")}</h3>
<DonutChart segments={agentStatusSegments} />
<div className="mt-4 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("totalAgentsLabel")}</span>
<Tip raw={(data?.overview.total_agents ?? 0).toLocaleString()}>
<span className="text-fg-secondary font-mono">
{fmt(data?.overview.total_agents ?? 0)}
</span>
</Tip>
</div>
{agentStatusSegments.map((s) => (
<div
key={s.label}
className="flex items-center justify-between text-xs text-fg-muted"
>
<span className="flex items-center gap-1.5">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: s.color }}
/>
{s.label}
</span>
<Tip raw={s.value.toLocaleString()}>
<span className="text-fg-secondary font-mono">{fmt(s.value)}</span>
</Tip>
</div>
))}
</div>
</div>
{/* Event type breakdown */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("eventTypes")}</h3>
{(data?.event_types ?? []).length === 0 ? (
<p className="text-sm text-fg-muted">{t("noEventData")}</p>
) : (
<div className="space-y-3">
{(data?.event_types ?? []).map(({ event_type, count }) => (
<BarRow
key={event_type}
label={event_type}
count={count}
max={maxEventTypeCount}
color={EVENT_TYPE_COLORS[event_type] ?? "bg-surface-4"}
/>
))}
</div>
)}
</div>
</div>
)}
{activeTab === "productivity" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Top tools */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("toolUsage")}</h3>
{(data?.tool_usage ?? []).length === 0 ? (
<p className="text-sm text-fg-muted">{t("noToolData")}</p>
) : (
<div className="space-y-3">
{(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => (
<BarRow
key={tool_name}
label={tool_name}
count={count}
max={maxToolCount}
color="bg-yellow-400"
/>
))}
</div>
)}
</div>
{/* Session outcomes donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("sessionOutcomes")}
</h3>
<DonutChart segments={sessionOutcomeSegments} />
<div className="mt-4 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("totalSessionsLabel")}</span>
<Tip raw={(data?.overview.total_sessions ?? 0).toLocaleString()}>
<span className="text-fg-secondary font-mono">
{fmt(data?.overview.total_sessions ?? 0)}
</span>
</Tip>
</div>
{sessionOutcomeSegments.map((s) => (
<div
key={s.label}
className="flex items-center justify-between text-xs text-fg-muted"
>
<span className="flex items-center gap-1.5">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: s.color }}
/>
{s.label}
</span>
<Tip raw={s.value.toLocaleString()}>
<span className="text-fg-secondary font-mono">{fmt(s.value)}</span>
</Tip>
</div>
))}
</div>
</div>
{/* Daily session trends */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("dailySessionTrends")}
</h3>
{dailySessionsLocal.length === 0 ? (
<p className="text-sm text-fg-muted">{t("noSessionTrendData")}</p>
) : (
<>
<Sparkline data={dailySessionsLocal.slice(-30)} color="#6366f1" />
<div className="mt-4 space-y-2">
{dailySessionsLocal
.slice(-7)
.reverse()
.map(({ date, count }) => {
const maxD = Math.max(
...(dailySessionsLocal.length > 0
? dailySessionsLocal
: [{ count: 1 }]
).map((d) => d.count)
);
return (
<div key={date} className="flex items-center gap-3">
<span className="text-[11px] text-fg-muted font-mono w-20 flex-shrink-0">
{date.slice(5)}
</span>
<div className="flex-1 bg-surface-3 rounded-full h-1.5">
<div
className="bg-accent h-1.5 rounded-full"
style={{
width: `${Math.round((count / Math.max(maxD, 1)) * 100)}%`,
}}
/>
</div>
<span className="text-[11px] text-fg-muted w-4 text-right">
{count}
</span>
</div>
);
})}
</div>
<p className="text-[11px] text-fg-muted mt-3">{t("last7Days")}</p>
</>
)}
</div>
</div>
)}
</div>
</>
)}
</div>
);
}