FreeLLMAPI / client /src /pages /AnalyticsPage.tsx
Nryn215's picture
Upload folder using huggingface_hub
077865a verified
Raw
History Blame Contribute Delete
15.3 kB
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>
)
}