Spaces:
Runtime error
Runtime error
| import { useState } from 'react' | |
| import { useQuery } from '@tanstack/react-query' | |
| import { | |
| BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, | |
| LineChart, Line, Legend, | |
| } from 'recharts' | |
| import { apiFetch } from '@/lib/api' | |
| import { Button } from '@/components/ui/button' | |
| import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' | |
| import { PageHeader } from '@/components/page-header' | |
| import { Tooltip as HoverTooltip } from '@/components/tooltip' | |
| import { formatSqliteUtcToLocalTime } from '@/lib/utils' | |
| type TimeRange = '24h' | '7d' | '30d' | |
| function formatTokens(n?: number): string { | |
| if (!n) return '0' | |
| 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) | |
| } | |
| function Stat({ label, value, hint, className }: { label: string; value: string | number; hint?: string; className?: string }) { | |
| const card = ( | |
| <div className="rounded-3xl border bg-card px-4 py-3"> | |
| <p className="text-[11px] text-muted-foreground uppercase tracking-wider">{label}</p> | |
| <p className={`text-xl font-semibold tabular-nums mt-1 ${className ?? ''}`}>{value}</p> | |
| </div> | |
| ) | |
| // Same portal tooltip as the routing strategy chips. Opens BELOW the card: | |
| // the stats row sits right under the sticky navbar. | |
| return hint ? <HoverTooltip text={hint} side="bottom" className="block">{card}</HoverTooltip> : card | |
| } | |
| function Panel({ title, children }: { title: string; children: React.ReactNode }) { | |
| return ( | |
| <div className="rounded-3xl border bg-card"> | |
| <div className="px-4 py-3 border-b"> | |
| <h3 className="text-sm font-medium">{title}</h3> | |
| </div> | |
| <div className="p-4">{children}</div> | |
| </div> | |
| ) | |
| } | |
| const axisStyle = { fontSize: 11, fill: 'var(--muted-foreground)' } as const | |
| const gridStyle = 'var(--border)' | |
| const primaryFill = 'var(--foreground)' | |
| export default function AnalyticsPage() { | |
| const [range, setRange] = useState<TimeRange>('7d') | |
| const { data: summary } = useQuery({ | |
| queryKey: ['analytics', 'summary', range], | |
| queryFn: () => apiFetch<any>(`/api/analytics/summary?range=${range}`), | |
| }) | |
| const { data: byPlatform = [] } = useQuery({ | |
| queryKey: ['analytics', 'by-platform', range], | |
| queryFn: () => apiFetch<any[]>(`/api/analytics/by-platform?range=${range}`), | |
| }) | |
| const { data: timeline = [] } = useQuery({ | |
| queryKey: ['analytics', 'timeline', range], | |
| queryFn: () => apiFetch<any[]>(`/api/analytics/timeline?range=${range}`), | |
| }) | |
| const { data: byModel = [] } = useQuery({ | |
| queryKey: ['analytics', 'by-model', range], | |
| queryFn: () => apiFetch<any[]>(`/api/analytics/by-model?range=${range}`), | |
| }) | |
| const { data: errors = [] } = useQuery({ | |
| queryKey: ['analytics', 'errors', range], | |
| queryFn: () => apiFetch<any[]>(`/api/analytics/errors?range=${range}`), | |
| }) | |
| const { data: errorDist } = useQuery({ | |
| queryKey: ['analytics', 'error-distribution', range], | |
| queryFn: () => apiFetch<{ byCategory: any[]; byPlatform: any[]; detailed: any[] }>(`/api/analytics/error-distribution?range=${range}`), | |
| }) | |
| // Savings card shows ONE stable monthly figure regardless of the selected | |
| // range: the last-30-days data projected to a full month from its actual | |
| // span (a young install with 2 days of data shows 15x its 2-day total). | |
| // Once 30 days of history exist the real total shows as-is. The hover | |
| // hint carries the selected period's actual amount and the projection | |
| // basis. Querying 30d separately is free: react-query shares the cache | |
| // with the 30d tab. | |
| const { data: summary30 } = useQuery({ | |
| queryKey: ['analytics', 'summary', '30d'], | |
| queryFn: () => apiFetch<any>(`/api/analytics/summary?range=30d`), | |
| }) | |
| const actualSavings = summary?.estimatedCostSavings ?? 0 | |
| const baseSavings = summary30?.estimatedCostSavings ?? 0 | |
| const spanDays = (() => { | |
| if (!summary30?.firstRequestAt) return 30 | |
| // SQLite stores UTC "YYYY-MM-DD HH:MM:SS" | |
| const first = new Date(summary30.firstRequestAt.replace(' ', 'T') + 'Z').getTime() | |
| const days = (Date.now() - first) / 86_400_000 | |
| if (!Number.isFinite(days)) return 30 | |
| return Math.min(Math.max(days, 1 / 24), 30) | |
| })() | |
| const extrapolated = spanDays < 29.5 | |
| const savings30d = extrapolated ? baseSavings * (30 / spanDays) : baseSavings | |
| const rangeLabel = range === '24h' ? '24 hours' : range === '7d' ? '7 days' : '30 days' | |
| const spanLabel = spanDays >= 2 ? `${Math.round(spanDays)} days` : `${Math.max(1, Math.round(spanDays * 24))} hours` | |
| const savingsHint = | |
| `You actually saved $${actualSavings.toFixed(2)} over the last ${rangeLabel}. That is what the same tokens would have cost on paid APIs, priced per model. ` + | |
| (extrapolated | |
| ? `The number shown projects your pace from the last ${spanLabel} of data to a full 30 days.` | |
| : `The number shown is your real 30-day total.`) | |
| // Pinned = the client named a specific model instead of auto-routing. | |
| // Honored = that model actually served it (the rest failed over). | |
| const pinned = summary?.pinnedRequests ?? 0 | |
| const pinHonored = summary?.pinHonoredRequests ?? 0 | |
| const requestsHint = pinned > 0 | |
| ? `${pinned} of these requests pinned a specific model by name. ${pinHonored} were served by the pinned model; ${pinned - pinHonored} failed over to a different one. The rest were auto-routed.` | |
| : 'All requests in this period were auto-routed; no client pinned a specific model by name.' | |
| return ( | |
| <div> | |
| <PageHeader | |
| title="Analytics" | |
| description="Request volume, latency, token usage, and failures." | |
| actions={ | |
| <div className="flex gap-1 rounded-lg border p-0.5"> | |
| {(['24h', '7d', '30d'] as TimeRange[]).map(r => ( | |
| <Button | |
| key={r} | |
| variant={range === r ? 'secondary' : 'ghost'} | |
| size="xs" | |
| onClick={() => setRange(r)} | |
| > | |
| {r} | |
| </Button> | |
| ))} | |
| </div> | |
| } | |
| /> | |
| <div className="space-y-6"> | |
| {/* Summary stats */} | |
| <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3"> | |
| <Stat label="Requests" value={summary?.totalRequests ?? 0} hint={requestsHint} /> | |
| <Stat label="Success rate" value={`${summary?.successRate ?? 0}%`} /> | |
| <Stat label="Input tokens" value={formatTokens(summary?.totalInputTokens)} /> | |
| <Stat label="Output tokens" value={formatTokens(summary?.totalOutputTokens)} /> | |
| <Stat label="Avg latency" value={`${summary?.avgLatencyMs ?? 0} ms`} /> | |
| {/* Priced per request at the served model's paid-API equivalent | |
| rate (not a flat frontier-model rate) — see db/model-pricing.ts. | |
| The value is a 30-day projection; the hover hint tells the whole | |
| story (actual period amount + whether it was extrapolated). */} | |
| <Stat label="Est. savings" value={`$${savings30d.toFixed(2)}`} hint={savingsHint} /> | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> | |
| <Panel title="Requests by provider"> | |
| {byPlatform.length === 0 ? ( | |
| <p className="text-sm text-muted-foreground text-center py-8">No data yet</p> | |
| ) : ( | |
| <ResponsiveContainer width="100%" height={240}> | |
| <BarChart data={byPlatform} margin={{ top: 6, right: 6, left: -12, bottom: 0 }}> | |
| <CartesianGrid strokeDasharray="2 4" stroke={gridStyle} /> | |
| <XAxis dataKey="platform" tick={axisStyle} tickLine={false} axisLine={{ stroke: gridStyle }} /> | |
| <YAxis tick={axisStyle} tickLine={false} axisLine={false} /> | |
| <Tooltip contentStyle={{ backgroundColor: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }} /> | |
| <Bar dataKey="requests" fill={primaryFill} radius={[3, 3, 0, 0]} /> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| )} | |
| </Panel> | |
| <Panel title="Avg latency by provider"> | |
| {byPlatform.length === 0 ? ( | |
| <p className="text-sm text-muted-foreground text-center py-8">No data yet</p> | |
| ) : ( | |
| <ResponsiveContainer width="100%" height={240}> | |
| <BarChart data={byPlatform} margin={{ top: 6, right: 6, left: -12, bottom: 0 }}> | |
| <CartesianGrid strokeDasharray="2 4" stroke={gridStyle} /> | |
| <XAxis dataKey="platform" tick={axisStyle} tickLine={false} axisLine={{ stroke: gridStyle }} /> | |
| <YAxis unit="ms" tick={axisStyle} tickLine={false} axisLine={false} /> | |
| <Tooltip contentStyle={{ backgroundColor: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }} /> | |
| <Bar dataKey="avgLatencyMs" name="Latency (ms)" fill="var(--muted-foreground)" radius={[3, 3, 0, 0]} /> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| )} | |
| </Panel> | |
| <div className="lg:col-span-2"> | |
| <Panel title="Requests over time"> | |
| {timeline.length === 0 ? ( | |
| <p className="text-sm text-muted-foreground text-center py-8">No data yet</p> | |
| ) : ( | |
| <ResponsiveContainer width="100%" height={240}> | |
| <LineChart data={timeline} margin={{ top: 6, right: 6, left: -12, bottom: 0 }}> | |
| <CartesianGrid strokeDasharray="2 4" stroke={gridStyle} /> | |
| <XAxis dataKey="timestamp" tick={axisStyle} tickLine={false} axisLine={{ stroke: gridStyle }} /> | |
| <YAxis tick={axisStyle} tickLine={false} axisLine={false} /> | |
| <Tooltip contentStyle={{ backgroundColor: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }} /> | |
| <Legend wrapperStyle={{ fontSize: 12 }} iconType="line" /> | |
| <Line type="monotone" dataKey="successCount" name="Success" stroke={primaryFill} strokeWidth={1.5} dot={false} /> | |
| <Line type="monotone" dataKey="failureCount" name="Failures" stroke="var(--destructive)" strokeWidth={1.5} dot={false} /> | |
| </LineChart> | |
| </ResponsiveContainer> | |
| )} | |
| </Panel> | |
| </div> | |
| <div className="lg:col-span-2"> | |
| <Panel title="Per-model breakdown"> | |
| {byModel.length === 0 ? ( | |
| <p className="text-sm text-muted-foreground text-center py-8">No data yet</p> | |
| ) : ( | |
| <div className="max-h-[360px] overflow-y-auto -mx-4"> | |
| <Table> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead className="pl-4">Model</TableHead> | |
| <TableHead>Provider</TableHead> | |
| <TableHead className="text-right">Requests</TableHead> | |
| <TableHead className="text-right">Pinned</TableHead> | |
| <TableHead className="text-right">Success</TableHead> | |
| <TableHead className="text-right">Latency</TableHead> | |
| <TableHead className="text-right">In tokens</TableHead> | |
| <TableHead className="text-right">Out tokens</TableHead> | |
| <TableHead className="text-right pr-4">Saved</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {byModel.map((m: any, i: number) => ( | |
| <TableRow key={i}> | |
| <TableCell className="pl-4 text-sm font-medium">{m.displayName}</TableCell> | |
| <TableCell className="text-xs text-muted-foreground">{m.platform}</TableCell> | |
| <TableCell className="text-right tabular-nums">{m.requests}</TableCell> | |
| <TableCell className="text-right tabular-nums">{m.pinnedRequests > 0 ? m.pinnedRequests : '—'}</TableCell> | |
| <TableCell className="text-right tabular-nums">{m.successRate}%</TableCell> | |
| <TableCell className="text-right tabular-nums">{m.avgLatencyMs} ms</TableCell> | |
| <TableCell className="text-right tabular-nums">{formatTokens(m.totalInputTokens)}</TableCell> | |
| <TableCell className="text-right tabular-nums">{formatTokens(m.totalOutputTokens)}</TableCell> | |
| <TableCell className="text-right tabular-nums pr-4">${(m.estimatedCost ?? 0).toFixed(2)}</TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| )} | |
| </Panel> | |
| </div> | |
| <Panel title="Errors by provider"> | |
| {!errorDist?.byPlatform?.length ? ( | |
| <p className="text-sm text-muted-foreground text-center py-8">No errors</p> | |
| ) : ( | |
| <ResponsiveContainer width="100%" height={240}> | |
| <BarChart data={errorDist.byPlatform} margin={{ top: 6, right: 6, left: -12, bottom: 0 }}> | |
| <CartesianGrid strokeDasharray="2 4" stroke={gridStyle} /> | |
| <XAxis dataKey="platform" tick={axisStyle} tickLine={false} axisLine={{ stroke: gridStyle }} /> | |
| <YAxis tick={axisStyle} tickLine={false} axisLine={false} /> | |
| <Tooltip contentStyle={{ backgroundColor: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }} /> | |
| <Bar dataKey="count" fill="var(--destructive)" radius={[3, 3, 0, 0]} /> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| )} | |
| </Panel> | |
| <Panel title="Recent errors"> | |
| {errors.length === 0 ? ( | |
| <p className="text-sm text-muted-foreground text-center py-8">No errors</p> | |
| ) : ( | |
| <div className="max-h-[240px] overflow-y-auto -mx-4"> | |
| <Table> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead className="pl-4">Provider</TableHead> | |
| <TableHead>Message</TableHead> | |
| <TableHead className="text-right pr-4">Time</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {errors.slice(0, 20).map((e: any) => ( | |
| <TableRow key={e.id}> | |
| <TableCell className="pl-4 text-xs">{e.platform}</TableCell> | |
| <TableCell className="text-xs max-w-[200px] truncate">{e.error}</TableCell> | |
| <TableCell className="text-right text-xs text-muted-foreground tabular-nums pr-4"> | |
| {formatSqliteUtcToLocalTime(e.createdAt, { hour: '2-digit', minute: '2-digit' })} | |
| </TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| )} | |
| </Panel> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |