7357070fb9
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.
1478 lines
58 KiB
TypeScript
1478 lines
58 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|