@oh-my-pi/omp-stats 16.0.3 → 16.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/build.ts +11 -0
  3. package/dist/client/index.css +1 -1
  4. package/dist/client/index.html +11 -0
  5. package/dist/client/index.js +108 -108
  6. package/dist/client/styles.css +1070 -631
  7. package/dist/types/client/api.d.ts +19 -10
  8. package/dist/types/client/app/AppLayout.d.ts +16 -0
  9. package/dist/types/client/app/NavRail.d.ts +7 -0
  10. package/dist/types/client/app/RangeControl.d.ts +7 -0
  11. package/dist/types/client/app/SyncButton.d.ts +14 -0
  12. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  13. package/dist/types/client/app/TopBar.d.ts +15 -0
  14. package/dist/types/client/app/routes.d.ts +12 -0
  15. package/dist/types/client/components/chart-shared.d.ts +26 -40
  16. package/dist/types/client/components/models-table-shared.d.ts +20 -40
  17. package/dist/types/client/data/charts.d.ts +1 -0
  18. package/dist/types/client/data/formatters.d.ts +7 -0
  19. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  20. package/dist/types/client/data/useResource.d.ts +13 -0
  21. package/dist/types/client/data/view-models.d.ts +37 -0
  22. package/dist/types/client/index.d.ts +1 -0
  23. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  24. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  25. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  26. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  27. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  28. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  29. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  30. package/dist/types/client/routes/index.d.ts +7 -0
  31. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  32. package/dist/types/client/ui/DataTable.d.ts +17 -0
  33. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  34. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  35. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  36. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  37. package/dist/types/client/ui/Panel.d.ts +7 -0
  38. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  39. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  40. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  41. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  42. package/dist/types/client/ui/index.d.ts +11 -0
  43. package/dist/types/client/useSystemTheme.d.ts +9 -0
  44. package/package.json +4 -4
  45. package/src/aggregator.ts +4 -3
  46. package/src/client/App.tsx +89 -207
  47. package/src/client/api.ts +55 -37
  48. package/src/client/app/AppLayout.tsx +93 -0
  49. package/src/client/app/NavRail.tsx +44 -0
  50. package/src/client/app/RangeControl.tsx +39 -0
  51. package/src/client/app/SyncButton.tsx +75 -0
  52. package/src/client/app/ThemeToggle.tsx +37 -0
  53. package/src/client/app/TopBar.tsx +73 -0
  54. package/src/client/app/routes.ts +50 -0
  55. package/src/client/components/chart-shared.tsx +28 -91
  56. package/src/client/components/models-table-shared.tsx +9 -29
  57. package/src/client/components/range-meta.ts +3 -2
  58. package/src/client/data/charts.ts +14 -0
  59. package/src/client/data/formatters.ts +38 -0
  60. package/src/client/data/useHashRoute.ts +85 -0
  61. package/src/client/data/useResource.ts +154 -0
  62. package/src/client/data/view-models.ts +178 -0
  63. package/src/client/index.tsx +4 -0
  64. package/src/client/routes/BehaviorRoute.tsx +623 -0
  65. package/src/client/routes/CostsRoute.tsx +234 -0
  66. package/src/client/routes/ErrorsRoute.tsx +118 -0
  67. package/src/client/routes/ModelsRoute.tsx +430 -0
  68. package/src/client/routes/OverviewRoute.tsx +332 -0
  69. package/src/client/routes/ProjectsRoute.tsx +163 -0
  70. package/src/client/routes/RequestsRoute.tsx +123 -0
  71. package/src/client/routes/index.ts +7 -0
  72. package/src/client/styles.css +1242 -225
  73. package/src/client/ui/AsyncBoundary.tsx +54 -0
  74. package/src/client/ui/DataTable.tsx +122 -0
  75. package/src/client/ui/EmptyState.tsx +16 -0
  76. package/src/client/ui/ErrorState.tsx +25 -0
  77. package/src/client/ui/JsonBlock.tsx +75 -0
  78. package/src/client/ui/MetricCluster.tsx +67 -0
  79. package/src/client/ui/Panel.tsx +24 -0
  80. package/src/client/ui/RequestDrawer.tsx +208 -0
  81. package/src/client/ui/SegmentedControl.tsx +36 -0
  82. package/src/client/ui/Skeleton.tsx +17 -0
  83. package/src/client/ui/StatusPill.tsx +15 -0
  84. package/src/client/ui/index.ts +11 -0
  85. package/src/client/useSystemTheme.ts +73 -17
  86. package/dist/types/client/components/BehaviorChart.d.ts +0 -6
  87. package/dist/types/client/components/BehaviorModelsTable.d.ts +0 -7
  88. package/dist/types/client/components/BehaviorSummary.d.ts +0 -7
  89. package/dist/types/client/components/ChartsContainer.d.ts +0 -7
  90. package/dist/types/client/components/CostChart.d.ts +0 -6
  91. package/dist/types/client/components/CostSummary.d.ts +0 -6
  92. package/dist/types/client/components/Header.d.ts +0 -12
  93. package/dist/types/client/components/ModelsTable.d.ts +0 -8
  94. package/dist/types/client/components/RequestDetail.d.ts +0 -6
  95. package/dist/types/client/components/RequestList.d.ts +0 -8
  96. package/dist/types/client/components/StatsGrid.d.ts +0 -6
  97. package/src/client/components/BehaviorChart.tsx +0 -189
  98. package/src/client/components/BehaviorModelsTable.tsx +0 -342
  99. package/src/client/components/BehaviorSummary.tsx +0 -95
  100. package/src/client/components/ChartsContainer.tsx +0 -221
  101. package/src/client/components/CostChart.tsx +0 -171
  102. package/src/client/components/CostSummary.tsx +0 -53
  103. package/src/client/components/Header.tsx +0 -72
  104. package/src/client/components/ModelsTable.tsx +0 -265
  105. package/src/client/components/RequestDetail.tsx +0 -172
  106. package/src/client/components/RequestList.tsx +0 -73
  107. package/src/client/components/StatsGrid.tsx +0 -135
@@ -1,171 +0,0 @@
1
- import {
2
- BarElement,
3
- CategoryScale,
4
- Chart as ChartJS,
5
- type ChartOptions,
6
- Filler,
7
- Legend,
8
- LinearScale,
9
- LineElement,
10
- type Plugin,
11
- PointElement,
12
- Title,
13
- Tooltip,
14
- } from "chart.js";
15
- import { useMemo, useState } from "react";
16
- import { Bar, Line } from "react-chartjs-2";
17
- import type { CostTimeSeriesPoint } from "../types";
18
- import { useSystemTheme } from "../useSystemTheme";
19
- import {
20
- barDatasetStyle,
21
- buildAggregateTimeSeries,
22
- buildSharedPlugins,
23
- buildSharedScales,
24
- buildTopNByModelSeries,
25
- CHART_THEMES,
26
- ChartFrame,
27
- type ChartSeries,
28
- lineDatasetStyle,
29
- MODEL_COLORS,
30
- styleDatasets,
31
- } from "./chart-shared";
32
-
33
- ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
34
-
35
- /** Cost bar labels need a per-theme color that the generic chart theme doesn't carry. */
36
- const BAR_LABEL_COLORS = {
37
- dark: "rgba(248, 250, 252, 0.7)",
38
- light: "rgba(15, 23, 42, 0.6)",
39
- } as const;
40
-
41
- interface CostChartProps {
42
- costSeries: CostTimeSeriesPoint[];
43
- }
44
-
45
- /** Inline Chart.js plugin — draws cost value centered above each bar. */
46
- function makeBarLabelPlugin(color: string): Plugin<"bar"> {
47
- return {
48
- id: "costBarLabels",
49
- afterDatasetsDraw(chart) {
50
- const { ctx } = chart;
51
- const dataset = chart.data.datasets[0];
52
- if (!dataset) return;
53
- const meta = chart.getDatasetMeta(0);
54
- ctx.save();
55
- ctx.font = "11px system-ui, sans-serif";
56
- ctx.fillStyle = color;
57
- ctx.textAlign = "center";
58
- ctx.textBaseline = "bottom";
59
- for (const bar of meta.data) {
60
- const value = (bar as unknown as { $context: { parsed: { y: number } } }).$context.parsed.y;
61
- if (!value) continue;
62
- const label = `$${Math.round(value)}`;
63
- const { x, y } = bar.getProps(["x", "y"], true) as { x: number; y: number };
64
- ctx.fillText(label, x, y - 3);
65
- }
66
- ctx.restore();
67
- },
68
- };
69
- }
70
-
71
- function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries {
72
- return buildAggregateTimeSeries<CostTimeSeriesPoint, { total: number }>(points, "Cost", {
73
- initBucket: () => ({ total: 0 }),
74
- accumulate: (bucket, point) => {
75
- bucket.total += point.cost;
76
- },
77
- bucketToValue: bucket => bucket.total,
78
- });
79
- }
80
-
81
- function buildByModelSeries(points: CostTimeSeriesPoint[]): ChartSeries {
82
- // Rank models by total cost; per-day buckets are simple cost sums.
83
- return buildTopNByModelSeries<CostTimeSeriesPoint, { total: number }>(points, {
84
- rankWeight: point => point.cost,
85
- initBucket: () => ({ total: 0 }),
86
- accumulate: (bucket, point) => {
87
- bucket.total += point.cost;
88
- },
89
- bucketToValue: bucket => bucket.total,
90
- });
91
- }
92
-
93
- export function CostChart({ costSeries }: CostChartProps) {
94
- const [byModel, setByModel] = useState(false);
95
- const theme = useSystemTheme();
96
- const chartTheme = CHART_THEMES[theme];
97
-
98
- const chartData = useMemo(
99
- () => (byModel ? buildByModelSeries(costSeries) : buildAggregateSeries(costSeries)),
100
- [costSeries, byModel],
101
- );
102
-
103
- const sharedPlugins = buildSharedPlugins({
104
- chartTheme,
105
- showLegend: byModel,
106
- defaultLabel: "Cost",
107
- formatValue: v => `$${Math.round(v)}`,
108
- footer: items => {
109
- if (!byModel || items.length < 2) return undefined;
110
- const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
111
- return `Total: $${Math.round(total)}`;
112
- },
113
- });
114
-
115
- const { sharedScaleBase, yScale } = buildSharedScales({
116
- chartTheme,
117
- formatY: v => `$${Math.round(v)}`,
118
- });
119
-
120
- let chartNode: React.ReactNode;
121
- if (byModel) {
122
- const lineData = {
123
- labels: chartData.labels,
124
- datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
125
- };
126
-
127
- const lineOptions: ChartOptions<"line"> = {
128
- responsive: true,
129
- maintainAspectRatio: false,
130
- interaction: { mode: "index", intersect: false },
131
- plugins: sharedPlugins,
132
- scales: { x: sharedScaleBase, y: yScale },
133
- };
134
-
135
- chartNode = <Line data={lineData} options={lineOptions} />;
136
- } else {
137
- const barData = {
138
- labels: chartData.labels,
139
- datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
140
- };
141
-
142
- const barLabelPlugin = makeBarLabelPlugin(BAR_LABEL_COLORS[theme]);
143
-
144
- const barOptions: ChartOptions<"bar"> = {
145
- responsive: true,
146
- maintainAspectRatio: false,
147
- interaction: { mode: "index", intersect: false },
148
- plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"],
149
- scales: {
150
- x: { ...sharedScaleBase, stacked: true },
151
- y: { ...yScale, stacked: true },
152
- },
153
- layout: { padding: { top: 24 } },
154
- };
155
-
156
- chartNode = <Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />;
157
- }
158
-
159
- return (
160
- <ChartFrame
161
- title="Daily Cost"
162
- subtitle="API spending over time"
163
- empty={chartData.labels.length === 0}
164
- emptyMessage="No cost data available"
165
- byModel={byModel}
166
- onByModelChange={setByModel}
167
- >
168
- {chartNode}
169
- </ChartFrame>
170
- );
171
- }
@@ -1,53 +0,0 @@
1
- import type { CostTimeSeriesPoint } from "../types";
2
-
3
- interface CostSummaryProps {
4
- costSeries: CostTimeSeriesPoint[];
5
- }
6
-
7
- function formatCost(value: number): string {
8
- return `$${Math.round(value)}`;
9
- }
10
-
11
- export function CostSummary({ costSeries }: CostSummaryProps) {
12
- const totalCost = costSeries.reduce((sum, p) => sum + p.cost, 0);
13
- const dayBuckets = new Set(costSeries.map(p => p.timestamp)).size;
14
- const avgDaily = dayBuckets > 0 ? totalCost / dayBuckets : 0;
15
-
16
- // Most expensive model over the visible window
17
- const modelTotals = new Map<string, number>();
18
- for (const point of costSeries) {
19
- modelTotals.set(point.model, (modelTotals.get(point.model) ?? 0) + point.cost);
20
- }
21
- let topModel = "";
22
- let topModelCost = 0;
23
- for (const [model, cost] of modelTotals) {
24
- if (cost > topModelCost) {
25
- topModel = model;
26
- topModelCost = cost;
27
- }
28
- }
29
-
30
- const cards = [
31
- { label: "Total", value: formatCost(totalCost) },
32
- { label: "Avg / day", value: formatCost(avgDaily) },
33
- {
34
- label: "Top model",
35
- value: topModel || "—",
36
- sub: topModel ? formatCost(topModelCost) : undefined,
37
- },
38
- ];
39
-
40
- return (
41
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
42
- {cards.map(card => (
43
- <div key={card.label} className="surface px-4 py-3">
44
- <p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
45
- <p className="text-lg font-semibold text-[var(--text-primary)] truncate" title={card.value}>
46
- {card.value}
47
- </p>
48
- {card.sub && <p className="text-xs text-[var(--text-muted)] mt-0.5">{card.sub}</p>}
49
- </div>
50
- ))}
51
- </div>
52
- );
53
- }
@@ -1,72 +0,0 @@
1
- import { Activity, RefreshCw } from "lucide-react";
2
- import type { TimeRange } from "../types";
3
-
4
- type Tab = "overview" | "requests" | "errors" | "models" | "costs" | "behavior";
5
-
6
- const tabs: Tab[] = ["overview", "requests", "errors", "models", "costs", "behavior"];
7
- const timeRanges: { label: string; value: TimeRange }[] = [
8
- { label: "1h", value: "1h" },
9
- { label: "24h", value: "24h" },
10
- { label: "7d", value: "7d" },
11
- { label: "30d", value: "30d" },
12
- { label: "90d", value: "90d" },
13
- { label: "All", value: "all" },
14
- ];
15
-
16
- interface HeaderProps {
17
- activeTab: Tab;
18
- onTabChange: (tab: Tab) => void;
19
- onSync: () => void;
20
- syncing: boolean;
21
- timeRange: TimeRange;
22
- onTimeRangeChange: (timeRange: TimeRange) => void;
23
- }
24
-
25
- export function Header({ activeTab, onTabChange, onSync, syncing, timeRange, onTimeRangeChange }: HeaderProps) {
26
- return (
27
- <header className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 mb-8 border-b border-[var(--border-subtle)]">
28
- <div className="flex items-center gap-3">
29
- <div className="w-10 h-10 rounded-[var(--radius-md)] bg-gradient-to-br from-[var(--accent-pink)] to-[var(--accent-cyan)] flex items-center justify-center shadow-lg">
30
- <Activity className="w-5 h-5 text-white" />
31
- </div>
32
- <div>
33
- <h1 className="text-xl font-semibold text-[var(--text-primary)]">AI Usage</h1>
34
- <p className="text-sm text-[var(--text-muted)]">Statistics & Analytics</p>
35
- </div>
36
- </div>
37
-
38
- <div className="flex items-center gap-3">
39
- <div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-md)] p-1 border border-[var(--border-subtle)]">
40
- {tabs.map(tab => (
41
- <button
42
- key={tab}
43
- type="button"
44
- onClick={() => onTabChange(tab)}
45
- className={`tab-btn capitalize ${activeTab === tab ? "active" : ""}`}
46
- >
47
- {tab}
48
- </button>
49
- ))}
50
- </div>
51
- <div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-md)] p-1 border border-[var(--border-subtle)]">
52
- {timeRanges.map(range => (
53
- <button
54
- key={range.value}
55
- type="button"
56
- onClick={() => onTimeRangeChange(range.value)}
57
- className={`tab-btn ${timeRange === range.value ? "active" : ""}`}
58
- title={range.value === "all" ? "All time" : `Last ${range.label}`}
59
- >
60
- {range.label}
61
- </button>
62
- ))}
63
- </div>
64
-
65
- <button type="button" onClick={onSync} disabled={syncing} className="btn btn-primary">
66
- <RefreshCw size={16} className={syncing ? "spin" : ""} />
67
- {syncing ? "Syncing..." : "Sync"}
68
- </button>
69
- </div>
70
- </header>
71
- );
72
- }
@@ -1,265 +0,0 @@
1
- import {
2
- CategoryScale,
3
- Chart as ChartJS,
4
- Legend,
5
- LinearScale,
6
- LineElement,
7
- PointElement,
8
- Title,
9
- Tooltip,
10
- } from "chart.js";
11
- import { format } from "date-fns";
12
- import { useMemo, useState } from "react";
13
- import { Line } from "react-chartjs-2";
14
- import type { ModelPerformancePoint, ModelStats, TimeRange } from "../types";
15
- import { useSystemTheme } from "../useSystemTheme";
16
- import {
17
- DetailChartEmpty,
18
- detailChartPlugins,
19
- detailChartScalesDualAxis,
20
- ExpandableModelRow,
21
- lineSeriesStyle,
22
- MiniSparkline,
23
- MODEL_COLORS,
24
- ModelNameCell,
25
- ModelTableBody,
26
- ModelTableHeader,
27
- ModelTableShell,
28
- TABLE_CHART_THEMES,
29
- type TableChartTheme,
30
- TrendEmpty,
31
- } from "./models-table-shared";
32
- import { rangeMeta } from "./range-meta";
33
-
34
- ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
35
-
36
- const GRID_TEMPLATE = "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px";
37
-
38
- interface ModelsTableProps {
39
- models: ModelStats[];
40
- performanceSeries: ModelPerformancePoint[];
41
- timeRange: TimeRange;
42
- }
43
-
44
- type ModelPerformanceSeries = {
45
- label: string;
46
- data: Array<{
47
- timestamp: number;
48
- avgTtftSeconds: number | null;
49
- avgTokensPerSecond: number | null;
50
- requests: number;
51
- }>;
52
- };
53
-
54
- export function ModelsTable({ models, performanceSeries, timeRange }: ModelsTableProps) {
55
- const [expandedKey, setExpandedKey] = useState<string | null>(null);
56
- const meta = rangeMeta(timeRange);
57
-
58
- const performanceSeriesByKey = useMemo(
59
- () => buildModelPerformanceLookup(performanceSeries, meta.bucketCount, meta.bucketMs),
60
- [performanceSeries, meta.bucketCount, meta.bucketMs],
61
- );
62
- const theme = useSystemTheme();
63
- const chartTheme = TABLE_CHART_THEMES[theme];
64
- const sortedModels = [...models].sort(
65
- (a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens),
66
- );
67
-
68
- return (
69
- <ModelTableShell title="Model Statistics">
70
- <ModelTableHeader
71
- gridTemplate={GRID_TEMPLATE}
72
- columns={[
73
- { label: "Model" },
74
- { label: "Requests", align: "right" },
75
- { label: "Cost", align: "right" },
76
- { label: "Tokens", align: "right" },
77
- { label: "Tokens/s", align: "right" },
78
- { label: "TTFT", align: "right" },
79
- { label: meta.trendLabel, align: "center" },
80
- ]}
81
- />
82
-
83
- <ModelTableBody>
84
- {sortedModels.map((model, index) => {
85
- const key = `${model.model}::${model.provider}`;
86
- const performance = performanceSeriesByKey.get(key);
87
- const trendData = performance?.data ?? [];
88
- const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
89
- const isExpanded = expandedKey === key;
90
- const errorRate = model.errorRate * 100;
91
-
92
- return (
93
- <ExpandableModelRow
94
- key={key}
95
- gridTemplate={GRID_TEMPLATE}
96
- isExpanded={isExpanded}
97
- onToggle={() => setExpandedKey(isExpanded ? null : key)}
98
- cells={[
99
- <ModelNameCell key="name" model={model.model} provider={model.provider} />,
100
- <div key="requests" className="text-right text-[var(--text-secondary)] font-mono text-sm">
101
- {model.totalRequests.toLocaleString()}
102
- </div>,
103
- <div key="cost" className="text-right text-[var(--text-secondary)] font-mono text-sm">
104
- ${model.totalCost.toFixed(2)}
105
- </div>,
106
- <div key="tokens" className="text-right text-[var(--text-secondary)] font-mono text-sm">
107
- {(model.totalInputTokens + model.totalOutputTokens).toLocaleString()}
108
- </div>,
109
- <div key="tps" className="text-right text-[var(--text-secondary)] font-mono text-sm">
110
- {model.avgTokensPerSecond?.toFixed(1) ?? "-"}
111
- </div>,
112
- <div key="ttft" className="text-right text-[var(--text-secondary)] font-mono text-sm">
113
- {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
114
- </div>,
115
- ]}
116
- trendCell={
117
- trendData.length === 0 ? (
118
- <TrendEmpty />
119
- ) : (
120
- <MiniSparkline
121
- timestamps={trendData.map(d => d.timestamp)}
122
- values={trendData.map(d => d.avgTokensPerSecond ?? 0)}
123
- color={trendColor}
124
- />
125
- )
126
- }
127
- expandedContent={
128
- <div className="grid gap-4" style={{ gridTemplateColumns: "200px 1fr" }}>
129
- <div className="space-y-4 text-sm">
130
- <div>
131
- <div className="text-[var(--text-primary)] font-medium mb-2">Quality</div>
132
- <div className="space-y-1 text-[var(--text-secondary)]">
133
- <div className="flex items-center justify-between">
134
- <span>Error rate</span>
135
- <span
136
- className={
137
- errorRate > 5 ? "text-[var(--accent-red)]" : "text-[var(--accent-green)]"
138
- }
139
- >
140
- {errorRate.toFixed(1)}%
141
- </span>
142
- </div>
143
- <div className="flex items-center justify-between">
144
- <span>Cache rate</span>
145
- <span className="text-[var(--accent-cyan)]">
146
- {(model.cacheRate * 100).toFixed(1)}%
147
- </span>
148
- </div>
149
- </div>
150
- </div>
151
- <div>
152
- <div className="text-[var(--text-primary)] font-medium mb-2">Latency</div>
153
- <div className="space-y-1 text-[var(--text-secondary)]">
154
- <div className="flex items-center justify-between">
155
- <span>Avg duration</span>
156
- <span className="font-mono">
157
- {model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"}
158
- </span>
159
- </div>
160
- <div className="flex items-center justify-between">
161
- <span>Avg TTFT</span>
162
- <span className="font-mono">
163
- {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
164
- </span>
165
- </div>
166
- </div>
167
- </div>
168
- </div>
169
- <div className="h-[200px]">
170
- {trendData.length === 0 ? (
171
- <DetailChartEmpty />
172
- ) : (
173
- <PerformanceChart data={trendData} color={trendColor} chartTheme={chartTheme} />
174
- )}
175
- </div>
176
- </div>
177
- }
178
- />
179
- );
180
- })}
181
- </ModelTableBody>
182
- </ModelTableShell>
183
- );
184
- }
185
-
186
- function PerformanceChart({
187
- data,
188
- color,
189
- chartTheme,
190
- }: {
191
- data: Array<{ timestamp: number; avgTtftSeconds: number | null; avgTokensPerSecond: number | null }>;
192
- color: string;
193
- chartTheme: TableChartTheme;
194
- }) {
195
- const chartData = {
196
- labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
197
- datasets: [
198
- {
199
- label: "TTFT",
200
- data: data.map(d => d.avgTtftSeconds ?? null),
201
- ...lineSeriesStyle("#fbbf24"),
202
- yAxisID: "y" as const,
203
- },
204
- {
205
- label: "Tokens/s",
206
- data: data.map(d => d.avgTokensPerSecond ?? null),
207
- ...lineSeriesStyle(color),
208
- yAxisID: "y1" as const,
209
- },
210
- ],
211
- };
212
-
213
- const options = {
214
- responsive: true,
215
- maintainAspectRatio: false,
216
- plugins: detailChartPlugins(chartTheme),
217
- scales: detailChartScalesDualAxis(chartTheme),
218
- };
219
-
220
- return <Line data={chartData} options={options} />;
221
- }
222
-
223
- function buildModelPerformanceLookup(
224
- points: ModelPerformancePoint[],
225
- bucketCount: number,
226
- bucketMs: number,
227
- ): Map<string, ModelPerformanceSeries> {
228
- const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0);
229
- const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / bucketMs) * bucketMs;
230
- const uniqueTimestamps = new Set(points.map(p => p.timestamp));
231
- const effectiveCount = bucketCount > 0 ? bucketCount : Math.max(1, uniqueTimestamps.size);
232
- const start = anchor - (effectiveCount - 1) * bucketMs;
233
- const buckets = Array.from({ length: effectiveCount }, (_, index) => start + index * bucketMs);
234
- const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index]));
235
- const seriesByKey = new Map<string, ModelPerformanceSeries>();
236
-
237
- for (const point of points) {
238
- const key = `${point.model}::${point.provider}`;
239
- let series = seriesByKey.get(key);
240
- if (!series) {
241
- series = {
242
- label: `${point.model} (${point.provider})`,
243
- data: buckets.map(timestamp => ({
244
- timestamp,
245
- avgTtftSeconds: null,
246
- avgTokensPerSecond: null,
247
- requests: 0,
248
- })),
249
- };
250
- seriesByKey.set(key, series);
251
- }
252
-
253
- const index = bucketIndex.get(point.timestamp);
254
- if (index === undefined) continue;
255
-
256
- series.data[index] = {
257
- timestamp: point.timestamp,
258
- avgTtftSeconds: point.avgTtft !== null ? point.avgTtft / 1000 : null,
259
- avgTokensPerSecond: point.avgTokensPerSecond,
260
- requests: point.requests,
261
- };
262
- }
263
-
264
- return seriesByKey;
265
- }