@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 +14 -7
- package/src/client/App.tsx +58 -555
- package/src/client/components/ChartsContainer.tsx +185 -0
- package/src/client/components/Header.tsx +48 -0
- package/src/client/components/ModelsTable.tsx +343 -0
- package/src/client/components/RequestDetail.tsx +105 -101
- package/src/client/components/RequestList.tsx +37 -112
- package/src/client/components/StatsGrid.tsx +88 -0
- package/src/client/index.tsx +1 -0
- package/src/client/styles.css +277 -0
- package/src/server.ts +39 -30
- package/src/client/components/StatCard.tsx +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/omp-stats",
|
|
3
|
-
"version": "12.
|
|
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.
|
|
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-
|
|
56
|
-
"
|
|
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
|
+
}
|
package/src/client/App.tsx
CHANGED
|
@@ -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 {
|
|
21
|
-
import type { DashboardStats, MessageStats
|
|
8
|
+
import { StatsGrid } from "./components/StatsGrid";
|
|
9
|
+
import type { DashboardStats, MessageStats } from "./types";
|
|
22
10
|
|
|
23
|
-
|
|
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<
|
|
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)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
</
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
480
58
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
110
|
+
{selectedRequest !== null && (
|
|
111
|
+
<RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
611
114
|
</div>
|
|
612
115
|
);
|
|
613
116
|
}
|