SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
18.5 KB · 363 lines tsx
Raw Blame History
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