- {/* Day labels */}
-
- {dayLabels.map((d, i) => (
-
- {d}
-
- ))}
-
- {/* Cells */}
- {weeks.map((week, wi) => (
-
- {week.map((cell) => (
-
{
- 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,
- <>
-
- {dayNames[dow] ?? ""}, {cell.date}
-
-
- {t("eventCountLabel", { count: cell.count })}
-
- >
- );
- }}
- 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",
- }}
- />
- ))}
-
- ))}
-
- {/* Legend */}
-
-
{t("less")}
- {[0, 0.25, 0.5, 0.75, 1].map((f) => {
- const v = Math.round(f * maxCount);
- return (
-
- );
- })}
-
{t("more")}
-
-
- );
-}
-
-// ── 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 (
-
- {node}
- {data.map(({ date, count }) => (
-
- show(
- e,
- <>
- {date}
- {t("eventCountLabel", { count })}
- >
- )
- }
- onMouseMove={move}
- onMouseLeave={hide}
- />
- ))}
-
- );
-}
-
-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 (
-
- {node}
-
-
-
-
-
-
-
-
-
- {points.map((point) => (
-
-
-
- show(
- e,
- <>
- {point.date}
- {fmtCostFull(point.cost)}
- >
- )
- }
- onMouseMove={move}
- onMouseLeave={hide}
- />
-
- ))}
-
-
- );
-}
-
-// ── 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 (
-
-
- {label}
-
-
-
- {fmt(count)}
-
-
- );
-}
-
-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 (
-
-
- {label}
-
-
-
- {fmtCost(cost)}
-
-
- );
-}
-
-// ── 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
{t("common:noData")}
;
-
- 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 (
-
- {node}
-
-
- {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 (
-
- show(
- e,
- <>
- {label}
- {pct}%
- >
- )
- }
- onMouseMove={move}
- onMouseLeave={hide}
- />
- );
- })}
-
- {(formatTotal ?? fmt)(total)}
-
-
- {t("common:total_lower")}
-
-
-
- {segments.map(({ label, value, color }) => (
-
-
- {label}
- {Math.round((value / total) * 100)}%
-
- ))}
-
-
- );
-}
-
-// ── 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 (
-
-
- {label}
-
-
- {loading ? (
-
- ) : (
-
- {raw ? {value} : value}
-
- )}
- {loading ? (
-
- ) : (
- sub &&
{sub}
- )}
-
- );
-}
-
-// ── Loading skeleton ───────────────────────────────────────────────────────────
-
-/** A single chart-card placeholder: title bar, chart body, two footer lines.
- * Uses the shared pulsing
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 (
-
- );
-}
-
-/** 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 (
-
- );
-}
-
-// ── Main page ─────────────────────────────────────────────────────────────────
-
-export function Analytics() {
- const { t, i18n } = useTranslation("analytics");
- const [data, setData] = useState(null);
- const [costData, setCostData] = useState(null);
- const [lastUpdate, setLastUpdate] = useState(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 = {};
- 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> = [];
- 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 = {};
- 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 = {};
- 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 = {
- PreToolUse: "bg-status-success",
- PostToolUse: "bg-blue-500",
- Stop: "bg-violet-400",
- SubagentStop: "bg-yellow-400",
- Notification: "bg-orange-400",
- };
-
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
-
{t("title")}
- {wsConnected ? (
-
-
- {t("common:live")}
-
- ) : (
-
-
- {t("common:offline")}
-
- )}
-
-
- {t("subtitle")}
-
-
- {lastUpdate.toLocaleTimeString()}
-
-
-
-
-
-
-
-
- {t("common:refresh")}
-
-
-
- {t("export")}
-
-
-
-
- {/* Key stats */}
-
-
-
-
-
-
-
-
- {!data ? (
-
- ) : (
- <>
- {/* Activity heatmap + 30-day sparkline */}
-
-
-
{t("eventActivity")}
-
-
-
-
{t("last30Days")}
-
{t("dailyEventCount")}
-
-
- {last30[0]?.date?.slice(5)}
- {last30[last30.length - 1]?.date?.slice(5)}
-
-
-
- {t("peakDay")}
-
- d.count)).toLocaleString()}>
- {fmt(Math.max(...last30.map((d) => d.count)))}
- {" "}
- {t("common:events")}
-
-
-
- {t("total30d")}
-
- s + d.count, 0).toLocaleString()}>
- {fmt(last30.reduce((s, d) => s + d.count, 0))}
- {" "}
- {t("common:events")}
-
-
-
-
-
-
- {/* Tabs */}
-
-
- {(
- [
- { 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 }) => (
- 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}
-
- ))}
-
-
- {activeTab === "tokens" && (
-
- {/* Token bars */}
-
-
- {t("tokenDistribution")}
-
-
- {[
- {
- 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 }) => (
-
- ))}
-
-
-
- {t("common:token.totalTokens")}
-
- {fmt(totalTokens)}
-
-
-
- {t("cacheEfficiency")}
- {cacheHitPct}%
-
-
-
-
- {/* Token summary */}
-
-
- {t("tokenBreakdown")}
-
-
- {[
- {
- 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 }) => (
-
- {label}
-
- {value.toLocaleString()}
-
-
- ))}
-
- {totalTokens === 0 && (
-
{t("tokenInfo")}
- )}
-
-
- {/* Token mix donut */}
-
-
{t("tokenMix")}
- {tokenMixSegments.length === 0 ? (
-
{t("common:noData")}
- ) : (
- <>
-
fmt(total)} />
-
- {tokenMixSegments.map((segment) => (
-
- {segment.label}
-
- {fmt(segment.value)}
-
-
- ))}
-
- >
- )}
-
-
- )}
-
- {activeTab === "cost" && (
-
- {/* Daily cost trends */}
-
-
- {t("dailyCostTrends")}
-
- {dailyCostsLocal.length === 0 ? (
-
{t("noDailyCostData")}
- ) : (
- <>
-
{t("costPerDay")}
-
-
- {dailyCostLast30[0]?.date?.slice(5)}
- {dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)}
-
-
-
- {t("peakCostDay")}
-
-
- {fmtCost(peakCostDay.cost)}
-
-
-
-
- {t("totalCost30d")}
-
- {fmtCost(totalCost30d)}
-
-
-
- >
- )}
-
-
- {/* Cost by model */}
-
-
{t("costByModel")}
- {costBreakdown.length > 0 ? (
- <>
-
({
- 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)}
- />
-
- {costBreakdown.map((b) => (
-
-
- {formatModelName(b.model)}
-
-
- {fmtCost(b.cost)}
-
-
- ))}
-
- {t("common:total")}
-
-
- {fmtCost(costData?.total_cost ?? 0)}
-
-
-
-
- >
- ) : (
- {t("noCostData")}
- )}
-
-
- {/* Cost by weekday */}
-
-
- {t("costByWeekday")}
-
- {dailyCostsLocal.length === 0 ? (
-
{t("noDailyCostData")}
- ) : (
- <>
-
{t("last30Days")}
-
- {weekdayCosts.map(({ label, cost }) => (
-
- ))}
-
-
- {t("common:total")}
-
- {fmtCost(totalCost30d)}
-
-
- >
- )}
-
-
- )}
-
- {activeTab === "workflow" && (
-
- {/* Agent type distribution */}
-
-
- {t("subagentTypes")}
-
- {(data?.agent_types ?? []).length === 0 ? (
-
{t("noSubagentData")}
- ) : (
-
- {(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => (
-
- ))}
-
- )}
-
-
- {/* Agent status donut */}
-
-
{t("agentStatus")}
-
-
-
- {t("totalAgentsLabel")}
-
-
- {fmt(data?.overview.total_agents ?? 0)}
-
-
-
- {agentStatusSegments.map((s) => (
-
-
-
- {s.label}
-
-
- {fmt(s.value)}
-
-
- ))}
-
-
-
- {/* Event type breakdown */}
-
-
{t("eventTypes")}
- {(data?.event_types ?? []).length === 0 ? (
-
{t("noEventData")}
- ) : (
-
- {(data?.event_types ?? []).map(({ event_type, count }) => (
-
- ))}
-
- )}
-
-
- )}
-
- {activeTab === "productivity" && (
-
- {/* Top tools */}
-
-
{t("toolUsage")}
- {(data?.tool_usage ?? []).length === 0 ? (
-
{t("noToolData")}
- ) : (
-
- {(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => (
-
- ))}
-
- )}
-
-
- {/* Session outcomes donut */}
-
-
- {t("sessionOutcomes")}
-
-
-
-
- {t("totalSessionsLabel")}
-
-
- {fmt(data?.overview.total_sessions ?? 0)}
-
-
-
- {sessionOutcomeSegments.map((s) => (
-
-
-
- {s.label}
-
-
- {fmt(s.value)}
-
-
- ))}
-
-
-
- {/* Daily session trends */}
-
-
- {t("dailySessionTrends")}
-
- {dailySessionsLocal.length === 0 ? (
-
{t("noSessionTrendData")}
- ) : (
- <>
-
-
- {dailySessionsLocal
- .slice(-7)
- .reverse()
- .map(({ date, count }) => {
- const maxD = Math.max(
- ...(dailySessionsLocal.length > 0
- ? dailySessionsLocal
- : [{ count: 1 }]
- ).map((d) => d.count)
- );
- return (
-
-
- {date.slice(5)}
-
-
-
- {count}
-
-
- );
- })}
-
-
{t("last7Days")}
- >
- )}
-
-
- )}
-
- >
- )}
-
- );
-}
diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx
index 94bfefe9..eae23680 100644
--- a/client/src/pages/Dashboard.tsx
+++ b/client/src/pages/Dashboard.tsx
@@ -1,6 +1,6 @@
/**
* @file Dashboard.tsx
- * @description Main dashboard page showing real-time stats, active agents, and recent activity feed for Claude Code sessions.
+ * @description Main dashboard page showing real-time stats, active agents, recent activity feed, an Analytics tab, and a System Health tab for Claude Code sessions.
* @author Nguyễn Ngọc Trí Vĩ
*/
/* =============================================================================
@@ -87,6 +87,8 @@ import {
ShieldCheck,
Database,
Search,
+ Download,
+ Clock,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
@@ -97,8 +99,18 @@ import { AgentCard } from "../components/AgentCard";
import { AgentStatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { Tip } from "../components/Tip";
-import { timeAgo, fmt, fmtCost, formatModelName } from "../lib/format";
-import type { Stats, Agent, DashboardEvent, WSMessage, WorkflowData, Session } from "../lib/types";
+import { Skeleton, StatValueSkeleton, TextSkeleton } from "../components/Skeleton";
+import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format";
+import type {
+ Stats,
+ Agent,
+ DashboardEvent,
+ WSMessage,
+ WorkflowData,
+ Session,
+ Analytics as AnalyticsData,
+ CostResult,
+} from "../lib/types";
interface SystemInfo {
db: {
@@ -152,6 +164,1367 @@ function formatUptime(seconds: number): string {
return `${m}m`;
}
+// ── Analytics tab (merged from the former standalone Analytics page) ─────────
+
+function ChartTooltip({ x, y, children }: { x: number; y: number; children: ReactNode }) {
+ const nearRight = x > window.innerWidth - 200;
+ return (
+
+ {children}
+
+ );
+}
+
+function useTooltip() {
+ const [tooltip, setTooltip] = useState<{
+ x: number;
+ y: number;
+ content: ReactNode;
+ } | null>(null);
+
+ const show = (e: React.MouseEvent, content: ReactNode) => {
+ setTooltip({ x: e.clientX, y: e.clientY, content });
+ };
+ const move = (e: React.MouseEvent) => {
+ setTooltip((t) => t && { ...t, x: e.clientX, y: e.clientY });
+ };
+ const hide = () => setTooltip(null);
+
+ const node = tooltip ? (
+
+ {tooltip.content}
+
+ ) : null;
+
+ return { show, move, hide, node };
+}
+
+function cellColor(count: number, max: number) {
+ if (count === 0) return "#161625";
+ // Log scale + RGB interpolation across a wide color ramp for maximum perceptual range
+ const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1);
+ // Ramp: near-black indigo → deep indigo → bright indigo → lavender
+ type RGB = [number, number, number];
+ const stops: RGB[] = [
+ [22, 20, 60], // near-black indigo
+ [55, 48, 163], // deep indigo
+ [99, 102, 241], // bright indigo
+ [199, 210, 254], // lavender
+ ];
+ const scaled = t * (stops.length - 1);
+ const lo = Math.min(Math.floor(scaled), stops.length - 2);
+ const frac = scaled - lo;
+ const [r1, g1, b1]: RGB = stops[lo] as RGB;
+ const [r2, g2, b2]: RGB = stops[lo + 1] as RGB;
+ const r = Math.round(r1 + (r2 - r1) * frac);
+ const g = Math.round(g1 + (g2 - g1) * frac);
+ const b = Math.round(b1 + (b2 - b1) * frac);
+ return `rgb(${r},${g},${b})`;
+}
+
+function Heatmap({ weeks }: { weeks: Array> }) {
+ 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 (
+
+ {node}
+ {/* Month labels */}
+
+ {monthPositions.map((mp, i) => (
+
+ {mp.label}
+
+ ))}
+
+
+ {/* Day labels */}
+
+ {dayLabels.map((d, i) => (
+
+ {d}
+
+ ))}
+
+ {/* Cells */}
+ {weeks.map((week, wi) => (
+
+ {week.map((cell) => (
+
{
+ 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,
+ <>
+
+ {dayNames[dow] ?? ""}, {cell.date}
+
+
+ {t("eventCountLabel", { count: cell.count })}
+
+ >
+ );
+ }}
+ 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",
+ }}
+ />
+ ))}
+
+ ))}
+
+ {/* Legend */}
+
+
{t("less")}
+ {[0, 0.25, 0.5, 0.75, 1].map((f) => {
+ const v = Math.round(f * maxCount);
+ return (
+
+ );
+ })}
+
{t("more")}
+
+
+ );
+}
+
+function AnalyticsSparkline({
+ data,
+ color = "#6366f1",
+}: {
+ data: Array<{ date: string; count: number }>;
+ color?: string;
+}) {
+ const { t } = useTranslation("analytics");
+ const { show, move, hide, node } = useTooltip();
+ const max = Math.max(...data.map((d) => d.count), 1);
+ return (
+
+ {node}
+ {data.map(({ date, count }) => (
+
+ show(
+ e,
+ <>
+ {date}
+ {t("eventCountLabel", { count })}
+ >
+ )
+ }
+ onMouseMove={move}
+ onMouseLeave={hide}
+ />
+ ))}
+
+ );
+}
+
+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 (
+
+ {node}
+
+
+
+
+
+
+
+
+
+ {points.map((point) => (
+
+
+
+ show(
+ e,
+ <>
+ {point.date}
+ {fmtCostFull(point.cost)}
+ >
+ )
+ }
+ onMouseMove={move}
+ onMouseLeave={hide}
+ />
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+
+ {label}
+
+
+
+ {fmt(count)}
+
+
+ );
+}
+
+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 (
+
+
+ {label}
+
+
+
+ {fmtCost(cost)}
+
+
+ );
+}
+
+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
{t("common:noData")}
;
+
+ 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 (
+
+ {node}
+
+
+ {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 (
+
+ show(
+ e,
+ <>
+ {label}
+ {pct}%
+ >
+ )
+ }
+ onMouseMove={move}
+ onMouseLeave={hide}
+ />
+ );
+ })}
+
+ {(formatTotal ?? fmt)(total)}
+
+
+ {t("common:total_lower")}
+
+
+
+ {segments.map(({ label, value, color }) => (
+
+
+ {label}
+ {Math.round((value / total) * 100)}%
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+
+ {label}
+
+
+ {loading ? (
+
+ ) : (
+
+ {raw ? {value} : value}
+
+ )}
+ {loading ? (
+
+ ) : (
+ sub &&
{sub}
+ )}
+
+ );
+}
+
+/** A single chart-card placeholder: title bar, chart body, two footer lines.
+ * Uses the shared pulsing
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 (
+
+ );
+}
+
+/** 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 (
+
+ );
+}
+
+function AnalyticsTab() {
+ const { t, i18n } = useTranslation("analytics");
+ const [data, setData] = useState(null);
+ const [costData, setCostData] = useState(null);
+ const [lastUpdate, setLastUpdate] = useState(new Date());
+ const [loading, setLoading] = useState(true);
+ const [analyticsSubTab, setAnalyticsSubTab] = useState<
+ "tokens" | "cost" | "workflow" | "productivity"
+ >("cost");
+ // Global data scope; a change re-runs `load` (api injects the `sources` param).
+ const [scope] = useDataScope();
+
+ const load = useCallback(async () => {
+ try {
+ const [result, cost] = await Promise.all([
+ api.analytics.get(),
+ api.pricing.totalCost().catch(() => null),
+ ]);
+ setData(result);
+ setCostData(cost);
+ setLastUpdate(new Date());
+ } finally {
+ setLoading(false);
+ }
+ }, [scope]);
+
+ useEffect(() => {
+ load();
+ const interval = setInterval(load, 15000);
+ return () => clearInterval(interval);
+ }, [load]);
+
+ useEffect(() => {
+ return eventBus.subscribe((msg) => {
+ if (
+ msg.type === "session_created" ||
+ msg.type === "session_updated" ||
+ msg.type === "new_event" ||
+ msg.type === "agent_created" ||
+ isRemoteDataRefreshMessage(msg)
+ ) {
+ load();
+ }
+ });
+ }, [load]);
+
+ function handleExport() {
+ if (!data) return;
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `analytics-${new Date().toISOString().slice(0, 10)}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ // Format a local Date object as a YYYY-MM-DD string for heatmap lookups
+ function localDateStr(d: Date): string {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${y}-${m}-${day}`;
+ }
+
+ // Server now returns dates grouped by the user's local timezone (via tz_offset),
+ // so we can use them directly without conversion.
+ const dailyMap: Record = {};
+ 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> = [];
+ 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 = {};
+ 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 = {};
+ 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 = {
+ PreToolUse: "bg-status-success",
+ PostToolUse: "bg-blue-500",
+ Stop: "bg-violet-400",
+ SubagentStop: "bg-yellow-400",
+ Notification: "bg-orange-400",
+ };
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+ {lastUpdate.toLocaleTimeString()}
+
+
+
+
+ {t("common:refresh")}
+
+
+
+ {t("export")}
+
+
+
+
+ {/* Key stats */}
+
+
+
+
+
+
+
+
+ {!data ? (
+
+ ) : (
+ <>
+ {/* Activity heatmap + 30-day sparkline */}
+
+
+
{t("eventActivity")}
+
+
+
+
{t("last30Days")}
+
{t("dailyEventCount")}
+
+
+ {last30[0]?.date?.slice(5)}
+ {last30[last30.length - 1]?.date?.slice(5)}
+
+
+
+ {t("peakDay")}
+
+ d.count)).toLocaleString()}>
+ {fmt(Math.max(...last30.map((d) => d.count)))}
+ {" "}
+ {t("common:events")}
+
+
+
+ {t("total30d")}
+
+ s + d.count, 0).toLocaleString()}>
+ {fmt(last30.reduce((s, d) => s + d.count, 0))}
+ {" "}
+ {t("common:events")}
+
+
+
+
+
+
+ {/* Tabs */}
+
+
+ {(
+ [
+ { 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 }) => (
+ setAnalyticsSubTab(key)}
+ className={`px-4 py-1.5 text-xs font-medium rounded-md transition-colors ${
+ analyticsSubTab === key
+ ? "bg-surface-4 text-fg-secondary"
+ : "text-fg-muted hover:text-fg-secondary"
+ }`}
+ >
+ {label}
+
+ ))}
+
+
+ {analyticsSubTab === "tokens" && (
+
+ {/* Token bars */}
+
+
+ {t("tokenDistribution")}
+
+
+ {[
+ {
+ 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 }) => (
+
+ ))}
+
+
+
+ {t("common:token.totalTokens")}
+
+ {fmt(totalTokens)}
+
+
+
+ {t("cacheEfficiency")}
+ {cacheHitPct}%
+
+
+
+
+ {/* Token summary */}
+
+
+ {t("tokenBreakdown")}
+
+
+ {[
+ {
+ 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 }) => (
+
+ {label}
+
+ {value.toLocaleString()}
+
+
+ ))}
+
+ {totalTokens === 0 && (
+
{t("tokenInfo")}
+ )}
+
+
+ {/* Token mix donut */}
+
+
{t("tokenMix")}
+ {tokenMixSegments.length === 0 ? (
+
{t("common:noData")}
+ ) : (
+ <>
+
fmt(total)} />
+
+ {tokenMixSegments.map((segment) => (
+
+ {segment.label}
+
+ {fmt(segment.value)}
+
+
+ ))}
+
+ >
+ )}
+
+
+ )}
+
+ {analyticsSubTab === "cost" && (
+
+ {/* Daily cost trends */}
+
+
+ {t("dailyCostTrends")}
+
+ {dailyCostsLocal.length === 0 ? (
+
{t("noDailyCostData")}
+ ) : (
+ <>
+
{t("costPerDay")}
+
+
+ {dailyCostLast30[0]?.date?.slice(5)}
+ {dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)}
+
+
+
+ {t("peakCostDay")}
+
+
+ {fmtCost(peakCostDay.cost)}
+
+
+
+
+ {t("totalCost30d")}
+
+ {fmtCost(totalCost30d)}
+
+
+
+ >
+ )}
+
+
+ {/* Cost by model */}
+
+
{t("costByModel")}
+ {costBreakdown.length > 0 ? (
+ <>
+
({
+ 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)}
+ />
+
+ {costBreakdown.map((b) => (
+
+
+ {formatModelName(b.model)}
+
+
+ {fmtCost(b.cost)}
+
+
+ ))}
+
+ {t("common:total")}
+
+
+ {fmtCost(costData?.total_cost ?? 0)}
+
+
+
+
+ >
+ ) : (
+ {t("noCostData")}
+ )}
+
+
+ {/* Cost by weekday */}
+
+
+ {t("costByWeekday")}
+
+ {dailyCostsLocal.length === 0 ? (
+
{t("noDailyCostData")}
+ ) : (
+ <>
+
{t("last30Days")}
+
+ {weekdayCosts.map(({ label, cost }) => (
+
+ ))}
+
+
+ {t("common:total")}
+
+ {fmtCost(totalCost30d)}
+
+
+ >
+ )}
+
+
+ )}
+
+ {analyticsSubTab === "workflow" && (
+
+ {/* Agent type distribution */}
+
+
+ {t("subagentTypes")}
+
+ {(data?.agent_types ?? []).length === 0 ? (
+
{t("noSubagentData")}
+ ) : (
+
+ {(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => (
+
+ ))}
+
+ )}
+
+
+ {/* Agent status donut */}
+
+
{t("agentStatus")}
+
+
+
+ {t("totalAgentsLabel")}
+
+
+ {fmt(data?.overview.total_agents ?? 0)}
+
+
+
+ {agentStatusSegments.map((s) => (
+
+
+
+ {s.label}
+
+
+ {fmt(s.value)}
+
+
+ ))}
+
+
+
+ {/* Event type breakdown */}
+
+
{t("eventTypes")}
+ {(data?.event_types ?? []).length === 0 ? (
+
{t("noEventData")}
+ ) : (
+
+ {(data?.event_types ?? []).map(({ event_type, count }) => (
+
+ ))}
+
+ )}
+
+
+ )}
+
+ {analyticsSubTab === "productivity" && (
+
+ {/* Top tools */}
+
+
{t("toolUsage")}
+ {(data?.tool_usage ?? []).length === 0 ? (
+
{t("noToolData")}
+ ) : (
+
+ {(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => (
+
+ ))}
+
+ )}
+
+
+ {/* Session outcomes donut */}
+
+
+ {t("sessionOutcomes")}
+
+
+
+
+ {t("totalSessionsLabel")}
+
+
+ {fmt(data?.overview.total_sessions ?? 0)}
+
+
+
+ {sessionOutcomeSegments.map((s) => (
+
+
+
+ {s.label}
+
+
+ {fmt(s.value)}
+
+
+ ))}
+
+
+
+ {/* Daily session trends */}
+
+
+ {t("dailySessionTrends")}
+
+ {dailySessionsLocal.length === 0 ? (
+
{t("noSessionTrendData")}
+ ) : (
+ <>
+
+
+ {dailySessionsLocal
+ .slice(-7)
+ .reverse()
+ .map(({ date, count }) => {
+ const maxD = Math.max(
+ ...(dailySessionsLocal.length > 0
+ ? dailySessionsLocal
+ : [{ count: 1 }]
+ ).map((d) => d.count)
+ );
+ return (
+
+
+ {date.slice(5)}
+
+
+
+ {count}
+
+
+ );
+ })}
+
+
{t("last7Days")}
+ >
+ )}
+
+
+ )}
+
+ >
+ )}
+
+ );
+}
+
function SystemHealthTab() {
const [info, setInfo] = useState(null);
const [workflow, setWorkflow] = useState(null);
@@ -955,8 +2328,10 @@ export function Dashboard() {
const { t } = useTranslation("dashboard");
// Persistent Tab State
- const [activeTab, setActiveTab] = useState<"monitor" | "health">(() => {
- return (localStorage.getItem("dashboard_tab") as "monitor" | "health") || "monitor";
+ const [activeTab, setActiveTab] = useState<"monitor" | "analytics" | "health">(() => {
+ return (
+ (localStorage.getItem("dashboard_tab") as "monitor" | "analytics" | "health") || "monitor"
+ );
});
useEffect(() => {
@@ -1189,6 +2564,16 @@ export function Dashboard() {
>
Monitor
+ setActiveTab("analytics")}
+ className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
+ activeTab === "analytics"
+ ? "bg-accent/15 text-accent shadow-sm"
+ : "text-fg-muted hover:text-fg-secondary"
+ }`}
+ >
+ Analytics
+
setActiveTab("health")}
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
@@ -1270,9 +2655,6 @@ export function Dashboard() {
{t("activeAgentsSection")}
- navigate("/kanban")} className="btn-ghost text-xs">
- {t("viewBoard")}
-
{activeAgents.length === 0 ? (
@@ -1504,6 +2886,8 @@ export function Dashboard() {