@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/omp-stats",
3
- "version": "10.3.2",
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.3.2",
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.10",
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
 
@@ -1,12 +1,165 @@
1
- import { Activity, AlertCircle, BarChart2, Database, RefreshCw, Server } from "lucide-react";
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
- function ModelStatsTable({ models }: { models: ModelStats[] }) {
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 style={{ overflowY: "auto", height: "calc(100% - 60px)" }}>
28
- <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.9rem" }}>
29
- <thead style={{ background: "rgba(0,0,0,0.2)", position: "sticky", top: 0 }}>
30
- <tr>
31
- <th
32
- style={{
33
- textAlign: "left",
34
- padding: "12px 20px",
35
- color: "var(--text-secondary)",
36
- fontWeight: 500,
37
- }}
38
- >
39
- Model
40
- </th>
41
- <th
42
- style={{
43
- textAlign: "left",
44
- padding: "12px 20px",
45
- color: "var(--text-secondary)",
46
- fontWeight: 500,
47
- }}
48
- >
49
- Provider
50
- </th>
51
- <th
52
- style={{
53
- textAlign: "right",
54
- padding: "12px 20px",
55
- color: "var(--text-secondary)",
56
- fontWeight: 500,
57
- }}
58
- >
59
- Requests
60
- </th>
61
- <th
62
- style={{
63
- textAlign: "right",
64
- padding: "12px 20px",
65
- color: "var(--text-secondary)",
66
- fontWeight: 500,
67
- }}
68
- >
69
- Cost
70
- </th>
71
- <th
72
- style={{
73
- textAlign: "right",
74
- padding: "12px 20px",
75
- color: "var(--text-secondary)",
76
- fontWeight: 500,
77
- }}
78
- >
79
- Tokens
80
- </th>
81
- <th
82
- style={{
83
- textAlign: "right",
84
- padding: "12px 20px",
85
- color: "var(--text-secondary)",
86
- fontWeight: 500,
87
- }}
88
- >
89
- Tokens/s
90
- </th>
91
- <th
92
- style={{
93
- textAlign: "right",
94
- padding: "12px 20px",
95
- color: "var(--text-secondary)",
96
- fontWeight: 500,
97
- }}
98
- >
99
- TTFT
100
- </th>
101
- <th
102
- style={{
103
- textAlign: "right",
104
- padding: "12px 20px",
105
- color: "var(--text-secondary)",
106
- fontWeight: 500,
107
- }}
108
- >
109
- Error Rate
110
- </th>
111
- </tr>
112
- </thead>
113
- <tbody>
114
- {sortedModels.map(m => (
115
- <tr key={`${m.provider}-${m.model}`} style={{ borderBottom: "1px solid var(--border)" }}>
116
- <td style={{ padding: "12px 20px" }}>
117
- <div style={{ fontWeight: 500 }}>{m.model}</div>
118
- </td>
119
- <td style={{ padding: "12px 20px", color: "var(--text-secondary)" }}>{m.provider}</td>
120
- <td style={{ padding: "12px 20px", textAlign: "right" }}>{m.totalRequests.toLocaleString()}</td>
121
- <td style={{ padding: "12px 20px", textAlign: "right" }}>${m.totalCost.toFixed(2)}</td>
122
- <td style={{ padding: "12px 20px", textAlign: "right" }}>
123
- {(m.totalInputTokens + m.totalOutputTokens).toLocaleString()}
124
- </td>
125
- <td style={{ padding: "12px 20px", textAlign: "right" }}>
126
- {m.avgTokensPerSecond?.toFixed(1) ?? "-"}
127
- </td>
128
- <td style={{ padding: "12px 20px", textAlign: "right" }}>
129
- {m.avgTtft ? `${(m.avgTtft / 1000).toFixed(2)}s` : "-"}
130
- </td>
131
- <td style={{ padding: "12px 20px", textAlign: "right" }}>{(m.errorRate * 100).toFixed(1)}%</td>
132
- </tr>
133
- ))}
134
- </tbody>
135
- </table>
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={{ height: "calc(100vh - 150px)" }}>
329
- <ModelStatsTable models={stats.byModel} />
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
 
@@ -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 { AggregatedStats, FolderStats, MessageStats, ModelStats, TimeSeriesPoint } from "./types";
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 { AggregatedStats, DashboardStats, FolderStats, MessageStats, ModelStats, TimeSeriesPoint } from "./types";
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 indexFile = Bun.file(path.join(STATIC_DIR, "index.html"));
17
- if (await indexFile.exists()) return;
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
  /**