diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts index 588a321e..fa98e9b7 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -21,7 +21,15 @@ import { loadSessionData, loadAllUsageData, } from './data-aggregator'; -import type { DailyUsage, MonthlyUsage, SessionUsage } from './usage-types'; +import type { + DailyUsage, + MonthlyUsage, + SessionUsage, + Anomaly, + AnomalySummary, + TokenBreakdown, +} from './usage-types'; +import { getModelPricing } from './model-pricing'; import { readDiskCache, writeDiskCache, @@ -606,6 +614,45 @@ function errorResponse(res: Response, error: unknown, defaultMessage: string): v }); } +/** + * Calculate cost breakdown for token categories + * Uses weighted average pricing across models in the dataset + */ +function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBreakdown { + let inputTokens = 0; + let outputTokens = 0; + let cacheCreationTokens = 0; + let cacheReadTokens = 0; + let inputCost = 0; + let outputCost = 0; + let cacheCreationCost = 0; + let cacheReadCost = 0; + + for (const day of dailyData) { + for (const breakdown of day.modelBreakdowns) { + const pricing = getModelPricing(breakdown.modelName); + + inputTokens += breakdown.inputTokens; + outputTokens += breakdown.outputTokens; + cacheCreationTokens += breakdown.cacheCreationTokens; + cacheReadTokens += breakdown.cacheReadTokens; + + inputCost += (breakdown.inputTokens / 1_000_000) * pricing.inputPerMillion; + outputCost += (breakdown.outputTokens / 1_000_000) * pricing.outputPerMillion; + cacheCreationCost += + (breakdown.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; + cacheReadCost += (breakdown.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; + } + } + + return { + input: { tokens: inputTokens, cost: Math.round(inputCost * 100) / 100 }, + output: { tokens: outputTokens, cost: Math.round(outputCost * 100) / 100 }, + cacheCreation: { tokens: cacheCreationTokens, cost: Math.round(cacheCreationCost * 100) / 100 }, + cacheRead: { tokens: cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, + }; +} + /** * GET /api/usage/summary * @@ -625,17 +672,23 @@ usageRoutes.get( // Calculate totals let totalInputTokens = 0; let totalOutputTokens = 0; - let totalCacheTokens = 0; + let totalCacheCreationTokens = 0; + let totalCacheReadTokens = 0; let totalCost = 0; for (const day of filtered) { totalInputTokens += day.inputTokens; totalOutputTokens += day.outputTokens; - totalCacheTokens += day.cacheCreationTokens + day.cacheReadTokens; + totalCacheCreationTokens += day.cacheCreationTokens; + totalCacheReadTokens += day.cacheReadTokens; totalCost += day.totalCost; } const totalTokens = totalInputTokens + totalOutputTokens; + const totalCacheTokens = totalCacheCreationTokens + totalCacheReadTokens; + + // Calculate detailed token breakdown with costs + const tokenBreakdown = calculateTokenBreakdownCosts(filtered); res.json({ success: true, @@ -644,7 +697,10 @@ usageRoutes.get( totalInputTokens, totalOutputTokens, totalCacheTokens, + totalCacheCreationTokens, + totalCacheReadTokens, totalCost: Math.round(totalCost * 100) / 100, + tokenBreakdown, totalDays: filtered.length, averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0, averageCostPerDay: @@ -710,14 +766,15 @@ usageRoutes.get( const dailyData = await getCachedDailyData(); const filtered = filterByDateRange(dailyData, since, until); - // Aggregate model usage across all days + // Aggregate model usage across all days with detailed breakdown const modelMap = new Map< string, { model: string; inputTokens: number; outputTokens: number; - cacheTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; cost: number; } >(); @@ -728,13 +785,15 @@ usageRoutes.get( model: breakdown.modelName, inputTokens: 0, outputTokens: 0, - cacheTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, cost: 0, }; existing.inputTokens += breakdown.inputTokens; existing.outputTokens += breakdown.outputTokens; - existing.cacheTokens += breakdown.cacheCreationTokens + breakdown.cacheReadTokens; + existing.cacheCreationTokens += breakdown.cacheCreationTokens; + existing.cacheReadTokens += breakdown.cacheReadTokens; existing.cost += breakdown.cost; modelMap.set(breakdown.modelName, existing); @@ -745,17 +804,46 @@ usageRoutes.get( const models = Array.from(modelMap.values()); const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0); - // Add percentage and sort by tokens + // Add percentage, cost breakdown, and I/O ratio const result = models - .map((m) => ({ - ...m, - tokens: m.inputTokens + m.outputTokens, - cost: Math.round(m.cost * 100) / 100, - percentage: - totalTokens > 0 - ? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10 - : 0, - })) + .map((m) => { + const pricing = getModelPricing(m.model); + + // Calculate cost breakdown + const inputCost = (m.inputTokens / 1_000_000) * pricing.inputPerMillion; + const outputCost = (m.outputTokens / 1_000_000) * pricing.outputPerMillion; + const cacheCreationCost = + (m.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; + const cacheReadCost = (m.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; + + // Calculate I/O ratio + const ioRatio = m.outputTokens > 0 ? m.inputTokens / m.outputTokens : 0; + + return { + model: m.model, + tokens: m.inputTokens + m.outputTokens, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + cacheCreationTokens: m.cacheCreationTokens, + cacheReadTokens: m.cacheReadTokens, + cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, + cost: Math.round(m.cost * 100) / 100, + percentage: + totalTokens > 0 + ? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10 + : 0, + costBreakdown: { + input: { tokens: m.inputTokens, cost: Math.round(inputCost * 100) / 100 }, + output: { tokens: m.outputTokens, cost: Math.round(outputCost * 100) / 100 }, + cacheCreation: { + tokens: m.cacheCreationTokens, + cost: Math.round(cacheCreationCost * 100) / 100, + }, + cacheRead: { tokens: m.cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, + }, + ioRatio: Math.round(ioRatio * 10) / 10, + }; + }) .sort((a, b) => b.tokens - a.tokens); res.json({ @@ -900,3 +988,187 @@ usageRoutes.get('/status', (_req: Request, res: Response) => { }, }); }); + +// ============================================================================ +// ANOMALY DETECTION +// ============================================================================ + +/** Anomaly detection thresholds */ +const ANOMALY_THRESHOLDS = { + HIGH_INPUT_TOKENS: 10_000_000, // 10M tokens/day/model + HIGH_IO_RATIO: 100, // 100x input/output ratio + COST_SPIKE_MULTIPLIER: 2, // 2x average daily cost + HIGH_CACHE_READ_TOKENS: 1_000_000_000, // 1B cache read tokens +}; + +/** + * Detect anomalies in usage data + */ +function detectAnomalies(dailyData: DailyUsage[]): Anomaly[] { + const anomalies: Anomaly[] = []; + + // Calculate average daily cost for spike detection + const totalCost = dailyData.reduce((sum, day) => sum + day.totalCost, 0); + const avgDailyCost = dailyData.length > 0 ? totalCost / dailyData.length : 0; + const costSpikeThreshold = avgDailyCost * ANOMALY_THRESHOLDS.COST_SPIKE_MULTIPLIER; + + for (const day of dailyData) { + // Check for cost spikes + if (avgDailyCost > 0 && day.totalCost > costSpikeThreshold) { + const multiplier = Math.round((day.totalCost / avgDailyCost) * 10) / 10; + anomalies.push({ + date: day.date, + type: 'cost_spike', + value: day.totalCost, + threshold: avgDailyCost, + message: `Cost ${multiplier}x above daily average ($${Math.round(day.totalCost)} vs $${Math.round(avgDailyCost)})`, + }); + } + + // Check per-model anomalies + for (const breakdown of day.modelBreakdowns) { + // High input tokens per model + if (breakdown.inputTokens > ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) { + const multiplier = + Math.round((breakdown.inputTokens / ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) * 10) / 10; + anomalies.push({ + date: day.date, + type: 'high_input', + model: breakdown.modelName, + value: breakdown.inputTokens, + threshold: ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS, + message: `Input tokens ${multiplier}x above threshold (${formatTokenCount(breakdown.inputTokens)})`, + }); + } + + // High I/O ratio + if (breakdown.outputTokens > 0) { + const ioRatio = breakdown.inputTokens / breakdown.outputTokens; + if (ioRatio > ANOMALY_THRESHOLDS.HIGH_IO_RATIO) { + const multiplier = Math.round((ioRatio / ANOMALY_THRESHOLDS.HIGH_IO_RATIO) * 10) / 10; + anomalies.push({ + date: day.date, + type: 'high_io_ratio', + model: breakdown.modelName, + value: ioRatio, + threshold: ANOMALY_THRESHOLDS.HIGH_IO_RATIO, + message: `I/O ratio ${multiplier}x above threshold (${Math.round(ioRatio)}:1)`, + }); + } + } + + // High cache read tokens + if (breakdown.cacheReadTokens > ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) { + const multiplier = + Math.round((breakdown.cacheReadTokens / ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) * 10) / + 10; + anomalies.push({ + date: day.date, + type: 'high_cache_read', + model: breakdown.modelName, + value: breakdown.cacheReadTokens, + threshold: ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS, + message: `Cache reads ${multiplier}x above threshold (${formatTokenCount(breakdown.cacheReadTokens)})`, + }); + } + } + } + + // Sort by date descending + return anomalies.sort((a, b) => b.date.localeCompare(a.date)); +} + +/** + * Format token count for human readability + */ +function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000_000) { + return `${(tokens / 1_000_000_000).toFixed(1)}B`; + } else if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(1)}M`; + } else if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(1)}K`; + } + return tokens.toString(); +} + +/** + * Summarize anomalies by type + */ +function summarizeAnomalies(anomalies: Anomaly[]): AnomalySummary { + const uniqueDates = new Set(); + let highInputDays = 0; + let highIoRatioDays = 0; + let costSpikeDays = 0; + let highCacheReadDays = 0; + + // Track unique dates per anomaly type + const highInputDates = new Set(); + const highIoRatioDates = new Set(); + const costSpikeDates = new Set(); + const highCacheReadDates = new Set(); + + for (const anomaly of anomalies) { + uniqueDates.add(anomaly.date); + + switch (anomaly.type) { + case 'high_input': + highInputDates.add(anomaly.date); + break; + case 'high_io_ratio': + highIoRatioDates.add(anomaly.date); + break; + case 'cost_spike': + costSpikeDates.add(anomaly.date); + break; + case 'high_cache_read': + highCacheReadDates.add(anomaly.date); + break; + } + } + + highInputDays = highInputDates.size; + highIoRatioDays = highIoRatioDates.size; + costSpikeDays = costSpikeDates.size; + highCacheReadDays = highCacheReadDates.size; + + return { + totalAnomalies: anomalies.length, + highInputDays, + highIoRatioDays, + costSpikeDays, + highCacheReadDays, + }; +} + +/** + * GET /api/usage/insights + * + * Returns anomaly detection results for usage patterns. + * Query: ?since=YYYYMMDD&until=YYYYMMDD + */ +usageRoutes.get( + '/insights', + async (req: Request, res: Response) => { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + + const anomalies = detectAnomalies(filtered); + const summary = summarizeAnomalies(anomalies); + + res.json({ + success: true, + data: { + anomalies, + summary, + }, + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch usage insights'); + } + } +); diff --git a/src/web-server/usage-types.ts b/src/web-server/usage-types.ts index 1650e0dc..b5ac7288 100644 --- a/src/web-server/usage-types.ts +++ b/src/web-server/usage-types.ts @@ -66,3 +66,67 @@ export interface SessionUsage { modelBreakdowns: ModelBreakdown[]; source: string; } + +// ============================================================================ +// ANALYTICS INSIGHTS TYPES +// ============================================================================ + +/** Token category with count and cost */ +export interface TokenCategoryCost { + tokens: number; + cost: number; +} + +/** Breakdown of tokens by type with individual costs */ +export interface TokenBreakdown { + input: TokenCategoryCost; + output: TokenCategoryCost; + cacheCreation: TokenCategoryCost; + cacheRead: TokenCategoryCost; +} + +/** Anomaly types for usage pattern detection */ +export type AnomalyType = + | 'high_input' // >10M tokens/day/model + | 'high_io_ratio' // >100x input/output ratio + | 'cost_spike' // >2x daily average cost + | 'high_cache_read'; // >1B cache read tokens + +/** Single anomaly detection result */ +export interface Anomaly { + date: string; + type: AnomalyType; + model?: string; + value: number; + threshold: number; + message: string; +} + +/** Summary of all detected anomalies */ +export interface AnomalySummary { + totalAnomalies: number; + highInputDays: number; + highIoRatioDays: number; + costSpikeDays: number; + highCacheReadDays: number; +} + +/** Insights API response */ +export interface UsageInsights { + anomalies: Anomaly[]; + summary: AnomalySummary; +} + +/** Extended model usage with cost breakdown */ +export interface ExtendedModelUsage { + model: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + tokens: number; + cost: number; + percentage: number; + costBreakdown: TokenBreakdown; + ioRatio: number; +} diff --git a/ui/bun.lock b/ui/bun.lock index e1e67280..14e0041b 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -10,6 +10,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", @@ -228,6 +229,8 @@ "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], @@ -792,6 +795,8 @@ "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], diff --git a/ui/package.json b/ui/package.json index 6462722c..acd632e1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/ui/src/components/analytics/anomaly-alert-badge.tsx b/ui/src/components/analytics/anomaly-alert-badge.tsx new file mode 100644 index 00000000..b1eabb46 --- /dev/null +++ b/ui/src/components/analytics/anomaly-alert-badge.tsx @@ -0,0 +1,128 @@ +/** + * Anomaly Alert Badge Component + * + * Displays detected usage anomalies with visual indicators. + * Shows high input, I/O ratio, cost spikes, and cache read alerts. + */ + +import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { AlertTriangle, ChevronDown, Zap, Gauge, DollarSign, Database } from 'lucide-react'; +import type { Anomaly, AnomalySummary, AnomalyType } from '@/hooks/use-usage'; +import { cn } from '@/lib/utils'; + +interface AnomalyAlertBadgeProps { + anomalies: Anomaly[]; + summary: AnomalySummary; + className?: string; +} + +const ANOMALY_CONFIG: Record< + AnomalyType, + { icon: React.ComponentType<{ className?: string }>; color: string; label: string } +> = { + high_input: { icon: Zap, color: 'text-yellow-600', label: 'High Input' }, + high_io_ratio: { icon: Gauge, color: 'text-orange-600', label: 'High I/O Ratio' }, + cost_spike: { icon: DollarSign, color: 'text-red-600', label: 'Cost Spike' }, + high_cache_read: { icon: Database, color: 'text-cyan-600', label: 'Heavy Caching' }, +}; + +export function AnomalyAlertBadge({ anomalies, summary, className }: AnomalyAlertBadgeProps) { + const [open, setOpen] = useState(false); + + if (summary.totalAnomalies === 0) { + return ( + + No anomalies + + ); + } + + // Get unique anomaly types for badges + const anomalyTypes = new Set(anomalies.map((a) => a.type)); + + return ( + + + + + +
+

+ + Detected Anomalies +

+

+ Unusual usage patterns detected in the selected period +

+
+ + {/* Summary badges */} +
+ {Array.from(anomalyTypes).map((type) => { + const config = ANOMALY_CONFIG[type]; + const Icon = config.icon; + const count = anomalies.filter((a) => a.type === type).length; + return ( + + + {count} {config.label} + + ); + })} +
+ + {/* Anomaly list */} +
+ {anomalies.slice(0, 10).map((anomaly, index) => { + const config = ANOMALY_CONFIG[anomaly.type]; + const Icon = config.icon; + + return ( +
+
+ +
+
+ {anomaly.date} + {anomaly.model && ( + + {truncateModel(anomaly.model)} + + )} +
+

{anomaly.message}

+
+
+
+ ); + })} + {anomalies.length > 10 && ( +
+ +{anomalies.length - 10} more anomalies +
+ )} +
+
+
+ ); +} + +function truncateModel(model: string): string { + if (model.length <= 20) return model; + // Try to extract the meaningful part + const parts = model.split('-'); + if (parts.length >= 3) { + return parts.slice(0, 3).join('-') + '...'; + } + return model.slice(0, 17) + '...'; +} diff --git a/ui/src/components/analytics/cache-efficiency-card.tsx b/ui/src/components/analytics/cache-efficiency-card.tsx new file mode 100644 index 00000000..eb04bff4 --- /dev/null +++ b/ui/src/components/analytics/cache-efficiency-card.tsx @@ -0,0 +1,166 @@ +/** + * Cache Efficiency Card Component + * + * Displays cache usage metrics including hit rate, savings estimate, + * and cache read/write breakdown. + */ + +import { useMemo } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Database, TrendingUp, Zap } from 'lucide-react'; +import type { UsageSummary } from '@/hooks/use-usage'; +import { cn } from '@/lib/utils'; + +interface CacheEfficiencyCardProps { + data: UsageSummary | undefined; + isLoading?: boolean; + className?: string; +} + +export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficiencyCardProps) { + const metrics = useMemo(() => { + if (!data) return null; + + const totalCacheTokens = data.totalCacheCreationTokens + data.totalCacheReadTokens; + const cacheHitRate = + totalCacheTokens > 0 ? (data.totalCacheReadTokens / totalCacheTokens) * 100 : 0; + + // Estimate savings: cache reads cost ~90% less than regular input + // Savings = cacheReadTokens * (inputRate - cacheReadRate) + const inputCost = data.tokenBreakdown.input.cost; + const inputTokens = data.tokenBreakdown.input.tokens || 1; + const cacheReadCost = data.tokenBreakdown.cacheRead.cost; + const cacheReadTokens = data.tokenBreakdown.cacheRead.tokens || 1; + + const inputRate = inputTokens > 0 ? inputCost / (inputTokens / 1_000_000) : 0; + const cacheReadRate = cacheReadTokens > 0 ? cacheReadCost / (cacheReadTokens / 1_000_000) : 0; + + const estimatedSavings = + inputRate > 0 && cacheReadRate < inputRate + ? (data.totalCacheReadTokens / 1_000_000) * (inputRate - cacheReadRate) + : 0; + + return { + cacheHitRate, + estimatedSavings: Math.max(0, estimatedSavings), + totalCacheReads: data.totalCacheReadTokens, + totalCacheWrites: data.totalCacheCreationTokens, + totalCacheTokens, + cacheCost: data.tokenBreakdown.cacheRead.cost + data.tokenBreakdown.cacheCreation.cost, + }; + }, [data]); + + if (isLoading) { + return ( + + + + + + + + + ); + } + + if (!metrics || metrics.totalCacheTokens === 0) { + return ( + + + + + Cache Efficiency + + + +

No cache data available

+
+
+ ); + } + + return ( + + + + + Cache Efficiency + + + + {/* Primary metric: Savings */} +
+
+ + ${metrics.estimatedSavings.toFixed(2)} +
+

+ Estimated Savings +

+
+ + {/* Secondary metrics row */} +
+ {/* Cache Hit Rate */} +
+
+ + {metrics.cacheHitRate.toFixed(0)}% +
+

Hit Rate

+
+ + {/* Cache Cost */} +
+ ${metrics.cacheCost.toFixed(2)} +

Cache Cost

+
+
+ + {/* Cache breakdown bar */} +
+
+ Reads: {formatCompact(metrics.totalCacheReads)} + Writes: {formatCompact(metrics.totalCacheWrites)} +
+
+
+
+
+
+ +
+ Read + + +
+ Write + +
+
+ + + ); +} + +function formatCompact(num: number): string { + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; + if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`; + return num.toString(); +} diff --git a/ui/src/components/analytics/model-breakdown-chart.tsx b/ui/src/components/analytics/model-breakdown-chart.tsx index e96045fe..9ef39a0e 100644 --- a/ui/src/components/analytics/model-breakdown-chart.tsx +++ b/ui/src/components/analytics/model-breakdown-chart.tsx @@ -25,7 +25,6 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo name: item.model, value: item.tokens, cost: item.cost, - requests: item.requests, percentage: item.percentage, fill: getModelColor(item.model), })); @@ -47,18 +46,18 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo if (!active || !payload) return null; const payloadArray = payload as Array<{ - payload: { name: string; value: number; cost: number; requests: number; percentage: number }; + payload: { name: string; value: number; cost: number; percentage: number }; }>; if (!payloadArray.length) return null; - const data = payloadArray[0].payload; + const item = payloadArray[0].payload; return (
-

{data.name}

+

{item.name}

- {formatNumber(data.value)} ({data.percentage.toFixed(1)}%) + {formatNumber(item.value)} ({item.percentage.toFixed(1)}%)

-

${data.cost.toFixed(4)}

+

${item.cost.toFixed(4)}

); }; @@ -77,8 +76,8 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo cy="50%" labelLine={false} label={renderLabel} - innerRadius={60} - outerRadius={80} + innerRadius={50} + outerRadius={70} paddingAngle={2} dataKey="value" > diff --git a/ui/src/components/analytics/model-details-content.tsx b/ui/src/components/analytics/model-details-content.tsx new file mode 100644 index 00000000..2b813283 --- /dev/null +++ b/ui/src/components/analytics/model-details-content.tsx @@ -0,0 +1,160 @@ +import { Badge } from '@/components/ui/badge'; +import { ArrowDownRight, ArrowUpRight, Database, Gauge, Sparkles } from 'lucide-react'; +import type { ModelUsage } from '@/hooks/use-usage'; + +interface ModelDetailsContentProps { + model: ModelUsage; +} + +export function ModelDetailsContent({ model }: ModelDetailsContentProps) { + const ioRatioStatus = getIoRatioStatus(model.ioRatio); + + return ( +
+ {/* Header */} +
+
+ +

+ {model.model} +

+
+
+ + {model.percentage.toFixed(1)}% usage + + + {model.ioRatio.toFixed(0)}:1 I/O + +
+
+ + {/* Stats Grid */} +
+
+

${model.cost.toFixed(2)}

+

Total Cost

+
+
+

{formatCompactNumber(model.tokens)}

+

Total Tokens

+
+
+ + {/* Token Breakdown */} +
+
+ Token Breakdown +
+
+ + + + +
+
+ + {/* I/O Ratio Info */} +
+
+ + Input/Output Ratio +
+

+ {ioRatioStatus.description} +

+
+
+ ); +} + +interface TokenRowProps { + label: string; + tokens: number; + cost: number; + color: string; + icon: React.ComponentType<{ className?: string }>; +} + +function TokenRow({ label, tokens, cost, color, icon: Icon }: TokenRowProps) { + if (tokens === 0) return null; + + return ( +
+
+
+
+ {label} + ${cost.toFixed(3)} +
+
+ + {formatNumber(tokens)} +
+
+
+ ); +} + +function getIoRatioStatus(ratio: number): { + variant: 'default' | 'secondary' | 'destructive' | 'outline'; + description: string; +} { + if (ratio >= 200) { + return { + variant: 'destructive', + description: 'Extended thinking or large context loading. Expected for reasoning models.', + }; + } + if (ratio >= 50) { + return { + variant: 'secondary', + description: 'More input than output. Typical for analysis tasks.', + }; + } + if (ratio >= 5) { + return { + variant: 'outline', + description: 'Balanced input/output ratio for typical coding tasks.', + }; + } + return { + variant: 'default', + description: 'More output than input. Generation-heavy workload.', + }; +} + +function formatNumber(num: number): string { + return num.toLocaleString(); +} + +function formatCompactNumber(num: number): string { + if (num >= 1000000000) return `${(num / 1000000000).toFixed(1)}B`; + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; + return num.toString(); +} diff --git a/ui/src/components/analytics/session-stats-card.tsx b/ui/src/components/analytics/session-stats-card.tsx new file mode 100644 index 00000000..2637467d --- /dev/null +++ b/ui/src/components/analytics/session-stats-card.tsx @@ -0,0 +1,155 @@ +/** + * Session Stats Card Component + * + * Displays session usage metrics including active sessions, average duration, + * and session cost breakdown. + */ + +import { useMemo } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Clock, Users, Zap, Terminal } from 'lucide-react'; +import type { PaginatedSessions } from '@/hooks/use-usage'; +import { cn } from '@/lib/utils'; +import { formatDistanceToNow } from 'date-fns'; + +interface SessionStatsCardProps { + data: PaginatedSessions | undefined; + isLoading?: boolean; + className?: string; +} + +export function SessionStatsCard({ data, isLoading, className }: SessionStatsCardProps) { + const stats = useMemo(() => { + if (!data?.sessions || data.sessions.length === 0) return null; + + const sessions = data.sessions; + const totalSessions = data.total; + + // Calculate average tokens per session + const totalTokens = sessions.reduce((sum, s) => sum + (s.inputTokens + s.outputTokens), 0); + const avgTokens = Math.round(totalTokens / sessions.length); + + // Calculate total cost for visible sessions + const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0); + const avgCost = totalCost / sessions.length; + + // Most recent session + const lastSession = sessions[0]; + const lastActive = lastSession + ? formatDistanceToNow(new Date(lastSession.lastActivity), { addSuffix: true }) + : 'N/A'; + + return { + totalSessions, + avgTokens, + avgCost, + lastActive, + recentSessions: sessions.slice(0, 3), + }; + }, [data]); + + if (isLoading) { + return ( + + + + + + + + + ); + } + + if (!stats) { + return ( + + + + + Session Stats + + + +

No session data available

+
+
+ ); + } + + return ( + + + + + Session Stats + + + + {/* Key Metrics Grid */} +
+ {/* Total Sessions */} +
+
+ + {stats.totalSessions} +
+

+ Total Sessions +

+
+ + {/* Avg Cost */} +
+
+ + ${stats.avgCost.toFixed(2)} +
+

+ Avg Cost/Session +

+
+
+ + {/* Recent Activity List */} +
+
+ + Recent Activity +
+
+ {stats.recentSessions.map((session) => ( +
+
+ + {session.projectPath.split('/').pop()} + + + {formatDistanceToNow(new Date(session.lastActivity), { addSuffix: true })} + +
+
+
${session.cost.toFixed(2)}
+
+ {formatCompact(session.inputTokens + session.outputTokens)} toks +
+
+
+ ))} +
+
+
+
+ ); +} + +function formatCompact(num: number): string { + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; + if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`; + return num.toString(); +} diff --git a/ui/src/components/analytics/token-breakdown-chart.tsx b/ui/src/components/analytics/token-breakdown-chart.tsx new file mode 100644 index 00000000..01f90913 --- /dev/null +++ b/ui/src/components/analytics/token-breakdown-chart.tsx @@ -0,0 +1,180 @@ +/** + * Token Breakdown Chart Component + * + * Displays token usage breakdown by type (input, output, cache). + * Shows stacked bar chart with cost breakdown. + */ + +import { useMemo } from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; +import { Skeleton } from '@/components/ui/skeleton'; +import type { TokenBreakdown } from '@/hooks/use-usage'; +import { cn } from '@/lib/utils'; + +interface TokenBreakdownChartProps { + data?: TokenBreakdown; + isLoading?: boolean; + className?: string; +} + +const COLORS = { + input: '#3b82f6', // blue-500 + output: '#f97316', // orange-500 + cacheCreation: '#06b6d4', // cyan-500 + cacheRead: '#22c55e', // green-500 +}; + +export function TokenBreakdownChart({ data, isLoading, className }: TokenBreakdownChartProps) { + const chartData = useMemo(() => { + if (!data) return []; + + return [ + { + name: 'Input', + tokens: data.input.tokens, + cost: data.input.cost, + fill: COLORS.input, + }, + { + name: 'Output', + tokens: data.output.tokens, + cost: data.output.cost, + fill: COLORS.output, + }, + { + name: 'Cache Write', + tokens: data.cacheCreation.tokens, + cost: data.cacheCreation.cost, + fill: COLORS.cacheCreation, + }, + { + name: 'Cache Read', + tokens: data.cacheRead.tokens, + cost: data.cacheRead.cost, + fill: COLORS.cacheRead, + }, + ]; + }, [data]); + + // Calculate totals for percentages + const totals = useMemo(() => { + const totalTokens = chartData.reduce((sum, d) => sum + d.tokens, 0); + const totalCost = chartData.reduce((sum, d) => sum + d.cost, 0); + return { totalTokens, totalCost }; + }, [chartData]); + + if (isLoading) { + return ; + } + + if (!data || chartData.every((d) => d.tokens === 0)) { + return ( +
+

No token data available

+
+ ); + } + + return ( +
+ + + + + formatNumber(value)} + /> + + + + { + if (!active || !payload?.length) return null; + const item = payload[0].payload as (typeof chartData)[0]; + const tokenPercent = + totals.totalTokens > 0 + ? ((item.tokens / totals.totalTokens) * 100).toFixed(1) + : '0'; + const costPercent = + totals.totalCost > 0 ? ((item.cost / totals.totalCost) * 100).toFixed(1) : '0'; + + return ( +
+

{item.name}

+

+ Tokens: {formatNumber(item.tokens)} ({tokenPercent}%) +

+

+ Cost: ${item.cost.toFixed(2)} ({costPercent}%) +

+
+ ); + }} + /> + + {value}} + wrapperStyle={{ paddingTop: '10px' }} + /> + + +
+
+ + {/* Cost breakdown summary */} +
+ {chartData.map((item) => ( +
+
+
+

{item.name}

+

${item.cost.toFixed(2)}

+
+
+ ))} +
+
+ ); +} + +function formatNumber(num: number): string { + if (num >= 1000000000) { + return `${(num / 1000000000).toFixed(1)}B`; + } + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M`; + } + if (num >= 1000) { + return `${(num / 1000).toFixed(1)}K`; + } + return num.toLocaleString(); +} diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx index 9be107c5..cde345f2 100644 --- a/ui/src/components/analytics/usage-summary-cards.tsx +++ b/ui/src/components/analytics/usage-summary-cards.tsx @@ -2,12 +2,12 @@ * Usage Summary Cards Component * * Displays key metrics in a card grid layout. - * Shows total tokens, cost, requests, and average tokens per request. + * Shows total tokens, cost, cache tokens, and average cost per day. */ import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { TrendingUp, DollarSign, Zap, FileText } from 'lucide-react'; +import { DollarSign, Database, FileText, ArrowDownRight, ArrowUpRight } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { UsageSummary } from '@/hooks/use-usage'; @@ -19,8 +19,8 @@ interface UsageSummaryCardsProps { export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { if (isLoading) { return ( -
- {[1, 2, 3, 4].map((i) => ( +
+ {[1, 2, 3, 4, 5].map((i) => (
@@ -37,6 +37,11 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { ); } + // Calculate cache cost percentage + const cacheCost = + (data?.tokenBreakdown?.cacheCreation?.cost ?? 0) + (data?.tokenBreakdown?.cacheRead?.cost ?? 0); + const cacheCostPercent = data?.totalCost ? Math.round((cacheCost / data.totalCost) * 100) : 0; + const cards = [ { title: 'Total Tokens', @@ -45,6 +50,7 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { format: (v: number) => formatNumber(v), color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20', + subtitle: `${formatNumber(data?.totalInputTokens ?? 0)} in / ${formatNumber(data?.totalOutputTokens ?? 0)} out`, }, { title: 'Total Cost', @@ -53,27 +59,39 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { format: (v: number) => `$${v.toFixed(2)}`, color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20', + subtitle: `$${data?.averageCostPerDay?.toFixed(2) ?? '0.00'}/day avg`, }, { - title: 'Total Requests', - value: data?.totalRequests ?? 0, - icon: Zap, + title: 'Cache Tokens', + value: data?.totalCacheTokens ?? 0, + icon: Database, format: (v: number) => formatNumber(v), + color: 'text-cyan-600', + bgColor: 'bg-cyan-100 dark:bg-cyan-900/20', + subtitle: `$${cacheCost.toFixed(2)} (${cacheCostPercent}% of cost)`, + }, + { + title: 'Input Cost', + value: data?.tokenBreakdown?.input?.cost ?? 0, + icon: ArrowDownRight, + format: (v: number) => `$${v.toFixed(2)}`, color: 'text-purple-600', bgColor: 'bg-purple-100 dark:bg-purple-900/20', + subtitle: `${formatNumber(data?.tokenBreakdown?.input?.tokens ?? 0)} tokens`, }, { - title: 'Avg Tokens/Request', - value: data?.averageTokensPerRequest ?? 0, - icon: TrendingUp, - format: (v: number) => formatNumber(Math.round(v)), + title: 'Output Cost', + value: data?.tokenBreakdown?.output?.cost ?? 0, + icon: ArrowUpRight, + format: (v: number) => `$${v.toFixed(2)}`, color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20', + subtitle: `${formatNumber(data?.tokenBreakdown?.output?.tokens ?? 0)} tokens`, }, ]; return ( -
+
{cards.map((card, index) => { const Icon = card.icon; return ( @@ -83,6 +101,9 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {

{card.title}

{card.format(card.value)}

+ {card.subtitle && ( +

{card.subtitle}

+ )}
diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx new file mode 100644 index 00000000..07b74e1a --- /dev/null +++ b/ui/src/components/ui/popover.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; + +import { cn } from '@/lib/utils'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts index 69dfe88e..3bcbde88 100644 --- a/ui/src/hooks/use-usage.ts +++ b/ui/src/hooks/use-usage.ts @@ -7,39 +7,88 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; // Types +export interface TokenCategoryCost { + tokens: number; + cost: number; +} + +export interface TokenBreakdown { + input: TokenCategoryCost; + output: TokenCategoryCost; + cacheCreation: TokenCategoryCost; + cacheRead: TokenCategoryCost; +} + export interface UsageSummary { totalTokens: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheTokens: number; + totalCacheCreationTokens: number; + totalCacheReadTokens: number; totalCost: number; - totalRequests: number; - averageTokensPerRequest: number; - dailyUsage: DailyUsage[]; + tokenBreakdown: TokenBreakdown; + totalDays: number; + averageTokensPerDay: number; + averageCostPerDay: number; } export interface DailyUsage { date: string; tokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; cost: number; - requests: number; + modelsUsed: number; } export interface ModelUsage { model: string; tokens: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cacheTokens: number; cost: number; - requests: number; percentage: number; + costBreakdown: TokenBreakdown; + ioRatio: number; +} + +export type AnomalyType = 'high_input' | 'high_io_ratio' | 'cost_spike' | 'high_cache_read'; + +export interface Anomaly { + date: string; + type: AnomalyType; + model?: string; + value: number; + threshold: number; + message: string; +} + +export interface AnomalySummary { + totalAnomalies: number; + highInputDays: number; + highIoRatioDays: number; + costSpikeDays: number; + highCacheReadDays: number; +} + +export interface UsageInsights { + anomalies: Anomaly[]; + summary: AnomalySummary; } export interface Session { - id: string; - startTime: string; - endTime?: string; - duration?: number; - tokens: number; + sessionId: string; + projectPath: string; + inputTokens: number; + outputTokens: number; cost: number; - requests: number; - profile: string; - model: string; + lastActivity: string; + modelsUsed: string[]; } export interface PaginatedSessions { @@ -132,6 +181,14 @@ export const usageApi = { }, /** Get cache status including last fetch timestamp */ status: () => request('/usage/status'), + /** Get usage insights including anomaly detection */ + insights: (options?: UsageQueryOptions) => { + const params = new URLSearchParams(); + if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); + if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + if (options?.profile) params.append('profile', options.profile); + return request(`/usage/insights?${params}`); + }, }; // Helper function to match existing API client pattern @@ -204,3 +261,23 @@ export function useUsageStatus() { refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds }); } + +/** + * Hook to get usage insights with anomaly detection + * Returns detected anomalies and summary statistics + */ +export function useUsageInsights(options?: UsageQueryOptions) { + return useQuery({ + queryKey: ['usage', 'insights', options], + queryFn: () => usageApi.insights(options), + staleTime: 60 * 1000, // 1 minute + }); +} + +export function useSessions(options?: UsageQueryOptions) { + return useQuery({ + queryKey: ['usage', 'sessions', options], + queryFn: () => usageApi.sessions(options), + staleTime: 60 * 1000, // 1 minute + }); +} diff --git a/ui/src/index.css b/ui/src/index.css index 1cdd21ed..1fcc98c7 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -30,6 +30,12 @@ --popover: oklch(0.9635 0.0067 97.35); /* Match background */ --popover-foreground: oklch(0.2 0.02 40); /* Match foreground */ + --card: oklch(0.9635 0.0067 97.35); /* Match background */ + --card-foreground: oklch(0.2 0.02 40); /* Match foreground */ + + --destructive: oklch(0.577 0.245 27.325); /* Red 600 */ + --destructive-foreground: oklch(0.9635 0.0067 97.35); /* White */ + /* Sidebar colors - Light */ --sidebar: oklch(0.9635 0.0067 97.35); /* Pampas */ --sidebar-foreground: oklch(0.2 0.02 40); @@ -69,6 +75,12 @@ --popover: oklch(0.21 0.006 100); /* Match dark bg */ --popover-foreground: oklch(0.9635 0.0067 97.35); /* Match dark fg */ + --card: oklch(0.21 0.006 100); /* Match dark bg */ + --card-foreground: oklch(0.9635 0.0067 97.35); /* Match dark fg */ + + --destructive: oklch(0.396 0.141 25.723); /* Red 900 */ + --destructive-foreground: oklch(0.9635 0.0067 97.35); /* White */ + /* Sidebar Dark Theme */ --sidebar: oklch(0.21 0.006 100); /* Match bg */ --sidebar-foreground: oklch(0.9635 0.0067 97.35); @@ -92,6 +104,14 @@ --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index 6f102607..0605c248 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -2,29 +2,44 @@ * Analytics Page * * Displays Claude Code usage analytics with charts. - * Features trend charts, model breakdown, and cost analysis. + * Features trend charts, model breakdown, cost analysis, and anomaly detection. */ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useRef, useCallback } from 'react'; import type { DateRange } from 'react-day-picker'; import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; +import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover'; import { DateRangeFilter } from '@/components/analytics/date-range-filter'; import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards'; import { UsageTrendChart } from '@/components/analytics/usage-trend-chart'; import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart'; -import { TrendingUp, PieChart, RefreshCw } from 'lucide-react'; +import { ModelDetailsContent } from '@/components/analytics/model-details-content'; +import { SessionStatsCard } from '@/components/analytics/session-stats-card'; +import { AnomalyAlertBadge } from '@/components/analytics/anomaly-alert-badge'; +import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react'; import { useUsageSummary, useUsageTrends, useModelUsage, useRefreshUsage, useUsageStatus, + useUsageInsights, + useSessions, + type ModelUsage, } from '@/hooks/use-usage'; import { getModelColor } from '@/lib/utils'; +// Format token count to human-readable (K/M/B) +function formatTokens(num: number): string { + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; + if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`; + return num.toString(); +} + export function AnalyticsPage() { // Default to last 30 days const [dateRange, setDateRange] = useState({ @@ -32,6 +47,9 @@ export function AnalyticsPage() { to: new Date(), }); const [isRefreshing, setIsRefreshing] = useState(false); + const [selectedModel, setSelectedModel] = useState(null); + const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null); + const popoverAnchorRef = useRef(null); // Refresh hook const refreshUsage = useRefreshUsage(); @@ -55,6 +73,8 @@ export function AnalyticsPage() { const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions); const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions); const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions); + const { data: insights, isLoading: isInsightsLoading } = useUsageInsights(apiOptions); + const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 }); const { data: status } = useUsageStatus(); // Format "Last updated" text @@ -63,6 +83,18 @@ export function AnalyticsPage() { return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true }); }, [status?.lastFetch]); + // Handle model click for popover + const handleModelClick = useCallback((model: ModelUsage, event: React.MouseEvent) => { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + setPopoverPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }); + setSelectedModel(model); + }, []); + + const handlePopoverClose = useCallback(() => { + setSelectedModel(null); + setPopoverPosition(null); + }, []); + return (
@@ -73,6 +105,10 @@ export function AnalyticsPage() {

Track usage & insights

+ {/* Anomaly Alert Badge */} + {!isInsightsLoading && insights && ( + + )} {/* Main Content */} -
+
{/* Usage Trend Chart - Full Width */} - - + + Usage Trends - + - {/* Bottom Row - Model Usage & Cost */} -
- {/* Model Distribution */} - - - - - Model Usage + {/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */} +
+ {/* Cost by Model - 4/10 width with breakdown */} + + + + + Cost by Model - -
-
- -
-
- {models?.slice(0, 8).map((model) => ( -
-
-
-
- - {model.model} - - - {model.percentage.toFixed(1)}% - -
-
-
- ))} -
-
- - - - {/* Cost Breakdown */} - - - Cost by Model - - + {isModelsLoading ? ( ) : ( -
+
{[...(models || [])] .sort((a, b) => b.cost - a.cost) .map((model) => ( -
handleModelClick(model, e)} + title="Click for details" > -
+ {/* Model name */} +
- + {model.model}
- - ${model.cost.toFixed(4)} + {/* Cost breakdown mini-bar */} +
+
+
0 ? (model.costBreakdown.input.cost / model.cost) * 100 : 0}%`, + }} + title={`Input: $${model.costBreakdown.input.cost.toFixed(2)}`} + /> +
0 ? (model.costBreakdown.output.cost / model.cost) * 100 : 0}%`, + }} + title={`Output: $${model.costBreakdown.output.cost.toFixed(2)}`} + /> +
0 ? (model.costBreakdown.cacheCreation.cost / model.cost) * 100 : 0}%`, + }} + title={`Cache Write: $${model.costBreakdown.cacheCreation.cost.toFixed(2)}`} + /> +
0 ? (model.costBreakdown.cacheRead.cost / model.cost) * 100 : 0}%`, + }} + title={`Cache Read: $${model.costBreakdown.cacheRead.cost.toFixed(2)}`} + /> +
+
+ {/* Token count */} + + {formatTokens(model.tokens)} -
+ {/* Total cost */} + + ${model.cost.toFixed(2)} + + + ))} + {/* Legend */} +
+ +
+ Input + + +
+ Output + + +
+ Cache Write + + +
+ Cache Read + +
)} + + {/* Model Distribution - 2/10 width */} + + + + + Model Usage + + + + + + + + {/* Session Stats - 4/10 width */} +
+ + {/* Model Details Popover - positioned at cursor */} + !open && handlePopoverClose()}> + +
+ + + {selectedModel && } + +
@@ -218,6 +336,26 @@ export function AnalyticsSkeleton() { {/* Bottom Row Skeletons */}
+ {/* Cost Breakdown Skeleton */} + + + + + +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+ {/* Model Usage Skeleton */} @@ -239,26 +377,6 @@ export function AnalyticsSkeleton() {
- - {/* Cost Breakdown Skeleton */} - - - - - -
- {[1, 2, 3, 4, 5].map((i) => ( -
-
- - -
- -
- ))} -
-
-
);