@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
@@ -0,0 +1,430 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Line } from "react-chartjs-2";
3
+ import { getModelDashboardStats } from "../api";
4
+ import { CHART_THEMES, MODEL_COLORS } from "../components/chart-shared";
5
+ import {
6
+ DetailChartEmpty,
7
+ detailChartPlugins,
8
+ detailChartScalesDualAxis,
9
+ ExpandableModelRow,
10
+ lineSeriesStyle,
11
+ MiniSparkline,
12
+ ModelNameCell,
13
+ ModelTableBody,
14
+ ModelTableHeader,
15
+ ModelTableShell,
16
+ TABLE_CHART_THEMES,
17
+ type TableChartTheme,
18
+ TrendEmpty,
19
+ } from "../components/models-table-shared";
20
+ import { formatRangeTick, rangeMeta } from "../components/range-meta";
21
+ import { useResource } from "../data/useResource";
22
+ import { buildModelPerformanceLookup } from "../data/view-models";
23
+ import type { ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeRange } from "../types";
24
+ import { AsyncBoundary, Panel } from "../ui";
25
+ import { useSystemTheme } from "../useSystemTheme";
26
+
27
+ export interface ModelsRouteProps {
28
+ active: boolean;
29
+ range: TimeRange;
30
+ refreshTrigger: number;
31
+ }
32
+
33
+ export function ModelsRoute({ active, range, refreshTrigger }: ModelsRouteProps) {
34
+ const {
35
+ data: modelStats,
36
+ error,
37
+ loading,
38
+ } = useResource(["models", range, refreshTrigger], signal => getModelDashboardStats(range, signal), {
39
+ pollMs: 30000,
40
+ enabled: active,
41
+ });
42
+
43
+ return (
44
+ <div className="stats-route-container space-y-6">
45
+ <AsyncBoundary loading={loading} error={error} data={modelStats}>
46
+ {modelStats && (
47
+ <>
48
+ <ModelShareChart modelSeries={modelStats.modelSeries} timeRange={range} />
49
+ <ModelsTable
50
+ models={modelStats.byModel}
51
+ performanceSeries={modelStats.modelPerformanceSeries}
52
+ timeRange={range}
53
+ />
54
+ </>
55
+ )}
56
+ </AsyncBoundary>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ function ModelShareChart({ modelSeries, timeRange }: { modelSeries: ModelTimeSeriesPoint[]; timeRange: TimeRange }) {
62
+ const theme = useSystemTheme();
63
+ const chartTheme = CHART_THEMES[theme];
64
+ const meta = rangeMeta(timeRange);
65
+
66
+ const chartData = useMemo(() => buildModelPreferenceSeries(modelSeries), [modelSeries]);
67
+
68
+ const data = useMemo(() => {
69
+ return {
70
+ labels: chartData.data.map(d => formatRangeTick(d.timestamp, timeRange)),
71
+ datasets: chartData.series.map((seriesName, index) => ({
72
+ label: seriesName,
73
+ data: chartData.data.map(d => d[seriesName] ?? 0),
74
+ borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
75
+ backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`,
76
+ fill: true,
77
+ tension: 0.4,
78
+ pointRadius: 0,
79
+ pointHoverRadius: 4,
80
+ borderWidth: 2,
81
+ })),
82
+ };
83
+ }, [chartData, timeRange]);
84
+
85
+ const options = useMemo(() => {
86
+ return {
87
+ responsive: true,
88
+ maintainAspectRatio: false,
89
+ interaction: {
90
+ mode: "index" as const,
91
+ intersect: false,
92
+ },
93
+ plugins: {
94
+ legend: {
95
+ position: "top" as const,
96
+ align: "start" as const,
97
+ labels: {
98
+ color: chartTheme.legendLabel,
99
+ usePointStyle: true,
100
+ padding: 16,
101
+ font: { size: 12 },
102
+ boxWidth: 8,
103
+ },
104
+ },
105
+ tooltip: {
106
+ backgroundColor: chartTheme.tooltipBackground,
107
+ titleColor: chartTheme.tooltipTitle,
108
+ bodyColor: chartTheme.tooltipBody,
109
+ borderColor: chartTheme.tooltipBorder,
110
+ borderWidth: 1,
111
+ padding: 12,
112
+ cornerRadius: 8,
113
+ callbacks: {
114
+ label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
115
+ const label = context.dataset.label ?? "";
116
+ const value = context.parsed.y;
117
+ return `${label}: ${(value ?? 0).toFixed(1)}%`;
118
+ },
119
+ },
120
+ },
121
+ },
122
+ scales: {
123
+ x: {
124
+ grid: {
125
+ color: chartTheme.grid,
126
+ drawBorder: false,
127
+ },
128
+ ticks: {
129
+ color: chartTheme.tick,
130
+ font: { size: 11 },
131
+ },
132
+ },
133
+ y: {
134
+ grid: {
135
+ color: chartTheme.grid,
136
+ drawBorder: false,
137
+ },
138
+ ticks: {
139
+ color: chartTheme.tick,
140
+ font: { size: 11 },
141
+ callback: (value: number | string) => `${value}%`,
142
+ },
143
+ min: 0,
144
+ max: 100,
145
+ },
146
+ },
147
+ };
148
+ }, [chartTheme]);
149
+
150
+ return (
151
+ <Panel title="Model Preference" subtitle={`Share of requests over ${meta.windowLabel}`}>
152
+ <div className="h-[280px]">
153
+ {chartData.data.length === 0 ? (
154
+ <div className="h-full flex items-center justify-center text-stats-muted text-sm">No data available</div>
155
+ ) : (
156
+ <Line data={data} options={options} />
157
+ )}
158
+ </div>
159
+ </Panel>
160
+ );
161
+ }
162
+
163
+ function buildModelPreferenceSeries(
164
+ points: ModelTimeSeriesPoint[],
165
+ topN = 5,
166
+ ): {
167
+ data: Array<Record<string, number>>;
168
+ series: string[];
169
+ } {
170
+ if (points.length === 0) return { data: [], series: [] };
171
+
172
+ const totals = new Map<string, { model: string; provider: string; total: number }>();
173
+ for (const point of points) {
174
+ const key = `${point.model}::${point.provider}`;
175
+ const existing = totals.get(key);
176
+ if (existing) {
177
+ existing.total += point.requests;
178
+ } else {
179
+ totals.set(key, {
180
+ model: point.model,
181
+ provider: point.provider,
182
+ total: point.requests,
183
+ });
184
+ }
185
+ }
186
+
187
+ const sorted = [...totals.entries()].map(([key, value]) => ({ key, ...value })).sort((a, b) => b.total - a.total);
188
+ const topEntries = sorted.slice(0, topN);
189
+ const topKeys = new Set(topEntries.map(entry => entry.key));
190
+
191
+ const topModelCounts = new Map<string, number>();
192
+ for (const entry of topEntries) {
193
+ topModelCounts.set(entry.model, (topModelCounts.get(entry.model) ?? 0) + 1);
194
+ }
195
+
196
+ const labelByKey = new Map<string, string>();
197
+ for (const entry of topEntries) {
198
+ const showProvider = (topModelCounts.get(entry.model) ?? 0) > 1;
199
+ labelByKey.set(entry.key, showProvider ? `${entry.model} (${entry.provider})` : entry.model);
200
+ }
201
+
202
+ const dataMap = new Map<number, Record<string, number>>();
203
+
204
+ for (const point of points) {
205
+ const key = `${point.model}::${point.provider}`;
206
+ const bucket = dataMap.get(point.timestamp) ?? {
207
+ timestamp: point.timestamp,
208
+ total: 0,
209
+ };
210
+ bucket.total += point.requests;
211
+ const seriesLabel = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other";
212
+ bucket[seriesLabel] = (bucket[seriesLabel] ?? 0) + point.requests;
213
+ dataMap.set(point.timestamp, bucket);
214
+ }
215
+
216
+ const series = topEntries.map(entry => labelByKey.get(entry.key) ?? entry.model);
217
+ if ([...dataMap.values()].some(row => (row.Other ?? 0) > 0)) {
218
+ series.push("Other");
219
+ }
220
+
221
+ const data = [...dataMap.values()]
222
+ .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
223
+ .map(row => {
224
+ const total = row.total ?? 0;
225
+ for (const key of series) {
226
+ row[key] = total > 0 ? ((row[key] ?? 0) / total) * 100 : 0;
227
+ }
228
+ return row;
229
+ });
230
+
231
+ return { data, series };
232
+ }
233
+
234
+ const GRID_TEMPLATE = "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px";
235
+
236
+ function ModelsTable({
237
+ models,
238
+ performanceSeries,
239
+ timeRange,
240
+ }: {
241
+ models: ModelStats[];
242
+ performanceSeries: ModelPerformancePoint[];
243
+ timeRange: TimeRange;
244
+ }) {
245
+ const [expandedKey, setExpandedKey] = useState<string | null>(null);
246
+ const meta = rangeMeta(timeRange);
247
+
248
+ const performanceSeriesByKey = useMemo(
249
+ () => buildModelPerformanceLookup(performanceSeries, timeRange),
250
+ [performanceSeries, timeRange],
251
+ );
252
+
253
+ const theme = useSystemTheme();
254
+ const chartTheme = TABLE_CHART_THEMES[theme];
255
+
256
+ const sortedModels = useMemo(() => {
257
+ return [...models].sort(
258
+ (a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens),
259
+ );
260
+ }, [models]);
261
+
262
+ return (
263
+ <ModelTableShell title="Model Statistics">
264
+ <ModelTableHeader
265
+ gridTemplate={GRID_TEMPLATE}
266
+ columns={[
267
+ { label: "Model" },
268
+ { label: "Requests", align: "right" },
269
+ { label: "Cost", align: "right" },
270
+ { label: "Tokens", align: "right" },
271
+ { label: "Tokens/s", align: "right" },
272
+ { label: "TTFT", align: "right" },
273
+ { label: meta.trendLabel, align: "center" },
274
+ ]}
275
+ />
276
+
277
+ <ModelTableBody>
278
+ {sortedModels.map((model, index) => {
279
+ const key = `${model.model}::${model.provider}`;
280
+ const performance = performanceSeriesByKey.get(key);
281
+ const trendData = performance?.data ?? [];
282
+ const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
283
+ const isExpanded = expandedKey === key;
284
+ const errorRate = model.errorRate * 100;
285
+
286
+ return (
287
+ <ExpandableModelRow
288
+ key={key}
289
+ gridTemplate={GRID_TEMPLATE}
290
+ isExpanded={isExpanded}
291
+ onToggle={() => setExpandedKey(isExpanded ? null : key)}
292
+ cells={[
293
+ <ModelNameCell key="name" model={model.model} provider={model.provider} />,
294
+ <div key="requests" className="text-right text-[var(--text-secondary)] font-mono text-sm">
295
+ {model.totalRequests.toLocaleString()}
296
+ </div>,
297
+ <div key="cost" className="text-right text-[var(--text-secondary)] font-mono text-sm">
298
+ ${model.totalCost.toFixed(2)}
299
+ </div>,
300
+ <div key="tokens" className="text-right text-[var(--text-secondary)] font-mono text-sm">
301
+ {(model.totalInputTokens + model.totalOutputTokens).toLocaleString()}
302
+ </div>,
303
+ <div key="tps" className="text-right text-[var(--text-secondary)] font-mono text-sm">
304
+ {model.avgTokensPerSecond?.toFixed(1) ?? "-"}
305
+ </div>,
306
+ <div key="ttft" className="text-right text-[var(--text-secondary)] font-mono text-sm">
307
+ {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
308
+ </div>,
309
+ ]}
310
+ trendCell={
311
+ trendData.length === 0 ? (
312
+ <TrendEmpty />
313
+ ) : (
314
+ <MiniSparkline
315
+ timestamps={trendData.map(d => d.timestamp)}
316
+ values={trendData.map(d => d.avgTokensPerSecond ?? 0)}
317
+ color={trendColor}
318
+ />
319
+ )
320
+ }
321
+ expandedContent={
322
+ <div className="grid gap-4" style={{ gridTemplateColumns: "200px 1fr" }}>
323
+ <div className="space-y-4 text-sm">
324
+ <div>
325
+ <div className="text-[var(--text-primary)] font-medium mb-2">Quality</div>
326
+ <div className="space-y-1 text-[var(--text-secondary)]">
327
+ <div className="flex items-center justify-between">
328
+ <span>Error rate</span>
329
+ <span
330
+ className={
331
+ errorRate > 5 ? "text-[var(--accent-red)]" : "text-[var(--accent-green)]"
332
+ }
333
+ >
334
+ {errorRate.toFixed(1)}%
335
+ </span>
336
+ </div>
337
+ <div className="flex items-center justify-between">
338
+ <span>Cache rate</span>
339
+ <span className="text-[var(--accent-cyan)]">
340
+ {(model.cacheRate * 100).toFixed(1)}%
341
+ </span>
342
+ </div>
343
+ </div>
344
+ </div>
345
+ <div>
346
+ <div className="text-[var(--text-primary)] font-medium mb-2">Latency</div>
347
+ <div className="space-y-1 text-[var(--text-secondary)]">
348
+ <div className="flex items-center justify-between">
349
+ <span>Avg duration</span>
350
+ <span className="font-mono">
351
+ {model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"}
352
+ </span>
353
+ </div>
354
+ <div className="flex items-center justify-between">
355
+ <span>Avg TTFT</span>
356
+ <span className="font-mono">
357
+ {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
358
+ </span>
359
+ </div>
360
+ </div>
361
+ </div>
362
+ </div>
363
+ <div className="h-[200px]">
364
+ {trendData.length === 0 ? (
365
+ <DetailChartEmpty />
366
+ ) : (
367
+ <PerformanceChart
368
+ data={trendData}
369
+ color={trendColor}
370
+ chartTheme={chartTheme}
371
+ timeRange={timeRange}
372
+ />
373
+ )}
374
+ </div>
375
+ </div>
376
+ }
377
+ />
378
+ );
379
+ })}
380
+ </ModelTableBody>
381
+ </ModelTableShell>
382
+ );
383
+ }
384
+
385
+ function PerformanceChart({
386
+ data,
387
+ color,
388
+ chartTheme,
389
+ timeRange,
390
+ }: {
391
+ data: Array<{
392
+ timestamp: number;
393
+ avgTtftSeconds: number | null;
394
+ avgTokensPerSecond: number | null;
395
+ }>;
396
+ color: string;
397
+ chartTheme: TableChartTheme;
398
+ timeRange: TimeRange;
399
+ }) {
400
+ const chartData = useMemo(() => {
401
+ return {
402
+ labels: data.map(d => formatRangeTick(d.timestamp, timeRange)),
403
+ datasets: [
404
+ {
405
+ label: "TTFT",
406
+ data: data.map(d => d.avgTtftSeconds ?? null),
407
+ ...lineSeriesStyle("#5ad8e6"),
408
+ yAxisID: "y" as const,
409
+ },
410
+ {
411
+ label: "Tokens/s",
412
+ data: data.map(d => d.avgTokensPerSecond ?? null),
413
+ ...lineSeriesStyle(color),
414
+ yAxisID: "y1" as const,
415
+ },
416
+ ],
417
+ };
418
+ }, [data, color, timeRange]);
419
+
420
+ const options = useMemo(() => {
421
+ return {
422
+ responsive: true,
423
+ maintainAspectRatio: false,
424
+ plugins: detailChartPlugins(chartTheme),
425
+ scales: detailChartScalesDualAxis(chartTheme),
426
+ };
427
+ }, [chartTheme]);
428
+
429
+ return <Line data={chartData} options={options} />;
430
+ }