import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Title, Tooltip, } from "chart.js"; import { format } from "date-fns"; import { useMemo, useState } from "react"; import { Line } from "react-chartjs-2"; import type { ModelPerformancePoint, ModelStats, TimeRange } from "../types"; import { useSystemTheme } from "../useSystemTheme"; import { DetailChartEmpty, detailChartPlugins, detailChartScalesDualAxis, ExpandableModelRow, lineSeriesStyle, MiniSparkline, MODEL_COLORS, ModelNameCell, ModelTableBody, ModelTableHeader, ModelTableShell, TABLE_CHART_THEMES, type TableChartTheme, TrendEmpty, } from "./models-table-shared"; import { rangeMeta } from "./range-meta"; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); const GRID_TEMPLATE = "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px"; interface ModelsTableProps { models: ModelStats[]; performanceSeries: ModelPerformancePoint[]; timeRange: TimeRange; } type ModelPerformanceSeries = { label: string; data: Array<{ timestamp: number; avgTtftSeconds: number | null; avgTokensPerSecond: number | null; requests: number; }>; }; export function ModelsTable({ models, performanceSeries, timeRange }: ModelsTableProps) { const [expandedKey, setExpandedKey] = useState(null); const meta = rangeMeta(timeRange); const performanceSeriesByKey = useMemo( () => buildModelPerformanceLookup(performanceSeries, meta.bucketCount, meta.bucketMs), [performanceSeries, meta.bucketCount, meta.bucketMs], ); const theme = useSystemTheme(); const chartTheme = TABLE_CHART_THEMES[theme]; const sortedModels = [...models].sort( (a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens), ); return ( {sortedModels.map((model, index) => { const key = `${model.model}::${model.provider}`; const performance = performanceSeriesByKey.get(key); const trendData = performance?.data ?? []; const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; const isExpanded = expandedKey === key; const errorRate = model.errorRate * 100; return ( setExpandedKey(isExpanded ? null : key)} cells={[ ,
{model.totalRequests.toLocaleString()}
,
${model.totalCost.toFixed(2)}
,
{(model.totalInputTokens + model.totalOutputTokens).toLocaleString()}
,
{model.avgTokensPerSecond?.toFixed(1) ?? "-"}
,
{model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
, ]} trendCell={ trendData.length === 0 ? ( ) : ( d.timestamp)} values={trendData.map(d => d.avgTokensPerSecond ?? 0)} color={trendColor} /> ) } expandedContent={
Quality
Error rate 5 ? "text-[var(--accent-red)]" : "text-[var(--accent-green)]" } > {errorRate.toFixed(1)}%
Cache rate {(model.cacheRate * 100).toFixed(1)}%
Latency
Avg duration {model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"}
Avg TTFT {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
{trendData.length === 0 ? ( ) : ( )}
} /> ); })}
); } function PerformanceChart({ data, color, chartTheme, }: { data: Array<{ timestamp: number; avgTtftSeconds: number | null; avgTokensPerSecond: number | null }>; color: string; chartTheme: TableChartTheme; }) { const chartData = { labels: data.map(d => format(new Date(d.timestamp), "MMM d")), datasets: [ { label: "TTFT", data: data.map(d => d.avgTtftSeconds ?? null), ...lineSeriesStyle("#fbbf24"), yAxisID: "y" as const, }, { label: "Tokens/s", data: data.map(d => d.avgTokensPerSecond ?? null), ...lineSeriesStyle(color), yAxisID: "y1" as const, }, ], }; const options = { responsive: true, maintainAspectRatio: false, plugins: detailChartPlugins(chartTheme), scales: detailChartScalesDualAxis(chartTheme), }; return ; } function buildModelPerformanceLookup( points: ModelPerformancePoint[], bucketCount: number, bucketMs: number, ): Map { const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0); const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / bucketMs) * bucketMs; const uniqueTimestamps = new Set(points.map(p => p.timestamp)); const effectiveCount = bucketCount > 0 ? bucketCount : Math.max(1, uniqueTimestamps.size); const start = anchor - (effectiveCount - 1) * bucketMs; const buckets = Array.from({ length: effectiveCount }, (_, index) => start + index * bucketMs); const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index])); const seriesByKey = new Map(); for (const point of points) { const key = `${point.model}::${point.provider}`; let series = seriesByKey.get(key); if (!series) { series = { label: `${point.model} (${point.provider})`, data: buckets.map(timestamp => ({ timestamp, avgTtftSeconds: null, avgTokensPerSecond: null, requests: 0, })), }; seriesByKey.set(key, series); } const index = bucketIndex.get(point.timestamp); if (index === undefined) continue; series.data[index] = { timestamp: point.timestamp, avgTtftSeconds: point.avgTtft !== null ? point.avgTtft / 1000 : null, avgTokensPerSecond: point.avgTokensPerSecond, requests: point.requests, }; } return seriesByKey; }