| 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>
|
| )
|
|
|
|
|
| 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}`),
|
| })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
| 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.`)
|
|
|
|
|
|
|
| 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>
|
| )
|
| }
|
|
|