import { useMemo, useState } from 'react'; import { createFileRoute } from '@tanstack/react-router'; import { useQuery } from '@tanstack/react-query'; import { ResponsiveLine } from '@nivo/line'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { ipc } from '@/ipc/manager'; import { useCloudAccounts } from '@/hooks/useCloudAccounts'; import { useAppConfig } from '@/hooks/useAppConfig'; import { StatCard } from '@/components/usage/StatCard'; import { ChartCard } from '@/components/usage/ChartCard'; import { ForecastCard } from '@/components/usage/ForecastCard'; import { ModelQuotaBar } from '@/components/usage/ModelQuotaBar'; import { CostReportCard, ModelCostEntry } from '@/components/usage/CostReportCard'; import { ModelPricingDialog } from '@/components/usage/ModelPricingDialog'; import { calculateCost } from '@/constants/pricing'; import { COLOR_PROMPT, COLOR_COMPLETION, LineTooltip } from '@/components/usage/NivoTooltip'; import { nivoTheme } from '@/lib/nivo-theme'; import { MonitorTab } from '@/components/usage/MonitorTab'; import { Activity, Layers, Loader2, MessageSquare, Radio, Zap, DollarSign, Info, AlertTriangle, Settings, } from 'lucide-react'; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ type TimeRange = '24h' | '7d' | '30d'; interface UsageBucket { bucket: string; promptTokens: number; completionTokens: number; totalTokens: number; requests: number; } interface ModelBucket { model: string; promptTokens: number; completionTokens: number; totalTokens: number; requests: number; } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function getTimeRange(r: TimeRange): { start: number; end: number } { const now = Date.now(); const ms: Record = { '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000, }; return { start: now - ms[r], end: now }; } function getPreviousTimeRange(r: TimeRange): { start: number; end: number } { const { start, end } = getTimeRange(r); const duration = end - start; return { start: start - duration, end: start }; } function formatBucketLabel(bucket: string, range: TimeRange): string { if (range === '24h') { const parts = bucket.split(' '); return parts.length === 2 ? parts[1] : bucket; } const d = new Date(bucket + 'T00:00:00'); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } 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 n.toLocaleString(); } function calcTrend( current: number, previous: number, ): { value: number; direction: 'up' | 'down' | 'neutral' } { if (previous === 0 && current === 0) { return { value: 0, direction: 'neutral' }; } if (previous === 0) { return { value: 100, direction: 'up' }; } const pct = ((current - previous) / previous) * 100; if (Math.abs(pct) < 0.5) { return { value: 0, direction: 'neutral' }; } return { value: Math.abs(pct), direction: pct > 0 ? 'up' : 'down' }; } /* ------------------------------------------------------------------ */ /* Aggregation helpers */ /* ------------------------------------------------------------------ */ function sumBuckets(data: UsageBucket[]) { return data.reduce( (acc, row) => ({ promptTokens: acc.promptTokens + row.promptTokens, completionTokens: acc.completionTokens + row.completionTokens, totalTokens: acc.totalTokens + row.totalTokens, requests: acc.requests + row.requests, }), { promptTokens: 0, completionTokens: 0, totalTokens: 0, requests: 0 }, ); } /* ------------------------------------------------------------------ */ /* Main Component */ /* ------------------------------------------------------------------ */ function UsagePage() { const [range, setRange] = useState('7d'); const ALL_ACCOUNTS = '__all__'; const [selectedAccountId, setSelectedAccountId] = useState(ALL_ACCOUNTS); const [isPricingDialogOpen, setIsPricingDialogOpen] = useState(false); const { data: accounts, isLoading: accountsLoading } = useCloudAccounts(); const { config, saveConfig } = useAppConfig(); const accountFilter = selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId; /* ───── Current period data ───── */ const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily, } = useQuery({ queryKey: ['usage', 'day', range, selectedAccountId], queryFn: () => { const { start, end } = getTimeRange(range); return ipc.client.usage.getUsageByDay({ accountId: accountFilter, start, end, }); }, refetchInterval: 15_000, refetchIntervalInBackground: false, staleTime: 30_000, }); const { data: prevDailyData } = useQuery({ queryKey: ['usage', 'day', 'prev', range, selectedAccountId], queryFn: () => { const { start, end } = getPreviousTimeRange(range); return ipc.client.usage.getUsageByDay({ accountId: accountFilter, start, end, }); }, staleTime: 60_000, }); const { data: modelData, isLoading: modelLoading, error: modelError, refetch: refetchModel, } = useQuery({ queryKey: ['usage', 'model', range, selectedAccountId], queryFn: () => { const { start, end } = getTimeRange(range); return ipc.client.usage.getUsageByModel({ accountId: accountFilter, start, end, }); }, refetchInterval: 15_000, refetchIntervalInBackground: false, staleTime: 30_000, }); const { data: hourlyData, isLoading: hourlyLoading, error: hourlyError, refetch: refetchHourly, } = useQuery({ queryKey: ['usage', 'hour', range, selectedAccountId], queryFn: () => { const { start, end } = getTimeRange(range); return ipc.client.usage.getUsageByHour({ accountId: accountFilter, start, end, }); }, refetchInterval: 15_000, refetchIntervalInBackground: false, staleTime: 30_000, }); /* ───── Derived data ───── */ const totals = useMemo(() => sumBuckets(dailyData || []), [dailyData]); const prevTotals = useMemo(() => sumBuckets(prevDailyData || []), [prevDailyData]); const promptRatio = totals.totalTokens ? Math.round((totals.promptTokens / totals.totalTokens) * 100) : 0; const completionRatio = totals.totalTokens ? Math.round((totals.completionTokens / totals.totalTokens) * 100) : 0; const avgReqSize = totals.requests ? Math.round(totals.totalTokens / totals.requests) : 0; const sparklineTotal = useMemo(() => (dailyData || []).map((d) => d.totalTokens), [dailyData]); const sparklinePrompt = useMemo(() => (dailyData || []).map((d) => d.promptTokens), [dailyData]); const sparklineCompletion = useMemo( () => (dailyData || []).map((d) => d.completionTokens), [dailyData], ); const sparklineRequests = useMemo(() => (dailyData || []).map((d) => d.requests), [dailyData]); // Area chart — Nivo line format const areaChartData = useMemo(() => { const source = range === '24h' ? hourlyData || [] : dailyData || []; return [ { id: 'Input Tokens', color: COLOR_PROMPT, data: source.map((d) => ({ x: formatBucketLabel(d.bucket, range), y: d.promptTokens, })), }, { id: 'Output Tokens', color: COLOR_COMPLETION, data: source.map((d) => ({ x: formatBucketLabel(d.bucket, range), y: d.completionTokens, })), }, ]; }, [dailyData, hourlyData, range]); // Model quota bar data and cost estimation const { topModelsData, costReport, totalCost, hasQuotaWarning } = useMemo(() => { let cost = 0; let warning = false; const report: ModelCostEntry[] = []; if (!modelData?.length) { return { topModelsData: [], costReport: [], totalCost: 0, hasQuotaWarning: false }; } const accountQuotas = selectedAccountId !== ALL_ACCOUNTS ? accounts?.find((a) => a.id === selectedAccountId)?.quota?.models : null; const processed = [...modelData] .sort((a, b) => b.totalTokens - a.totalTokens) .map((m) => { const inputCost = calculateCost(m.model, m.promptTokens, 0, config?.custom_model_pricing); const outputCost = calculateCost( m.model, 0, m.completionTokens, config?.custom_model_pricing, ); const mTotalCost = calculateCost( m.model, m.promptTokens, m.completionTokens, config?.custom_model_pricing, ); cost += mTotalCost; report.push({ model: m.model, inputTokens: m.promptTokens, outputTokens: m.completionTokens, inputCost, outputCost, totalCost: mTotalCost, }); // Use percentage if available directly from the API response const percentage = accountQuotas?.[m.model]?.percentage; if (percentage && percentage >= 80) { warning = true; } // Since limit tokens isn't stored in quotas exactly, we just use undefined // to not display limit stats in the bar but just usage const limitTokens = undefined; return { model: m.model, tokens: m.totalTokens, limit: limitTokens, }; }); return { topModelsData: processed.slice(0, 8), costReport: report.sort((a, b) => b.totalCost - a.totalCost), totalCost: cost, hasQuotaWarning: warning, }; }, [modelData, accounts, selectedAccountId]); const isAreaEmpty = range === '24h' ? !hourlyLoading && !hourlyError && !hourlyData?.length : !dailyLoading && !dailyError && !dailyData?.length; const areaLoading = range === '24h' ? hourlyLoading : dailyLoading; const areaError = range === '24h' ? hourlyError : dailyError; const isModelEmpty = !modelLoading && !modelError && !modelData?.length; const handleSavePricing = async ( pricing: Record, ) => { if (config) { await saveConfig({ ...config, custom_model_pricing: pricing }); } }; /* ───── Render ───── */ return (
{/* ─── Header ─── */}

Usage Analytics

Live

Token consumption and model distribution

setRange(v as TimeRange)}> 24h 7d 30d
Analytics Monitor
{hasQuotaWarning && ( Quota Warning One or more models have exceeded 80% of their allocated usage limit. )} {/* ─── Stat cards ─── */}
Input Tokens

Tokens sent to the model (prompt, context, files).

} value={formatNumber(totals.promptTokens)} icon={MessageSquare} isLoading={dailyLoading} accent="blue" trend={ prevDailyData ? calcTrend(totals.promptTokens, prevTotals.promptTokens) : undefined } sparkline={sparklinePrompt} />
Output Tokens

Tokens generated by the model in response.

} value={formatNumber(totals.completionTokens)} icon={Activity} isLoading={dailyLoading} accent="green" trend={ prevDailyData ? calcTrend(totals.completionTokens, prevTotals.completionTokens) : undefined } sparkline={sparklineCompletion} />
Estimated Cost

Approximate cost based on configured pricing.

} value={`$${totalCost.toFixed(2)}`} icon={DollarSign} isLoading={modelLoading} accent="purple" /> {/* ─── Prompt/Completion ratio bar + top models ─── */} {!dailyLoading && totals.totalTokens > 0 && (
Input / Output {formatNumber(totals.totalTokens)} total
Input {promptRatio}%
Output {completionRatio}%
Avg/req:{' '} {formatNumber(avgReqSize)}
)} {/* ─── Charts — Area + Model ranking ─── */}
(range === '24h' ? refetchHourly() : refetchDaily())} emptyTitle="No usage data" emptyDescription="Data will appear here once proxy requests are recorded." errorMessage="Failed to load usage data" retryLabel="Retry" >
(v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`), }} tooltip={LineTooltip} legends={[ { anchor: 'bottom', direction: 'row', justify: false, translateX: 0, translateY: 36, itemsSpacing: 16, itemWidth: 80, itemHeight: 16, itemDirection: 'left-to-right', symbolSize: 8, symbolShape: 'circle', }, ]} animate={true} motionConfig="gentle" />
refetchModel()} emptyTitle="No model data" emptyDescription="Model distribution will appear after proxy usage." errorMessage="Failed to load model data" retryLabel="Retry" >
{topModelsData.map((m) => ( ))}
{/* ─── Cost Report ─── */} {!dailyLoading && modelData && modelData.length > 0 && (
acc + r.inputCost, 0)} totalOutputCost={costReport.reduce((acc, r) => acc + r.outputCost, 0)} totalCost={totalCost} isLoading={modelLoading} />
)} {/* ─── Modals ─── */} m.model) || []} />
); } export const Route = createFileRoute('/usage')({ component: UsagePage, });