TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { ResponsiveContainer, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, PieChart, Pie, Cell, LabelList, type TooltipContentProps } from "recharts";4import { ProviderIcon } from "@/components/brand/provider-icon";5import { PROVIDERS, providerName } from "@/lib/client/providers";6import { formatUsd, formatTokens, formatNumber, formatMs, cn } from "@/lib/utils";7import type { ProviderId } from "@/lib/client/types";89/* ------------------------------------------------------------------------------------------------10 * Theme-bound chart primitives. Rules (dataviz): one hue per entity (provider color follows the11 * provider, never its rank), thin marks (≤ 24 px, 4 px rounded data-end), 2 px surface gaps between12 * stacked fills, hairline solid grid, text in text tokens (never the series color), tooltips on13 * every plot, no dual axes, no default recharts colors anywhere.14 * ---------------------------------------------------------------------------------------------- */1516const GRID = "var(--border)";17const TEXT = "var(--fg-muted)";18const SURFACE = "var(--bg-elevated)";19const AXIS_TICK = { fill: TEXT, fontSize: 11, fontFamily: "var(--font-mono)" } as const;20const BAR_MAX = 24;2122export function providerColor(p: string): string {23 return p in PROVIDERS ? PROVIDERS[p as ProviderId].colorVar : "var(--p-custom)";24}2526export interface SeriesPoint {27 bucket: string;28 requests: number;29 failures: number;30 inputTokens: number;31 outputTokens: number;32 costUsd: number;33}3435/** `YYYY-MM-DD` → "Sep 11" · `YYYY-MM-DDTHH:00` → "3 PM" (parsed as wall-clock, never shifted by the browser zone). */36export function bucketLabel(key: string, hourly: boolean, opts: { long?: boolean } = {}): string {37 const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/.exec(key);38 if (!m) return key;39 const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0));40 if (hourly) return opts.long ? d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric" }) : d.toLocaleTimeString("en-US", { hour: "numeric" });41 return d.toLocaleDateString("en-US", opts.long ? { weekday: "short", month: "short", day: "numeric", year: "numeric" } : { month: "short", day: "numeric" });42}4344interface TooltipRow {45 name: string;46 value: React.ReactNode;47 color?: string;48}4950function ChartTooltipBox({ title, rows }: { title?: React.ReactNode; rows: TooltipRow[] }) {51 return (52 <div className="rounded-md border border-border bg-bg-elevated px-2.5 py-2 text-xs shadow-md">53 {title ? <p className="mb-1 font-medium text-fg">{title}</p> : null}54 <ul className="space-y-0.5">55 {rows.map((r) => (56 <li key={r.name} className="flex items-center justify-between gap-4">57 <span className="flex items-center gap-1.5 text-fg-muted">58 {r.color ? <span className="size-2 rounded-full" style={{ background: r.color }} aria-hidden /> : null}59 {r.name}60 </span>61 <span className="font-mono tabular-nums text-fg">{r.value}</span>62 </li>63 ))}64 </ul>65 </div>66 );67}6869type RechartsTooltipProps = TooltipContentProps;7071function num(v: unknown): number {72 return typeof v === "number" ? v : Number(v ?? 0);73}7475/* ---------- chart card frame ---------- */7677export function ChartCard({ title, hint, children, className, legend, action }: { title: string; hint?: React.ReactNode; children: React.ReactNode; className?: string; legend?: React.ReactNode; action?: React.ReactNode }) {78 return (79 <section className={cn("panel min-w-0 p-4", className)} aria-label={title}>80 <header className="mb-3 flex items-start justify-between gap-3">81 <div className="min-w-0">82 <h3 className="text-[13px] font-semibold tracking-tight">{title}</h3>83 {hint ? <p className="text-[11px] text-fg-subtle">{hint}</p> : null}84 </div>85 {legend ?? action}86 </header>87 {children}88 </section>89 );90}9192export function Legend({ items }: { items: { label: string; color: string; icon?: React.ReactNode; opacity?: number }[] }) {93 return (94 <ul className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 text-[11px] text-fg-muted">95 {items.map((i) => (96 <li key={i.label} className="flex items-center gap-1.5">97 {i.icon ?? <span className="size-2 rounded-full" style={{ background: i.color, opacity: i.opacity ?? 1 }} aria-hidden />}98 {i.label}99 </li>100 ))}101 </ul>102 );103}104105export function ChartEmpty({ children = "Nothing recorded for this period." }: { children?: React.ReactNode }) {106 return <p className="flex h-[160px] items-center justify-center text-center text-xs text-fg-subtle">{children}</p>;107}108109/* ---------- time series ---------- */110111const H = 200;112113export function CostOverTime({ data, hourly }: { data: SeriesPoint[]; hourly: boolean }) {114 if (!data.some((d) => d.costUsd > 0)) return <ChartEmpty>No priced requests in this period.</ChartEmpty>;115 return (116 <ResponsiveContainer width="100%" height={H}>117 <AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>118 <defs>119 <linearGradient id="plm-cost-fill" x1="0" y1="0" x2="0" y2="1">120 <stop offset="0%" stopColor="var(--accent)" stopOpacity={0.18} />121 <stop offset="100%" stopColor="var(--accent)" stopOpacity={0.02} />122 </linearGradient>123 </defs>124 <CartesianGrid vertical={false} stroke={GRID} strokeWidth={1} />125 <XAxis dataKey="bucket" tickFormatter={(v) => bucketLabel(String(v), hourly)} tick={AXIS_TICK} axisLine={false} tickLine={false} minTickGap={28} interval="preserveStartEnd" />126 <YAxis tick={AXIS_TICK} axisLine={false} tickLine={false} tickFormatter={(v) => (num(v) === 0 ? "$0" : formatUsd(num(v)))} width={64} />127 <Tooltip128 cursor={{ stroke: "var(--border-strong)" }}129 content={({ active, payload, label }: RechartsTooltipProps) => {130 if (!active || !payload?.length) return null;131 const row = payload[0].payload as SeriesPoint;132 return <ChartTooltipBox title={bucketLabel(String(label), hourly, { long: true })} rows={[{ name: "Est. cost", value: formatUsd(row.costUsd, { precise: true }), color: "var(--accent)" }, { name: "Requests", value: formatNumber(row.requests) }]} />;133 }}134 />135 <Area type="monotone" dataKey="costUsd" stroke="var(--accent)" strokeWidth={2} fill="url(#plm-cost-fill)" isAnimationActive={false} dot={false} activeDot={{ r: 4, fill: "var(--accent)", stroke: SURFACE, strokeWidth: 2 }} />136 </AreaChart>137 </ResponsiveContainer>138 );139}140141export function RequestsOverTime({ data, hourly }: { data: SeriesPoint[]; hourly: boolean }) {142 if (!data.some((d) => d.requests > 0)) return <ChartEmpty />;143 const hasFailures = data.some((d) => d.failures > 0);144 return (145 <ResponsiveContainer width="100%" height={H}>146 <BarChart data={data} margin={{ top: 8, right: 8, left: -18, bottom: 0 }} barCategoryGap="30%">147 <CartesianGrid vertical={false} stroke={GRID} />148 <XAxis dataKey="bucket" tickFormatter={(v) => bucketLabel(String(v), hourly)} tick={AXIS_TICK} axisLine={false} tickLine={false} minTickGap={28} interval="preserveStartEnd" />149 <YAxis tick={AXIS_TICK} axisLine={false} tickLine={false} allowDecimals={false} width={52} />150 <Tooltip151 cursor={{ fill: "var(--bg-muted)" }}152 content={({ active, payload, label }: RechartsTooltipProps) => {153 if (!active || !payload?.length) return null;154 const row = payload[0].payload as SeriesPoint;155 return (156 <ChartTooltipBox157 title={bucketLabel(String(label), hourly, { long: true })}158 rows={[159 { name: "Succeeded", value: formatNumber(row.requests - row.failures), color: "var(--accent)" },160 { name: "Failed", value: formatNumber(row.failures), color: "var(--danger)" },161 ]}162 />163 );164 }}165 />166 {/* stacked: ok (accent) + failed (status danger, only when > 0); 2 px surface gap via stroke */}167 <Bar dataKey={(d: SeriesPoint) => d.requests - d.failures} name="ok" stackId="r" fill="var(--accent)" stroke={SURFACE} strokeWidth={2} radius={hasFailures ? undefined : [4, 4, 0, 0]} isAnimationActive={false} maxBarSize={BAR_MAX} />168 {hasFailures ? <Bar dataKey="failures" stackId="r" fill="var(--danger)" stroke={SURFACE} strokeWidth={2} radius={[4, 4, 0, 0]} isAnimationActive={false} maxBarSize={BAR_MAX} /> : null}169 </BarChart>170 </ResponsiveContainer>171 );172}173174export function TokensOverTime({ data, hourly }: { data: SeriesPoint[]; hourly: boolean }) {175 if (!data.some((d) => d.inputTokens + d.outputTokens > 0)) return <ChartEmpty />;176 return (177 <ResponsiveContainer width="100%" height={H}>178 <BarChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }} barCategoryGap="30%">179 <CartesianGrid vertical={false} stroke={GRID} />180 <XAxis dataKey="bucket" tickFormatter={(v) => bucketLabel(String(v), hourly)} tick={AXIS_TICK} axisLine={false} tickLine={false} minTickGap={28} interval="preserveStartEnd" />181 <YAxis tick={AXIS_TICK} axisLine={false} tickLine={false} tickFormatter={(v) => formatTokens(num(v))} width={56} />182 <Tooltip183 cursor={{ fill: "var(--bg-muted)" }}184 content={({ active, payload, label }: RechartsTooltipProps) => {185 if (!active || !payload?.length) return null;186 const row = payload[0].payload as SeriesPoint;187 return (188 <ChartTooltipBox189 title={bucketLabel(String(label), hourly, { long: true })}190 rows={[191 { name: "Input", value: formatTokens(row.inputTokens), color: "var(--accent)" },192 { name: "Output", value: formatTokens(row.outputTokens), color: "var(--fg)" },193 { name: "Total", value: formatTokens(row.inputTokens + row.outputTokens) },194 ]}195 />196 );197 }}198 />199 <Bar dataKey="inputTokens" stackId="t" fill="var(--accent)" fillOpacity={0.55} stroke={SURFACE} strokeWidth={2} isAnimationActive={false} maxBarSize={BAR_MAX} />200 <Bar dataKey="outputTokens" stackId="t" fill="var(--fg)" fillOpacity={0.85} stroke={SURFACE} strokeWidth={2} radius={[4, 4, 0, 0]} isAnimationActive={false} maxBarSize={BAR_MAX} />201 </BarChart>202 </ResponsiveContainer>203 );204}205206/* ---------- by provider ---------- */207208export interface ProviderSlice {209 provider: string;210 requests: number;211 failures: number;212 costUsd: number;213 inputTokens: number;214 outputTokens: number;215}216217/** Part-to-whole donut (≤ 6 slices, tail folded into "Other") with a labelled list — identity comes from the list, not color alone. */218export function ProviderDonut({ data, metric }: { data: ProviderSlice[]; metric: "requests" | "costUsd" }) {219 const total = data.reduce((a, d) => a + num(d[metric]), 0);220 const sorted = [...data].filter((d) => num(d[metric]) > 0).sort((a, b) => num(b[metric]) - num(a[metric]));221 const shown = sorted.length > 6 ? [...sorted.slice(0, 5), { ...sorted[5], provider: "other", [metric]: sorted.slice(5).reduce((a, d) => a + num(d[metric]), 0) }] : sorted;222 if (!total) return <ChartEmpty>{metric === "costUsd" ? "No priced requests in this period." : "Nothing recorded for this period."}</ChartEmpty>;223 const color = (p: string) => (p === "other" ? "var(--fg-subtle)" : providerColor(p));224 const name = (p: string) => (p === "other" ? "Other" : providerName(p));225 return (226 <div className="flex flex-col items-center gap-3 sm:flex-row">227 <div className="relative size-[168px] shrink-0">228 <ResponsiveContainer width="100%" height="100%">229 <PieChart>230 <Pie data={shown} dataKey={metric} nameKey="provider" innerRadius={56} outerRadius={80} paddingAngle={2} stroke={SURFACE} strokeWidth={2} isAnimationActive={false}>231 {shown.map((d) => (232 <Cell key={d.provider} fill={color(d.provider)} />233 ))}234 </Pie>235 <Tooltip236 content={({ active, payload }: RechartsTooltipProps) => {237 if (!active || !payload?.length) return null;238 const d = payload[0].payload as ProviderSlice;239 const v = num(d[metric]);240 return <ChartTooltipBox title={name(d.provider)} rows={[{ name: metric === "costUsd" ? "Est. cost" : "Requests", value: metric === "costUsd" ? formatUsd(v, { precise: true }) : formatNumber(v), color: color(d.provider) }, { name: "Share", value: `${((v / total) * 100).toFixed(1)}%` }]} />;241 }}242 />243 </PieChart>244 </ResponsiveContainer>245 <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">246 <span className="text-[10px] uppercase tracking-wide text-fg-subtle">{metric === "costUsd" ? "Est. cost" : "Requests"}</span>247 <span className="text-sm font-semibold">{metric === "costUsd" ? formatUsd(total) : formatNumber(total)}</span>248 </div>249 </div>250 <ul className="w-full min-w-0 space-y-1.5 text-xs">251 {shown.map((d) => {252 const v = num(d[metric]);253 const pct = (v / total) * 100;254 return (255 <li key={d.provider} className="flex items-center gap-2">256 {d.provider === "other" ? <span className="size-3 rounded-full bg-fg-subtle" aria-hidden /> : <ProviderIcon provider={d.provider} size={12} />}257 <span className="w-[72px] shrink-0 truncate text-fg">{name(d.provider)}</span>258 <span className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-bg-muted">259 <span className="block h-full rounded-full" style={{ width: `${pct}%`, background: color(d.provider) }} />260 </span>261 <span className="w-14 shrink-0 text-right font-mono tabular-nums text-fg-muted">{metric === "costUsd" ? formatUsd(v) : `${pct.toFixed(0)}%`}</span>262 </li>263 );264 })}265 </ul>266 </div>267 );268}269270/* ---------- by model ---------- */271272export interface ModelRow {273 modelKey: string;274 provider: string;275 label: string;276 requests: number;277 costUsd: number;278 inputTokens: number;279 outputTokens: number;280 tokensPerSec: number | null;281 avgLatencyMs: number;282 avgTtftMs: number | null;283 failures: number;284}285286/** Horizontal bars, one row per model, colored by provider (identity), values labelled at the bar end. */287export function ModelBars({ data, metric, format, max = 8, ascending = false, emptyText }: { data: ModelRow[]; metric: keyof ModelRow; format: (v: number) => string; max?: number; ascending?: boolean; emptyText?: string }) {288 const rows = [...data]289 .filter((d) => num(d[metric]) > 0)290 .sort((a, b) => (ascending ? num(a[metric]) - num(b[metric]) : num(b[metric]) - num(a[metric])))291 .slice(0, max);292 if (rows.length === 0) return <ChartEmpty>{emptyText ?? "Not enough data."}</ChartEmpty>;293 const height = Math.max(120, rows.length * 32 + 12);294 return (295 <ResponsiveContainer width="100%" height={height}>296 <BarChart data={rows} layout="vertical" margin={{ top: 0, right: 64, left: 0, bottom: 0 }} barCategoryGap="30%">297 <CartesianGrid horizontal={false} stroke={GRID} />298 <XAxis type="number" hide />299 <YAxis type="category" dataKey="label" width={124} tick={{ ...AXIS_TICK, fontFamily: "var(--font-sans)", fill: "var(--fg)", fontSize: 12 }} axisLine={false} tickLine={false} interval={0} tickFormatter={(v: string) => (v.length > 17 ? `${v.slice(0, 16)}…` : v)} />300 <Tooltip301 cursor={{ fill: "var(--bg-muted)" }}302 content={({ active, payload }: RechartsTooltipProps) => {303 if (!active || !payload?.length) return null;304 const d = payload[0].payload as ModelRow;305 return (306 <ChartTooltipBox307 title={308 <span className="flex items-center gap-1.5">309 <ProviderIcon provider={d.provider} size={12} /> {d.label}310 </span>311 }312 rows={[313 { name: "Value", value: format(num(d[metric])) },314 { name: "Requests", value: formatNumber(d.requests) },315 { name: "Est. cost", value: formatUsd(d.costUsd, { precise: true }) },316 { name: "Tokens in / out", value: `${formatTokens(d.inputTokens)} / ${formatTokens(d.outputTokens)}` },317 ]}318 />319 );320 }}321 />322 <Bar dataKey={metric} radius={[0, 4, 4, 0]} isAnimationActive={false} maxBarSize={18}>323 {rows.map((d) => (324 <Cell key={d.modelKey} fill={providerColor(d.provider)} />325 ))}326 <LabelList dataKey={metric} position="right" formatter={(v: unknown) => format(num(v))} style={{ fill: TEXT, fontSize: 11, fontFamily: "var(--font-mono)" }} />327 </Bar>328 </BarChart>329 </ResponsiveContainer>330 );331}332333/** Two small multiples (never a dual axis): time to first token (lower is better) and output speed (higher is better). */334export function LatencyByModel({ data, max = 6 }: { data: ModelRow[]; max?: number }) {335 return (336 <div className="grid gap-4 sm:grid-cols-2">337 <div className="min-w-0">338 <p className="mb-1 text-[11px] font-medium uppercase tracking-wide text-fg-subtle">Time to first token · lower is better</p>339 <ModelBars data={data} metric="avgTtftMs" format={(v) => formatMs(v)} max={max} ascending emptyText="No first-token timings yet." />340 </div>341 <div className="min-w-0">342 <p className="mb-1 text-[11px] font-medium uppercase tracking-wide text-fg-subtle">Output speed · higher is better</p>343 <ModelBars data={data} metric="tokensPerSec" format={(v) => `${v.toFixed(0)} tok/s`} max={max} emptyText="No streaming speed measured yet." />344 </div>345 </div>346 );347}348349/** Tiny 12-point sparkline for KPI tiles (de-emphasised hue, no axes). */350export function Sparkline({ values, color = "var(--accent)", height = 28 }: { values: number[]; color?: string; height?: number }) {351 const pts = values.slice(-12);352 if (pts.length < 2 || !pts.some((v) => v > 0)) return null;353 const maxV = Math.max(...pts);354 const w = 72;355 const step = w / (pts.length - 1);356 const d = pts.map((v, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(height - 2 - (v / maxV) * (height - 4)).toFixed(1)}`).join(" ");357 return (358 <svg width={w} height={height} viewBox={`0 0 ${w} ${height}`} className="shrink-0 opacity-70" aria-hidden>359 <path d={d} fill="none" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />360 </svg>361 );362}363