/** * Analytics Page (Story 11.3) * * Route: /analytics * * Features: * - Time range controls: 24h, 7d, 30d * - Events Over Time chart (line) * - Error Rate trend (area) * - Tool Usage breakdown (bar) * - Cost Over Time chart (bar, stacked by agent) */ import React, { Suspense, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { PageSkeleton } from '../components/PageSkeleton'; import { TimeRangePicker, DEFAULT_TIME_RANGE, type TimeRange as PickerTimeRange } from '../components/TimeRangePicker'; import { BarChart, Bar, LineChart, Line, AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, PieChart, Pie, Cell, } from 'recharts'; import { useApi } from '../hooks/useApi'; import { getAnalytics, getCostAnalytics, getToolAnalytics, getSkillAnalytics, } from '../api/client'; import type { AnalyticsResult, CostAnalyticsResult, ToolAnalytics, SkillAnalytics, } from '../api/client'; // ─── Types ────────────────────────────────────────────────────────── type TimeRange = '24h' | '7d' | '30d'; interface TimeRangeConfig { label: string; from: () => string; granularity: 'hour' | 'day' | 'week'; } const TIME_RANGES: Record = { '24h': { label: 'Last 24 Hours', from: () => new Date(Date.now() - 86400_000).toISOString(), granularity: 'hour', }, '7d': { label: 'Last 7 Days', from: () => new Date(Date.now() - 7 * 86400_000).toISOString(), granularity: 'day', }, '30d': { label: 'Last 30 Days', from: () => new Date(Date.now() - 30 * 86400_000).toISOString(), granularity: 'day', }, }; // ─── Lazy tab content (merged pages) ──────────────────────────────── const LlmAnalytics = React.lazy(() => import('./LlmAnalytics').then(m => ({ default: m.LlmAnalytics }))); const EventsExplorer = React.lazy(() => import('./EventsExplorer').then(m => ({ default: m.EventsExplorer }))); const HealthOverview = React.lazy(() => import('./HealthOverview').then(m => ({ default: m.HealthOverview }))); const CostOptimization = React.lazy(() => import('./CostOptimization').then(m => ({ default: m.CostOptimization }))); const Insights = React.lazy(() => import('./Insights').then(m => ({ default: m.Insights }))); type AnalyticsTab = 'overview' | 'llm' | 'events' | 'health' | 'cost' | 'insights'; const ANALYTICS_TABS: { key: AnalyticsTab; label: string }[] = [ { key: 'overview', label: 'Overview' }, { key: 'llm', label: 'LLM' }, { key: 'events', label: 'Events' }, { key: 'health', label: 'Health' }, { key: 'cost', label: 'Cost' }, { key: 'insights', label: 'Insights' }, ]; // ─── Colors ───────────────────────────────────────────────────────── const COLORS = ['#0c8ce9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316']; // ─── Formatters ───────────────────────────────────────────────────── function formatBucketLabel(ts: string, range: TimeRange): string { if (!ts) return ''; if (range === '24h') { const d = new Date(ts); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } const d = new Date(ts); return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); } function formatCost(usd: number): string { if (usd < 0.01) return `$${usd.toFixed(4)}`; if (usd < 1) return `$${usd.toFixed(3)}`; return `$${usd.toFixed(2)}`; } function formatNumber(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(n); } // ─── Metric Card ──────────────────────────────────────────────────── function MetricCard({ label, value, icon }: { label: string; value: string | number; icon: string }) { return (
{icon} {label}
{value}
); } // ─── Component ────────────────────────────────────────────────────── function AnalyticsOverview(): React.ReactElement { const [timeRange, setTimeRange] = useState(DEFAULT_TIME_RANGE); const from = timeRange.from; const to = timeRange.to ?? new Date().toISOString(); const granularity = (timeRange.granularity ?? 'hour') as 'hour' | 'day' | 'week'; // Keep `range` for bucket label formatting const range: TimeRange = (['24h', '7d', '30d'].includes(timeRange.range ?? '') ? timeRange.range : '24h') as TimeRange; // Fetch analytics data const { data: analytics, loading: analyticsLoading } = useApi( () => getAnalytics({ from, to, granularity }), [from, to, granularity], ); const { data: costs, loading: costsLoading } = useApi( () => getCostAnalytics({ from, to, granularity }), [from, to, granularity], ); const { data: toolsData, loading: toolsLoading } = useApi<{ tools: ToolAnalytics[] }>( () => getToolAnalytics({ from, to }), [from, to], ); const { data: skillsData } = useApi<{ skills: SkillAnalytics[] }>( () => getSkillAnalytics({ from, to }), [from, to], ); const skillChartData = useMemo( () => (skillsData?.skills ?? []).slice(0, 12).map((s) => ({ name: s.skillName, count: s.count })), [skillsData], ); // Chart data const eventsChartData = useMemo(() => { if (!analytics?.buckets) return []; return analytics.buckets.map((b) => ({ label: formatBucketLabel(b.timestamp, range), events: b.eventCount, toolCalls: b.toolCallCount, errors: b.errorCount, })); }, [analytics, range]); const errorRateData = useMemo(() => { if (!analytics?.buckets) return []; return analytics.buckets.map((b) => ({ label: formatBucketLabel(b.timestamp, range), errorRate: b.eventCount > 0 ? (b.errorCount / b.eventCount) * 100 : 0, })); }, [analytics, range]); // Collect all unique agent IDs from cost data for stacked bars const costAgentIds = useMemo(() => { if (!costs?.overTime) return []; const agentSet = new Set(); for (const b of costs.overTime) { if (b.byAgent) { for (const agentId of Object.keys(b.byAgent)) { agentSet.add(agentId); } } } return Array.from(agentSet); }, [costs]); const costChartData = useMemo(() => { if (!costs?.overTime) return []; return costs.overTime.map((b) => { const row: Record = { label: formatBucketLabel(b.bucket, range), }; // Add per-agent cost fields for stacked bars if (b.byAgent && costAgentIds.length > 0) { for (const agentId of costAgentIds) { row[agentId] = b.byAgent[agentId] ?? 0; } } else { row['cost'] = b.totalCostUsd; } return row; }); }, [costs, range, costAgentIds]); const toolChartData = useMemo(() => { if (!toolsData?.tools) return []; return toolsData.tools.slice(0, 10).map((t) => ({ name: t.toolName, calls: t.callCount, errors: t.errorCount, })); }, [toolsData]); const toolPieData = useMemo(() => { if (!toolsData?.tools) return []; return toolsData.tools.slice(0, 8).map((t) => ({ name: t.toolName, value: t.callCount, })); }, [toolsData]); const isLoading = analyticsLoading || costsLoading || toolsLoading; return (
{/* Header + Time Range */}

Analytics

Aggregated metrics, trends, and cost insights.

{/* Summary Metrics */} {analytics && (
)} {/* Charts Row 1: Events Over Time + Error Rate */}
{/* Events Over Time */}

Events Over Time

{timeRange.label}

{isLoading ? (
) : ( )}
{/* Error Rate */}

Error Rate

{timeRange.label}

{isLoading ? (
) : ( [`${Number(value).toFixed(1)}%`, 'Error Rate']} /> )}
{/* Charts Row 2: Tool Usage + Cost Over Time */}
{/* Tool Usage */}

Tool Usage

Top tools by call count

{isLoading ? (
) : toolChartData.length === 0 ? (
No tool calls in this period
) : (
{toolPieData.length > 0 && ( entry.name} > {toolPieData.map((_, idx) => ( ))} )}
)}
{/* Cost Over Time */}

Cost Over Time

{timeRange.label}

{isLoading ? (
) : costChartData.length === 0 ? (
No cost data in this period
) : ( `$${v}`} /> [formatCost(Number(value))]} /> {costAgentIds.length > 0 ? ( costAgentIds.map((agentId, idx) => ( )) ) : ( )} )}
{/* Skill Usage */}

Skill Usage

Skills activated by count

{isLoading ? (
) : skillChartData.length === 0 ? (
No skills activated in this period
) : ( )}
{/* Cost by Agent Table */} {costs && costs.byAgent.length > 0 && (

Cost by Agent

Breakdown of costs per agent

{costs.byAgent.map((agent) => ( ))}
Agent Cost Input Output Cache Read Cache Write Events
{agent.agentId} {formatCost(agent.totalCostUsd)} {formatNumber(agent.totalInputTokens)} {formatNumber(agent.totalOutputTokens)} {formatNumber(agent.cacheReadTokens ?? 0)} {formatNumber(agent.cacheWriteTokens ?? 0)} {agent.eventCount}
)}
); } // ─── Tabbed Analytics Wrapper ─────────────────────────────────────── export function Analytics(): React.ReactElement { const [searchParams, setSearchParams] = useSearchParams(); const activeTab = (searchParams.get('tab') as AnalyticsTab) || 'overview'; const setTab = (tab: AnalyticsTab) => { if (tab === 'overview') { setSearchParams({}); } else { setSearchParams({ tab }); } }; return (
{/* Tab bar */}
{ANALYTICS_TABS.map(({ key, label }) => ( ))}
{/* Tab content */} {activeTab === 'overview' && } {activeTab === 'llm' && }>} {activeTab === 'events' && }>} {activeTab === 'health' && }>} {activeTab === 'cost' && }>} {activeTab === 'insights' && }>}
); }