@oh-my-pi/omp-stats 12.1.0 → 12.2.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/omp-stats",
3
- "version": "12.1.0",
3
+ "version": "12.2.0",
4
4
  "description": "Local observability dashboard for pi AI usage statistics",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -47,21 +47,28 @@
47
47
  "url": "git+https://github.com/can1357/oh-my-pi.git",
48
48
  "directory": "packages/stats"
49
49
  },
50
+ "homepage": "https://github.com/can1357/oh-my-pi",
51
+ "bugs": {
52
+ "url": "https://github.com/can1357/oh-my-pi/issues"
53
+ },
50
54
  "dependencies": {
51
- "@oh-my-pi/pi-ai": "12.1.0",
55
+ "@oh-my-pi/pi-ai": "12.2.0",
56
+ "@oh-my-pi/pi-utils": "12.2.0",
57
+ "chart.js": "4.5.1",
52
58
  "date-fns": "^4.1.0",
53
59
  "lucide-react": "^0.563.0",
54
60
  "react": "^19.2.4",
55
- "react-dom": "^19.2.4",
56
- "recharts": "^3.7.0",
57
- "@oh-my-pi/pi-utils": "12.1.0"
61
+ "react-chartjs-2": "5.3.1",
62
+ "react-dom": "^19.2.4"
58
63
  },
59
64
  "devDependencies": {
60
65
  "@types/bun": "^1.3.9",
61
66
  "@types/react": "^19.2.10",
62
- "@types/react-dom": "^19.2.3"
67
+ "@types/react-dom": "^19.2.3",
68
+ "postcss": "8.5.6",
69
+ "tailwindcss": "3"
63
70
  },
64
71
  "engines": {
65
72
  "bun": ">=1.3.7"
66
73
  }
67
- }
74
+ }
@@ -1,381 +1,14 @@
1
- import { format } from "date-fns";
2
- import { Activity, AlertCircle, BarChart2, ChevronDown, ChevronUp, Database, RefreshCw, Server } from "lucide-react";
3
- import type { ReactNode } from "react";
4
1
  import { useCallback, useEffect, useState } from "react";
5
- import {
6
- Area,
7
- AreaChart,
8
- CartesianGrid,
9
- Legend,
10
- Line,
11
- LineChart,
12
- ResponsiveContainer,
13
- Tooltip,
14
- XAxis,
15
- YAxis,
16
- } from "recharts";
17
2
  import { getRecentErrors, getRecentRequests, getStats, sync } from "./api";
3
+ import { ChartsContainer } from "./components/ChartsContainer";
4
+ import { Header } from "./components/Header";
5
+ import { ModelsTable } from "./components/ModelsTable";
18
6
  import { RequestDetail } from "./components/RequestDetail";
19
7
  import { RequestList } from "./components/RequestList";
20
- import { StatCard } from "./components/StatCard";
21
- import type { DashboardStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint } from "./types";
8
+ import { StatsGrid } from "./components/StatsGrid";
9
+ import type { DashboardStats, MessageStats } from "./types";
22
10
 
23
- const MODEL_COLORS = ["#60a5fa", "#34d399", "#fbbf24", "#f87171", "#a78bfa", "#38bdf8", "#f472b6"];
24
-
25
- function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) {
26
- return (
27
- <div
28
- style={{
29
- background: "var(--bg-secondary)",
30
- borderRadius: "12px",
31
- border: "1px solid var(--border)",
32
- overflow: "hidden",
33
- height: "100%",
34
- display: "flex",
35
- flexDirection: "column",
36
- }}
37
- >
38
- <div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
39
- <div style={{ fontSize: "1rem", fontWeight: 600 }}>{title}</div>
40
- {subtitle ? <div style={{ color: "var(--text-secondary)", fontSize: "0.8rem" }}>{subtitle}</div> : null}
41
- </div>
42
- <div style={{ flex: 1, padding: "12px 16px" }}>{children}</div>
43
- </div>
44
- );
45
- }
46
-
47
- function formatDateTick(timestamp: number): string {
48
- return format(new Date(timestamp), "MMM d");
49
- }
50
-
51
- function buildModelPreferenceSeries(
52
- points: ModelTimeSeriesPoint[],
53
- topN = 5,
54
- ): {
55
- data: Array<Record<string, number>>;
56
- series: string[];
57
- } {
58
- if (points.length === 0) return { data: [], series: [] };
59
-
60
- const totals = new Map<string, { label: string; total: number }>();
61
- for (const point of points) {
62
- const key = `${point.model}::${point.provider}`;
63
- const label = `${point.model} (${point.provider})`;
64
- const existing = totals.get(key);
65
- if (existing) {
66
- existing.total += point.requests;
67
- } else {
68
- totals.set(key, { label, total: point.requests });
69
- }
70
- }
71
-
72
- const sorted = [...totals.values()].sort((a, b) => b.total - a.total);
73
- const topLabels = sorted.slice(0, topN).map(entry => entry.label);
74
- const dataMap = new Map<number, Record<string, number>>();
75
-
76
- for (const point of points) {
77
- const label = `${point.model} (${point.provider})`;
78
- const bucket = dataMap.get(point.timestamp) ?? { timestamp: point.timestamp, total: 0 };
79
- bucket.total += point.requests;
80
- const key = topLabels.includes(label) ? label : "Other";
81
- bucket[key] = (bucket[key] ?? 0) + point.requests;
82
- dataMap.set(point.timestamp, bucket);
83
- }
84
-
85
- const series = [...topLabels];
86
- if ([...dataMap.values()].some(row => (row.Other ?? 0) > 0)) {
87
- series.push("Other");
88
- }
89
-
90
- const data = [...dataMap.values()]
91
- .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
92
- .map(row => {
93
- const total = row.total ?? 0;
94
- for (const key of series) {
95
- row[key] = total > 0 ? ((row[key] ?? 0) / total) * 100 : 0;
96
- }
97
- return row;
98
- });
99
-
100
- return { data, series };
101
- }
102
-
103
- type ModelPerformanceSeries = {
104
- label: string;
105
- data: Array<{
106
- timestamp: number;
107
- avgTtftSeconds: number | null;
108
- avgTokensPerSecond: number | null;
109
- requests: number;
110
- }>;
111
- };
112
-
113
- function buildModelPerformanceLookup(
114
- points: ModelPerformancePoint[],
115
- days = 14,
116
- ): { buckets: number[]; seriesByKey: Map<string, ModelPerformanceSeries> } {
117
- const dayMs = 24 * 60 * 60 * 1000;
118
- const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0);
119
- const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / dayMs) * dayMs;
120
- const start = anchor - (days - 1) * dayMs;
121
- const buckets = Array.from({ length: days }, (_, index) => start + index * dayMs);
122
- const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index]));
123
- const seriesByKey = new Map<string, ModelPerformanceSeries>();
124
-
125
- for (const point of points) {
126
- const key = `${point.model}::${point.provider}`;
127
- let series = seriesByKey.get(key);
128
- if (!series) {
129
- series = {
130
- label: `${point.model} (${point.provider})`,
131
- data: buckets.map(timestamp => ({
132
- timestamp,
133
- avgTtftSeconds: null,
134
- avgTokensPerSecond: null,
135
- requests: 0,
136
- })),
137
- };
138
- seriesByKey.set(key, series);
139
- }
140
-
141
- const index = bucketIndex.get(point.timestamp);
142
- if (index === undefined) continue;
143
-
144
- series.data[index] = {
145
- timestamp: point.timestamp,
146
- avgTtftSeconds: point.avgTtft !== null ? point.avgTtft / 1000 : null,
147
- avgTokensPerSecond: point.avgTokensPerSecond,
148
- requests: point.requests,
149
- };
150
- }
151
-
152
- return { buckets, seriesByKey };
153
- }
154
-
155
- function ModelStatsTable({
156
- models,
157
- performanceSeriesByKey,
158
- }: {
159
- models: ModelStats[];
160
- performanceSeriesByKey: Map<string, ModelPerformanceSeries>;
161
- }) {
162
- const [expandedKey, setExpandedKey] = useState<string | null>(null);
163
- const sortedModels = [...models].sort(
164
- (a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens),
165
- );
166
-
167
- return (
168
- <div
169
- style={{
170
- background: "var(--bg-secondary)",
171
- borderRadius: "12px",
172
- border: "1px solid var(--border)",
173
- overflow: "hidden",
174
- }}
175
- >
176
- <div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
177
- <h3 style={{ margin: 0, fontSize: "1rem" }}>Model Statistics</h3>
178
- </div>
179
- <div>
180
- <div
181
- style={{
182
- display: "grid",
183
- gridTemplateColumns: "2.4fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 160px 32px",
184
- gap: "12px",
185
- padding: "12px 20px",
186
- color: "var(--text-secondary)",
187
- fontSize: "0.75rem",
188
- textTransform: "uppercase",
189
- letterSpacing: "0.04em",
190
- }}
191
- >
192
- <div>Model</div>
193
- <div style={{ textAlign: "right" }}>Requests</div>
194
- <div style={{ textAlign: "right" }}>Cost</div>
195
- <div style={{ textAlign: "right" }}>Tokens</div>
196
- <div style={{ textAlign: "right" }}>Tokens/s</div>
197
- <div style={{ textAlign: "right" }}>TTFT</div>
198
- <div style={{ textAlign: "center" }}>14d Trend</div>
199
- <div />
200
- </div>
201
- <div style={{ maxHeight: "calc(100vh - 260px)", overflowY: "auto" }}>
202
- {sortedModels.map((model, index) => {
203
- const key = `${model.model}::${model.provider}`;
204
- const performance = performanceSeriesByKey.get(key);
205
- const trendData = performance?.data ?? [];
206
- const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
207
- const isExpanded = expandedKey === key;
208
-
209
- return (
210
- <div key={key} style={{ borderTop: "1px solid var(--border)" }}>
211
- <button
212
- type="button"
213
- onClick={() => setExpandedKey(isExpanded ? null : key)}
214
- style={{
215
- width: "100%",
216
- background: "transparent",
217
- border: "none",
218
- color: "inherit",
219
- padding: "12px 20px",
220
- textAlign: "left",
221
- cursor: "pointer",
222
- }}
223
- >
224
- <div
225
- style={{
226
- display: "grid",
227
- gridTemplateColumns: "2.4fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 160px 32px",
228
- gap: "12px",
229
- alignItems: "center",
230
- }}
231
- >
232
- <div>
233
- <div style={{ fontWeight: 600 }}>{model.model}</div>
234
- <div style={{ color: "var(--text-secondary)", fontSize: "0.8rem" }}>
235
- {model.provider}
236
- </div>
237
- </div>
238
- <div style={{ textAlign: "right" }}>{model.totalRequests.toLocaleString()}</div>
239
- <div style={{ textAlign: "right" }}>${model.totalCost.toFixed(2)}</div>
240
- <div style={{ textAlign: "right" }}>
241
- {(model.totalInputTokens + model.totalOutputTokens).toLocaleString()}
242
- </div>
243
- <div style={{ textAlign: "right" }}>{model.avgTokensPerSecond?.toFixed(1) ?? "-"}</div>
244
- <div style={{ textAlign: "right" }}>
245
- {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
246
- </div>
247
- <div style={{ height: 40 }}>
248
- {trendData.length === 0 ? (
249
- <div style={{ color: "var(--text-secondary)", textAlign: "center" }}>-</div>
250
- ) : (
251
- <ResponsiveContainer width="100%" height="100%">
252
- <LineChart data={trendData}>
253
- <Line
254
- type="monotone"
255
- dataKey="avgTokensPerSecond"
256
- stroke={trendColor}
257
- strokeWidth={2}
258
- dot={false}
259
- />
260
- </LineChart>
261
- </ResponsiveContainer>
262
- )}
263
- </div>
264
- <div style={{ display: "flex", justifyContent: "center" }}>
265
- {isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
266
- </div>
267
- </div>
268
- </button>
269
- {isExpanded && (
270
- <div
271
- style={{
272
- padding: "16px 20px 20px",
273
- background: "rgba(0,0,0,0.2)",
274
- }}
275
- >
276
- <div
277
- style={{
278
- display: "grid",
279
- gridTemplateColumns: "240px 1fr",
280
- gap: "16px",
281
- alignItems: "stretch",
282
- }}
283
- >
284
- <div style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
285
- <div style={{ marginBottom: "8px" }}>
286
- <div style={{ color: "var(--text-primary)", fontWeight: 600 }}>Quality</div>
287
- <div>Errors: {(model.errorRate * 100).toFixed(1)}%</div>
288
- <div>Cache rate: {(model.cacheRate * 100).toFixed(1)}%</div>
289
- </div>
290
- <div>
291
- <div style={{ color: "var(--text-primary)", fontWeight: 600 }}>Latency</div>
292
- <div>
293
- Avg duration:{" "}
294
- {model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"}
295
- </div>
296
- <div>
297
- Avg TTFT: {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
298
- </div>
299
- </div>
300
- </div>
301
- <div style={{ height: 180 }}>
302
- {trendData.length === 0 ? (
303
- <div
304
- style={{
305
- color: "var(--text-secondary)",
306
- textAlign: "center",
307
- paddingTop: "40px",
308
- }}
309
- >
310
- No data yet
311
- </div>
312
- ) : (
313
- <ResponsiveContainer width="100%" height="100%">
314
- <LineChart data={trendData} margin={{ left: 4, right: 8, top: 8, bottom: 4 }}>
315
- <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
316
- <XAxis
317
- dataKey="timestamp"
318
- tickFormatter={formatDateTick}
319
- stroke="var(--text-secondary)"
320
- />
321
- <YAxis
322
- yAxisId="left"
323
- stroke="var(--text-secondary)"
324
- tickFormatter={value => `${value}s`}
325
- />
326
- <YAxis
327
- yAxisId="right"
328
- orientation="right"
329
- stroke="var(--text-secondary)"
330
- />
331
- <Tooltip
332
- labelFormatter={(label: ReactNode) =>
333
- typeof label === "number" ? formatDateTick(label) : ""
334
- }
335
- formatter={(
336
- value: number | string | undefined,
337
- name: string | undefined,
338
- ) => {
339
- const numericValue = value ?? 0;
340
- if (name === "avgTtftSeconds")
341
- return [`${Number(numericValue).toFixed(2)}s`, "TTFT"];
342
- return [`${Number(numericValue).toFixed(1)}`, "Tokens/s"];
343
- }}
344
- />
345
- <Legend
346
- formatter={value => (value === "avgTtftSeconds" ? "TTFT" : "Tokens/s")}
347
- />
348
- <Line
349
- yAxisId="left"
350
- type="monotone"
351
- dataKey="avgTtftSeconds"
352
- stroke="#fbbf24"
353
- strokeWidth={2}
354
- dot={false}
355
- />
356
- <Line
357
- yAxisId="right"
358
- type="monotone"
359
- dataKey="avgTokensPerSecond"
360
- stroke={trendColor}
361
- strokeWidth={2}
362
- dot={false}
363
- />
364
- </LineChart>
365
- </ResponsiveContainer>
366
- )}
367
- </div>
368
- </div>
369
- </div>
370
- )}
371
- </div>
372
- );
373
- })}
374
- </div>
375
- </div>
376
- </div>
377
- );
378
- }
11
+ type Tab = "overview" | "requests" | "errors" | "models";
379
12
 
380
13
  export default function App() {
381
14
  const [stats, setStats] = useState<DashboardStats | null>(null);
@@ -383,7 +16,7 @@ export default function App() {
383
16
  const [recentErrors, setRecentErrors] = useState<MessageStats[]>([]);
384
17
  const [selectedRequest, setSelectedRequest] = useState<number | null>(null);
385
18
  const [syncing, setSyncing] = useState(false);
386
- const [activeTab, setActiveTab] = useState<"overview" | "requests" | "errors" | "models">("overview");
19
+ const [activeTab, setActiveTab] = useState<Tab>("overview");
387
20
 
388
21
  const loadData = useCallback(async () => {
389
22
  try {
@@ -412,202 +45,72 @@ export default function App() {
412
45
  return () => clearInterval(interval);
413
46
  }, [loadData]);
414
47
 
415
- if (!stats) return <div style={{ padding: 40, textAlign: "center" }}>Loading stats...</div>;
416
-
417
- const { seriesByKey: performanceSeriesByKey } = buildModelPerformanceLookup(stats.modelPerformanceSeries);
418
- const modelPreference = buildModelPreferenceSeries(stats.modelSeries);
419
-
420
- return (
421
- <div style={{ maxWidth: "1400px", margin: "0 auto", padding: "20px" }}>
422
- <header
423
- style={{
424
- display: "flex",
425
- justifyContent: "space-between",
426
- alignItems: "center",
427
- marginBottom: "30px",
428
- paddingBottom: "20px",
429
- borderBottom: "1px solid var(--border)",
430
- }}
431
- >
432
- <h1 style={{ margin: 0, fontSize: "1.5rem", display: "flex", alignItems: "center", gap: "10px" }}>
433
- <Activity color="var(--accent)" />
434
- AI Usage Statistics
435
- </h1>
436
- <div style={{ display: "flex", gap: "15px", alignItems: "center" }}>
437
- <div style={{ display: "flex", background: "var(--bg-secondary)", borderRadius: "6px", padding: "4px" }}>
438
- {(["overview", "requests", "errors", "models"] as const).map(tab => (
439
- <button
440
- type="button"
441
- key={tab}
442
- onClick={() => setActiveTab(tab)}
443
- style={{
444
- background: activeTab === tab ? "var(--bg-card)" : "transparent",
445
- color: activeTab === tab ? "var(--text-primary)" : "var(--text-secondary)",
446
- border: "none",
447
- padding: "6px 16px",
448
- borderRadius: "4px",
449
- cursor: "pointer",
450
- textTransform: "capitalize",
451
- fontWeight: 500,
452
- }}
453
- >
454
- {tab}
455
- </button>
456
- ))}
457
- </div>
458
- <button
459
- type="button"
460
- onClick={handleSync}
461
- disabled={syncing}
462
- style={{
463
- background: "var(--accent)",
464
- color: "white",
465
- border: "none",
466
- padding: "8px 16px",
467
- borderRadius: "6px",
468
- cursor: "pointer",
469
- display: "flex",
470
- alignItems: "center",
471
- gap: "8px",
472
- opacity: syncing ? 0.7 : 1,
473
- }}
474
- >
475
- <RefreshCw size={16} className={syncing ? "spin" : ""} />
476
- {syncing ? "Syncing..." : "Sync"}
477
- </button>
48
+ if (!stats) {
49
+ return (
50
+ <div className="min-h-screen flex items-center justify-center">
51
+ <div className="flex items-center gap-3 text-[var(--text-muted)]">
52
+ <div className="w-5 h-5 border-2 border-[var(--border-default)] border-t-[var(--accent-cyan)] rounded-full spin" />
53
+ <span className="text-sm">Loading analytics...</span>
478
54
  </div>
479
- </header>
55
+ </div>
56
+ );
57
+ }
480
58
 
481
- {activeTab === "overview" && (
482
- <>
483
- <div
484
- style={{
485
- display: "grid",
486
- gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
487
- gap: "20px",
488
- marginBottom: "30px",
489
- }}
490
- >
491
- <StatCard
492
- title="Total Requests"
493
- value={stats.overall.totalRequests.toLocaleString()}
494
- detail={`${stats.overall.successfulRequests} success, ${stats.overall.failedRequests} errors`}
495
- icon={<Server size={20} />}
496
- />
497
- <StatCard
498
- title="Total Cost"
499
- value={`$${stats.overall.totalCost.toFixed(2)}`}
500
- detail={
501
- stats.overall.totalRequests > 0
502
- ? `$${(stats.overall.totalCost / stats.overall.totalRequests).toFixed(4)} avg/req`
503
- : "-"
504
- }
505
- icon={<Activity size={20} />}
506
- />
507
- <StatCard
508
- title="Cache Rate"
509
- value={`${(stats.overall.cacheRate * 100).toFixed(1)}%`}
510
- detail={`${(stats.overall.totalCacheReadTokens / 1000).toFixed(1)}k cached tokens`}
511
- icon={<Database size={20} />}
512
- />
513
- <StatCard
514
- title="Error Rate"
515
- value={`${(stats.overall.errorRate * 100).toFixed(1)}%`}
516
- detail={`${stats.overall.failedRequests} failed requests`}
517
- icon={<AlertCircle size={20} />}
518
- color="var(--error)"
519
- />
520
- <StatCard
521
- title="Tokens/Sec"
522
- value={stats.overall.avgTokensPerSecond?.toFixed(1) ?? "-"}
523
- detail={`${(stats.overall.totalInputTokens + stats.overall.totalOutputTokens).toLocaleString()} total tokens`}
524
- icon={<BarChart2 size={20} />}
525
- />
526
- <StatCard
527
- title="TTFT"
528
- value={stats.overall.avgTtft ? `${(stats.overall.avgTtft / 1000).toFixed(2)}s` : "-"}
529
- detail="Time to first token"
530
- icon={<Activity size={20} />}
531
- />
59
+ return (
60
+ <div className="min-h-screen">
61
+ <div className="max-w-[1600px] mx-auto px-6 py-6">
62
+ <Header activeTab={activeTab} onTabChange={setActiveTab} onSync={handleSync} syncing={syncing} />
63
+
64
+ {activeTab === "overview" && (
65
+ <div className="space-y-6 animate-fade-in">
66
+ <StatsGrid stats={stats.overall} />
67
+
68
+ <div className="grid lg:grid-cols-2 gap-6">
69
+ <RequestList
70
+ title="Recent Requests"
71
+ requests={recentRequests.slice(0, 10)}
72
+ onSelect={r => r.id && setSelectedRequest(r.id)}
73
+ />
74
+ <RequestList
75
+ title="Recent Errors"
76
+ requests={recentErrors.slice(0, 10)}
77
+ onSelect={r => r.id && setSelectedRequest(r.id)}
78
+ />
79
+ </div>
532
80
  </div>
81
+ )}
533
82
 
534
- <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "20px", height: "400px" }}>
83
+ {activeTab === "requests" && (
84
+ <div className="h-[calc(100vh-140px)] animate-fade-in">
535
85
  <RequestList
536
- title="Recent Requests"
86
+ title="All Recent Requests"
537
87
  requests={recentRequests}
538
88
  onSelect={r => r.id && setSelectedRequest(r.id)}
539
89
  />
90
+ </div>
91
+ )}
92
+
93
+ {activeTab === "errors" && (
94
+ <div className="h-[calc(100vh-140px)] animate-fade-in">
540
95
  <RequestList
541
- title="Recent Errors"
96
+ title="Failed Requests"
542
97
  requests={recentErrors}
543
98
  onSelect={r => r.id && setSelectedRequest(r.id)}
544
99
  />
545
100
  </div>
546
- </>
547
- )}
101
+ )}
548
102
 
549
- {activeTab === "requests" && (
550
- <div style={{ height: "calc(100vh - 150px)" }}>
551
- <RequestList
552
- title="All Recent Requests"
553
- requests={recentRequests}
554
- onSelect={r => r.id && setSelectedRequest(r.id)}
555
- />
556
- </div>
557
- )}
558
-
559
- {activeTab === "errors" && (
560
- <div style={{ height: "calc(100vh - 150px)" }}>
561
- <RequestList
562
- title="Failed Requests"
563
- requests={recentErrors}
564
- onSelect={r => r.id && setSelectedRequest(r.id)}
565
- />
566
- </div>
567
- )}
568
-
569
- {activeTab === "models" && (
570
- <div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
571
- <ChartCard title="Model Preference" subtitle="Share of requests, last 14 days">
572
- {modelPreference.data.length === 0 ? (
573
- <div style={{ color: "var(--text-secondary)", textAlign: "center", paddingTop: "40px" }}>
574
- No data yet
575
- </div>
576
- ) : (
577
- <ResponsiveContainer width="100%" height={260}>
578
- <AreaChart data={modelPreference.data} margin={{ left: 4, right: 8, top: 8, bottom: 4 }}>
579
- <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
580
- <XAxis dataKey="timestamp" tickFormatter={formatDateTick} stroke="var(--text-secondary)" />
581
- <YAxis stroke="var(--text-secondary)" tickFormatter={value => `${value}%`} />
582
- <Tooltip
583
- labelFormatter={(label: ReactNode) =>
584
- typeof label === "number" ? formatDateTick(label) : ""
585
- }
586
- formatter={(value: number | string | undefined) => [
587
- `${Number(value ?? 0).toFixed(1)}%`,
588
- "Share",
589
- ]}
590
- />
591
- <Legend />
592
- {modelPreference.series.map((seriesName, index) => (
593
- <Area
594
- key={seriesName}
595
- dataKey={seriesName}
596
- stackId="1"
597
- stroke={MODEL_COLORS[index % MODEL_COLORS.length]}
598
- fill={MODEL_COLORS[index % MODEL_COLORS.length]}
599
- fillOpacity={0.25}
600
- />
601
- ))}
602
- </AreaChart>
603
- </ResponsiveContainer>
604
- )}
605
- </ChartCard>
606
- <ModelStatsTable models={stats.byModel} performanceSeriesByKey={performanceSeriesByKey} />
607
- </div>
608
- )}
103
+ {activeTab === "models" && (
104
+ <div className="space-y-6 animate-fade-in">
105
+ <ChartsContainer modelSeries={stats.modelSeries} />
106
+ <ModelsTable models={stats.byModel} performanceSeries={stats.modelPerformanceSeries} />
107
+ </div>
108
+ )}
609
109
 
610
- {selectedRequest !== null && <RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />}
110
+ {selectedRequest !== null && (
111
+ <RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />
112
+ )}
113
+ </div>
611
114
  </div>
612
115
  );
613
116
  }