@oh-my-pi/omp-stats 10.3.2 → 10.6.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 +3 -3
- package/src/aggregator.ts +4 -0
- package/src/client/App.tsx +392 -115
- package/src/client/types.ts +18 -0
- package/src/db.ts +74 -1
- package/src/index.ts +10 -1
- package/src/server.ts +31 -2
- package/src/types.ts +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/omp-stats",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.6.0",
|
|
4
4
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"directory": "packages/stats"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@oh-my-pi/pi-ai": "10.
|
|
51
|
+
"@oh-my-pi/pi-ai": "10.6.0",
|
|
52
52
|
"date-fns": "^4.1.0",
|
|
53
53
|
"lucide-react": "^0.563.0",
|
|
54
54
|
"react": "^19.2.4",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"recharts": "^3.7.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@types/node": "^25.0
|
|
59
|
+
"@types/node": "^25.2.0",
|
|
60
60
|
"@types/react": "^19.2.10",
|
|
61
61
|
"@types/react-dom": "^19.2.3"
|
|
62
62
|
},
|
package/src/aggregator.ts
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
getFileOffset,
|
|
6
6
|
getMessageById,
|
|
7
7
|
getMessageCount,
|
|
8
|
+
getModelPerformanceSeries,
|
|
9
|
+
getModelTimeSeries,
|
|
8
10
|
getOverallStats,
|
|
9
11
|
getStatsByFolder,
|
|
10
12
|
getStatsByModel,
|
|
@@ -84,6 +86,8 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|
|
84
86
|
byModel: getStatsByModel(),
|
|
85
87
|
byFolder: getStatsByFolder(),
|
|
86
88
|
timeSeries: getTimeSeries(24),
|
|
89
|
+
modelSeries: getModelTimeSeries(14),
|
|
90
|
+
modelPerformanceSeries: getModelPerformanceSeries(14),
|
|
87
91
|
};
|
|
88
92
|
}
|
|
89
93
|
|
package/src/client/App.tsx
CHANGED
|
@@ -1,12 +1,165 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
2
4
|
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";
|
|
3
17
|
import { getRecentErrors, getRecentRequests, getStats, sync } from "./api";
|
|
4
18
|
import { RequestDetail } from "./components/RequestDetail";
|
|
5
19
|
import { RequestList } from "./components/RequestList";
|
|
6
20
|
import { StatCard } from "./components/StatCard";
|
|
7
|
-
import type { DashboardStats, MessageStats, ModelStats } from "./types";
|
|
21
|
+
import type { DashboardStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint } from "./types";
|
|
8
22
|
|
|
9
|
-
|
|
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);
|
|
10
163
|
const sortedModels = [...models].sort(
|
|
11
164
|
(a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens),
|
|
12
165
|
);
|
|
@@ -18,121 +171,207 @@ function ModelStatsTable({ models }: { models: ModelStats[] }) {
|
|
|
18
171
|
borderRadius: "12px",
|
|
19
172
|
border: "1px solid var(--border)",
|
|
20
173
|
overflow: "hidden",
|
|
21
|
-
height: "100%",
|
|
22
174
|
}}
|
|
23
175
|
>
|
|
24
176
|
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
|
|
25
177
|
<h3 style={{ margin: 0, fontSize: "1rem" }}>Model Statistics</h3>
|
|
26
178
|
</div>
|
|
27
|
-
<div
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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>
|
|
136
375
|
</div>
|
|
137
376
|
</div>
|
|
138
377
|
);
|
|
@@ -175,6 +414,9 @@ export default function App() {
|
|
|
175
414
|
|
|
176
415
|
if (!stats) return <div style={{ padding: 40, textAlign: "center" }}>Loading stats...</div>;
|
|
177
416
|
|
|
417
|
+
const { seriesByKey: performanceSeriesByKey } = buildModelPerformanceLookup(stats.modelPerformanceSeries);
|
|
418
|
+
const modelPreference = buildModelPreferenceSeries(stats.modelSeries);
|
|
419
|
+
|
|
178
420
|
return (
|
|
179
421
|
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "20px" }}>
|
|
180
422
|
<header
|
|
@@ -325,8 +567,43 @@ export default function App() {
|
|
|
325
567
|
)}
|
|
326
568
|
|
|
327
569
|
{activeTab === "models" && (
|
|
328
|
-
<div style={{
|
|
329
|
-
<
|
|
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} />
|
|
330
607
|
</div>
|
|
331
608
|
)}
|
|
332
609
|
|
package/src/client/types.ts
CHANGED
|
@@ -74,9 +74,27 @@ export interface TimeSeriesPoint {
|
|
|
74
74
|
cost: number;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export interface ModelTimeSeriesPoint {
|
|
78
|
+
timestamp: number;
|
|
79
|
+
model: string;
|
|
80
|
+
provider: string;
|
|
81
|
+
requests: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ModelPerformancePoint {
|
|
85
|
+
timestamp: number;
|
|
86
|
+
model: string;
|
|
87
|
+
provider: string;
|
|
88
|
+
requests: number;
|
|
89
|
+
avgTtft: number | null;
|
|
90
|
+
avgTokensPerSecond: number | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
export interface DashboardStats {
|
|
78
94
|
overall: AggregatedStats;
|
|
79
95
|
byModel: ModelStats[];
|
|
80
96
|
byFolder: FolderStats[];
|
|
81
97
|
timeSeries: TimeSeriesPoint[];
|
|
98
|
+
modelSeries: ModelTimeSeriesPoint[];
|
|
99
|
+
modelPerformanceSeries: ModelPerformancePoint[];
|
|
82
100
|
}
|
package/src/db.ts
CHANGED
|
@@ -2,7 +2,15 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
AggregatedStats,
|
|
7
|
+
FolderStats,
|
|
8
|
+
MessageStats,
|
|
9
|
+
ModelPerformancePoint,
|
|
10
|
+
ModelStats,
|
|
11
|
+
ModelTimeSeriesPoint,
|
|
12
|
+
TimeSeriesPoint,
|
|
13
|
+
} from "./types";
|
|
6
14
|
|
|
7
15
|
const DB_PATH = path.join(os.homedir(), ".omp", "stats.db");
|
|
8
16
|
|
|
@@ -316,6 +324,71 @@ export function getTimeSeries(hours = 24): TimeSeriesPoint[] {
|
|
|
316
324
|
}));
|
|
317
325
|
}
|
|
318
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Get daily performance time series data for the last N days.
|
|
329
|
+
*/
|
|
330
|
+
/**
|
|
331
|
+
* Get daily model usage time series data for the last N days.
|
|
332
|
+
*/
|
|
333
|
+
export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
|
|
334
|
+
if (!db) return [];
|
|
335
|
+
|
|
336
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
337
|
+
|
|
338
|
+
const stmt = db.prepare(`
|
|
339
|
+
SELECT
|
|
340
|
+
(timestamp / 86400000) * 86400000 as bucket,
|
|
341
|
+
model,
|
|
342
|
+
provider,
|
|
343
|
+
COUNT(*) as requests
|
|
344
|
+
FROM messages
|
|
345
|
+
WHERE timestamp >= ?
|
|
346
|
+
GROUP BY bucket, model, provider
|
|
347
|
+
ORDER BY bucket ASC
|
|
348
|
+
`);
|
|
349
|
+
|
|
350
|
+
const rows = stmt.all(cutoff) as any[];
|
|
351
|
+
return rows.map(row => ({
|
|
352
|
+
timestamp: row.bucket,
|
|
353
|
+
model: row.model,
|
|
354
|
+
provider: row.provider,
|
|
355
|
+
requests: row.requests,
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Get daily model performance time series data for the last N days.
|
|
361
|
+
*/
|
|
362
|
+
export function getModelPerformanceSeries(days = 14): ModelPerformancePoint[] {
|
|
363
|
+
if (!db) return [];
|
|
364
|
+
|
|
365
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
366
|
+
|
|
367
|
+
const stmt = db.prepare(`
|
|
368
|
+
SELECT
|
|
369
|
+
(timestamp / 86400000) * 86400000 as bucket,
|
|
370
|
+
model,
|
|
371
|
+
provider,
|
|
372
|
+
COUNT(*) as requests,
|
|
373
|
+
AVG(ttft) as avg_ttft,
|
|
374
|
+
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
|
|
375
|
+
FROM messages
|
|
376
|
+
WHERE timestamp >= ?
|
|
377
|
+
GROUP BY bucket, model, provider
|
|
378
|
+
ORDER BY bucket ASC
|
|
379
|
+
`);
|
|
380
|
+
|
|
381
|
+
const rows = stmt.all(cutoff) as any[];
|
|
382
|
+
return rows.map(row => ({
|
|
383
|
+
timestamp: row.bucket,
|
|
384
|
+
model: row.model,
|
|
385
|
+
provider: row.provider,
|
|
386
|
+
requests: row.requests,
|
|
387
|
+
avgTtft: row.avg_ttft,
|
|
388
|
+
avgTokensPerSecond: row.avg_tokens_per_second,
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
|
|
319
392
|
/**
|
|
320
393
|
* Get total message count.
|
|
321
394
|
*/
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,16 @@ import { closeDb } from "./db";
|
|
|
6
6
|
import { startServer } from "./server";
|
|
7
7
|
|
|
8
8
|
export { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
|
|
9
|
-
export type {
|
|
9
|
+
export type {
|
|
10
|
+
AggregatedStats,
|
|
11
|
+
DashboardStats,
|
|
12
|
+
FolderStats,
|
|
13
|
+
MessageStats,
|
|
14
|
+
ModelPerformancePoint,
|
|
15
|
+
ModelStats,
|
|
16
|
+
ModelTimeSeriesPoint,
|
|
17
|
+
TimeSeriesPoint,
|
|
18
|
+
} from "./types";
|
|
10
19
|
|
|
11
20
|
/**
|
|
12
21
|
* Format a number with appropriate suffix (K, M, etc.)
|
package/src/server.ts
CHANGED
|
@@ -12,9 +12,38 @@ import {
|
|
|
12
12
|
const CLIENT_DIR = path.join(import.meta.dir, "client");
|
|
13
13
|
const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client");
|
|
14
14
|
|
|
15
|
+
async function getLatestMtime(dir: string): Promise<number> {
|
|
16
|
+
let latest = 0;
|
|
17
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
18
|
+
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
const fullPath = path.join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory()) {
|
|
22
|
+
latest = Math.max(latest, await getLatestMtime(fullPath));
|
|
23
|
+
} else if (entry.isFile()) {
|
|
24
|
+
const stats = await fs.stat(fullPath);
|
|
25
|
+
latest = Math.max(latest, stats.mtimeMs);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return latest;
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
const ensureClientBuild = async () => {
|
|
16
|
-
const
|
|
17
|
-
|
|
33
|
+
const indexPath = path.join(STATIC_DIR, "index.html");
|
|
34
|
+
const sourceMtime = await getLatestMtime(CLIENT_DIR);
|
|
35
|
+
let shouldBuild = true;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const indexStats = await fs.stat(indexPath);
|
|
39
|
+
if (indexStats.isFile() && indexStats.mtimeMs >= sourceMtime) {
|
|
40
|
+
shouldBuild = false;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
shouldBuild = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!shouldBuild) return;
|
|
18
47
|
|
|
19
48
|
await fs.rm(STATIC_DIR, { recursive: true, force: true });
|
|
20
49
|
|
package/src/types.ts
CHANGED
|
@@ -106,6 +106,38 @@ export interface TimeSeriesPoint {
|
|
|
106
106
|
cost: number;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Model usage time series data point (daily buckets).
|
|
111
|
+
*/
|
|
112
|
+
export interface ModelTimeSeriesPoint {
|
|
113
|
+
/** Bucket timestamp (start of day) */
|
|
114
|
+
timestamp: number;
|
|
115
|
+
/** Model name */
|
|
116
|
+
model: string;
|
|
117
|
+
/** Provider name */
|
|
118
|
+
provider: string;
|
|
119
|
+
/** Request count */
|
|
120
|
+
requests: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Model performance time series data point (daily buckets).
|
|
125
|
+
*/
|
|
126
|
+
export interface ModelPerformancePoint {
|
|
127
|
+
/** Bucket timestamp (start of day) */
|
|
128
|
+
timestamp: number;
|
|
129
|
+
/** Model name */
|
|
130
|
+
model: string;
|
|
131
|
+
/** Provider name */
|
|
132
|
+
provider: string;
|
|
133
|
+
/** Request count */
|
|
134
|
+
requests: number;
|
|
135
|
+
/** Average TTFT in ms */
|
|
136
|
+
avgTtft: number | null;
|
|
137
|
+
/** Average tokens per second */
|
|
138
|
+
avgTokensPerSecond: number | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
109
141
|
/**
|
|
110
142
|
* Overall dashboard stats.
|
|
111
143
|
*/
|
|
@@ -114,6 +146,8 @@ export interface DashboardStats {
|
|
|
114
146
|
byModel: ModelStats[];
|
|
115
147
|
byFolder: FolderStats[];
|
|
116
148
|
timeSeries: TimeSeriesPoint[];
|
|
149
|
+
modelSeries: ModelTimeSeriesPoint[];
|
|
150
|
+
modelPerformanceSeries: ModelPerformancePoint[];
|
|
117
151
|
}
|
|
118
152
|
|
|
119
153
|
/**
|