@oh-my-pi/omp-stats 14.2.1 → 14.4.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 +15 -15
- package/src/aggregator.ts +2 -1
- package/src/client/App.tsx +10 -1
- package/src/client/components/CostChart.tsx +365 -0
- package/src/client/components/CostSummary.tsx +92 -0
- package/src/client/components/Header.tsx +2 -2
- package/src/client/types.ts +13 -0
- package/src/db.ts +40 -0
- package/src/types.ts +22 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/omp-stats",
|
|
4
|
-
"version": "14.
|
|
4
|
+
"version": "14.4.0",
|
|
5
5
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,22 +37,22 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-ai": "14.
|
|
41
|
-
"@oh-my-pi/pi-utils": "14.
|
|
42
|
-
"@tailwindcss/node": "^4.2",
|
|
43
|
-
"chart.js": "^4.5",
|
|
44
|
-
"date-fns": "^4.1",
|
|
45
|
-
"lucide-react": "^0
|
|
46
|
-
"react": "^19.2",
|
|
47
|
-
"react-chartjs-2": "^5.3",
|
|
48
|
-
"react-dom": "^19.2"
|
|
40
|
+
"@oh-my-pi/pi-ai": "14.4.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "14.4.0",
|
|
42
|
+
"@tailwindcss/node": "^4.2.4",
|
|
43
|
+
"chart.js": "^4.5.1",
|
|
44
|
+
"date-fns": "^4.1.0",
|
|
45
|
+
"lucide-react": "^1.11.0",
|
|
46
|
+
"react": "^19.2.5",
|
|
47
|
+
"react-chartjs-2": "^5.3.1",
|
|
48
|
+
"react-dom": "^19.2.5"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@types/bun": "^1.3",
|
|
52
|
-
"@types/react": "^19.2",
|
|
53
|
-
"@types/react-dom": "^19.2",
|
|
54
|
-
"postcss": "^8.5",
|
|
55
|
-
"tailwindcss": "^4.2"
|
|
51
|
+
"@types/bun": "^1.3.13",
|
|
52
|
+
"@types/react": "^19.2.14",
|
|
53
|
+
"@types/react-dom": "^19.2.3",
|
|
54
|
+
"postcss": "^8.5.10",
|
|
55
|
+
"tailwindcss": "^4.2.4"
|
|
56
56
|
},
|
|
57
57
|
"engines": {
|
|
58
58
|
"bun": ">=1.3.7"
|
package/src/aggregator.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import {
|
|
3
3
|
getRecentErrors as dbGetRecentErrors,
|
|
4
4
|
getRecentRequests as dbGetRecentRequests,
|
|
5
|
+
getCostTimeSeries,
|
|
5
6
|
getFileOffset,
|
|
6
7
|
getMessageById,
|
|
7
8
|
getMessageCount,
|
|
@@ -88,9 +89,9 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|
|
88
89
|
timeSeries: getTimeSeries(24),
|
|
89
90
|
modelSeries: getModelTimeSeries(14),
|
|
90
91
|
modelPerformanceSeries: getModelPerformanceSeries(14),
|
|
92
|
+
costSeries: getCostTimeSeries(90),
|
|
91
93
|
};
|
|
92
94
|
}
|
|
93
|
-
|
|
94
95
|
export async function getRecentRequests(limit?: number): Promise<MessageStats[]> {
|
|
95
96
|
await initDb();
|
|
96
97
|
return dbGetRecentRequests(limit);
|
package/src/client/App.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
2
|
import { getRecentErrors, getRecentRequests, getStats, sync } from "./api";
|
|
3
3
|
import { ChartsContainer } from "./components/ChartsContainer";
|
|
4
|
+
import { CostChart } from "./components/CostChart";
|
|
5
|
+
import { CostSummary } from "./components/CostSummary";
|
|
4
6
|
import { Header } from "./components/Header";
|
|
5
7
|
import { ModelsTable } from "./components/ModelsTable";
|
|
6
8
|
import { RequestDetail } from "./components/RequestDetail";
|
|
@@ -8,7 +10,7 @@ import { RequestList } from "./components/RequestList";
|
|
|
8
10
|
import { StatsGrid } from "./components/StatsGrid";
|
|
9
11
|
import type { DashboardStats, MessageStats } from "./types";
|
|
10
12
|
|
|
11
|
-
type Tab = "overview" | "requests" | "errors" | "models";
|
|
13
|
+
type Tab = "overview" | "requests" | "errors" | "models" | "costs";
|
|
12
14
|
|
|
13
15
|
export default function App() {
|
|
14
16
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
@@ -107,6 +109,13 @@ export default function App() {
|
|
|
107
109
|
</div>
|
|
108
110
|
)}
|
|
109
111
|
|
|
112
|
+
{activeTab === "costs" && (
|
|
113
|
+
<div className="space-y-6 animate-fade-in">
|
|
114
|
+
<CostSummary costSeries={stats.costSeries} />
|
|
115
|
+
<CostChart costSeries={stats.costSeries} />
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
|
|
110
119
|
{selectedRequest !== null && (
|
|
111
120
|
<RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />
|
|
112
121
|
)}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BarElement,
|
|
3
|
+
CategoryScale,
|
|
4
|
+
Chart as ChartJS,
|
|
5
|
+
type ChartOptions,
|
|
6
|
+
Filler,
|
|
7
|
+
Legend,
|
|
8
|
+
LinearScale,
|
|
9
|
+
LineElement,
|
|
10
|
+
type Plugin,
|
|
11
|
+
PointElement,
|
|
12
|
+
Title,
|
|
13
|
+
Tooltip,
|
|
14
|
+
} from "chart.js";
|
|
15
|
+
import { format } from "date-fns";
|
|
16
|
+
import { useMemo, useState } from "react";
|
|
17
|
+
import { Bar, Line } from "react-chartjs-2";
|
|
18
|
+
import type { CostTimeSeriesPoint } from "../types";
|
|
19
|
+
import { useSystemTheme } from "../useSystemTheme";
|
|
20
|
+
|
|
21
|
+
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
|
|
22
|
+
|
|
23
|
+
const MODEL_COLORS = [
|
|
24
|
+
"#a78bfa", // violet
|
|
25
|
+
"#22d3ee", // cyan
|
|
26
|
+
"#ec4899", // pink
|
|
27
|
+
"#4ade80", // green
|
|
28
|
+
"#fbbf24", // amber
|
|
29
|
+
"#f87171", // red
|
|
30
|
+
"#60a5fa", // blue
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const CHART_THEMES = {
|
|
34
|
+
dark: {
|
|
35
|
+
legendLabel: "#94a3b8",
|
|
36
|
+
tooltipBackground: "#16161e",
|
|
37
|
+
tooltipTitle: "#f8fafc",
|
|
38
|
+
tooltipBody: "#94a3b8",
|
|
39
|
+
tooltipBorder: "rgba(255, 255, 255, 0.1)",
|
|
40
|
+
grid: "rgba(255, 255, 255, 0.06)",
|
|
41
|
+
tick: "#64748b",
|
|
42
|
+
barLabel: "rgba(248, 250, 252, 0.7)",
|
|
43
|
+
},
|
|
44
|
+
light: {
|
|
45
|
+
legendLabel: "#475569",
|
|
46
|
+
tooltipBackground: "#ffffff",
|
|
47
|
+
tooltipTitle: "#0f172a",
|
|
48
|
+
tooltipBody: "#334155",
|
|
49
|
+
tooltipBorder: "rgba(15, 23, 42, 0.18)",
|
|
50
|
+
grid: "rgba(15, 23, 42, 0.08)",
|
|
51
|
+
tick: "#64748b",
|
|
52
|
+
barLabel: "rgba(15, 23, 42, 0.6)",
|
|
53
|
+
},
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
const RANGE_OPTIONS = [14, 30, 90] as const;
|
|
57
|
+
type RangeDays = (typeof RANGE_OPTIONS)[number];
|
|
58
|
+
|
|
59
|
+
interface CostChartProps {
|
|
60
|
+
costSeries: CostTimeSeriesPoint[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Inline Chart.js plugin — draws cost value centered above each bar. */
|
|
64
|
+
function makeBarLabelPlugin(color: string): Plugin<"bar"> {
|
|
65
|
+
return {
|
|
66
|
+
id: "costBarLabels",
|
|
67
|
+
afterDatasetsDraw(chart) {
|
|
68
|
+
const { ctx } = chart;
|
|
69
|
+
const dataset = chart.data.datasets[0];
|
|
70
|
+
if (!dataset) return;
|
|
71
|
+
const meta = chart.getDatasetMeta(0);
|
|
72
|
+
ctx.save();
|
|
73
|
+
ctx.font = "11px system-ui, sans-serif";
|
|
74
|
+
ctx.fillStyle = color;
|
|
75
|
+
ctx.textAlign = "center";
|
|
76
|
+
ctx.textBaseline = "bottom";
|
|
77
|
+
for (const bar of meta.data) {
|
|
78
|
+
const value = (bar as unknown as { $context: { parsed: { y: number } } }).$context.parsed.y;
|
|
79
|
+
if (!value) continue;
|
|
80
|
+
const label = `$${Math.round(value)}`;
|
|
81
|
+
const { x, y } = bar.getProps(["x", "y"], true) as { x: number; y: number };
|
|
82
|
+
ctx.fillText(label, x, y - 3);
|
|
83
|
+
}
|
|
84
|
+
ctx.restore();
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function CostChart({ costSeries }: CostChartProps) {
|
|
90
|
+
const [byModel, setByModel] = useState(false);
|
|
91
|
+
const [days, setDays] = useState<RangeDays>(30);
|
|
92
|
+
const theme = useSystemTheme();
|
|
93
|
+
const chartTheme = CHART_THEMES[theme];
|
|
94
|
+
|
|
95
|
+
const cutoff = Date.now() - days * 86400000;
|
|
96
|
+
const filtered = useMemo(() => costSeries.filter(p => p.timestamp >= cutoff), [costSeries, cutoff]);
|
|
97
|
+
|
|
98
|
+
const chartData = useMemo(
|
|
99
|
+
() => (byModel ? buildByModelSeries(filtered) : buildAggregateSeries(filtered)),
|
|
100
|
+
[filtered, byModel],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const sharedPlugins = {
|
|
104
|
+
legend: {
|
|
105
|
+
display: byModel,
|
|
106
|
+
position: "top" as const,
|
|
107
|
+
align: "start" as const,
|
|
108
|
+
labels: {
|
|
109
|
+
color: chartTheme.legendLabel,
|
|
110
|
+
usePointStyle: true,
|
|
111
|
+
padding: 16,
|
|
112
|
+
font: { size: 12 },
|
|
113
|
+
boxWidth: 8,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
tooltip: {
|
|
117
|
+
backgroundColor: chartTheme.tooltipBackground,
|
|
118
|
+
titleColor: chartTheme.tooltipTitle,
|
|
119
|
+
bodyColor: chartTheme.tooltipBody,
|
|
120
|
+
borderColor: chartTheme.tooltipBorder,
|
|
121
|
+
borderWidth: 1,
|
|
122
|
+
padding: 12,
|
|
123
|
+
cornerRadius: 8,
|
|
124
|
+
callbacks: {
|
|
125
|
+
label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
|
126
|
+
const label = context.dataset.label ?? "Cost";
|
|
127
|
+
const value = context.parsed.y ?? 0;
|
|
128
|
+
return `${label}: $${Math.round(value)}`;
|
|
129
|
+
},
|
|
130
|
+
footer: (items: { parsed: { y: number | null } }[]) => {
|
|
131
|
+
if (!byModel || items.length < 2) return undefined;
|
|
132
|
+
const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
|
|
133
|
+
return `Total: $${Math.round(total)}`;
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const sharedScaleBase = {
|
|
140
|
+
grid: { color: chartTheme.grid, drawBorder: false },
|
|
141
|
+
ticks: { color: chartTheme.tick, font: { size: 11 } },
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const yScale = {
|
|
145
|
+
...sharedScaleBase,
|
|
146
|
+
ticks: {
|
|
147
|
+
...sharedScaleBase.ticks,
|
|
148
|
+
callback: (value: number | string) => `$${Math.round(Number(value))}`,
|
|
149
|
+
},
|
|
150
|
+
min: 0,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (byModel) {
|
|
154
|
+
const lineData = {
|
|
155
|
+
labels: chartData.labels,
|
|
156
|
+
datasets: chartData.datasets.map((ds, index) => ({
|
|
157
|
+
label: ds.label,
|
|
158
|
+
data: ds.data,
|
|
159
|
+
borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
160
|
+
backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`,
|
|
161
|
+
fill: true,
|
|
162
|
+
tension: 0,
|
|
163
|
+
pointRadius: 3,
|
|
164
|
+
pointHoverRadius: 4,
|
|
165
|
+
borderWidth: 2,
|
|
166
|
+
})),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const lineOptions: ChartOptions<"line"> = {
|
|
170
|
+
responsive: true,
|
|
171
|
+
maintainAspectRatio: false,
|
|
172
|
+
interaction: { mode: "index", intersect: false },
|
|
173
|
+
plugins: sharedPlugins,
|
|
174
|
+
scales: { x: sharedScaleBase, y: yScale },
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<ChartWrapper
|
|
179
|
+
byModel={byModel}
|
|
180
|
+
days={days}
|
|
181
|
+
onByModelChange={setByModel}
|
|
182
|
+
onDaysChange={setDays}
|
|
183
|
+
empty={chartData.labels.length === 0}
|
|
184
|
+
>
|
|
185
|
+
<Line data={lineData} options={lineOptions} />
|
|
186
|
+
</ChartWrapper>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const barData = {
|
|
191
|
+
labels: chartData.labels,
|
|
192
|
+
datasets: chartData.datasets.map((ds, index) => ({
|
|
193
|
+
label: ds.label,
|
|
194
|
+
data: ds.data,
|
|
195
|
+
backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
196
|
+
borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
197
|
+
borderWidth: 0,
|
|
198
|
+
borderRadius: 3,
|
|
199
|
+
})),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const barLabelPlugin = makeBarLabelPlugin(chartTheme.barLabel);
|
|
203
|
+
|
|
204
|
+
const barOptions: ChartOptions<"bar"> = {
|
|
205
|
+
responsive: true,
|
|
206
|
+
maintainAspectRatio: false,
|
|
207
|
+
interaction: { mode: "index", intersect: false },
|
|
208
|
+
plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"],
|
|
209
|
+
scales: {
|
|
210
|
+
x: { ...sharedScaleBase, stacked: true },
|
|
211
|
+
y: { ...yScale, stacked: true },
|
|
212
|
+
},
|
|
213
|
+
layout: { padding: { top: 24 } },
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<ChartWrapper
|
|
218
|
+
byModel={byModel}
|
|
219
|
+
days={days}
|
|
220
|
+
onByModelChange={setByModel}
|
|
221
|
+
onDaysChange={setDays}
|
|
222
|
+
empty={chartData.labels.length === 0}
|
|
223
|
+
>
|
|
224
|
+
<Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />
|
|
225
|
+
</ChartWrapper>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface ChartWrapperProps {
|
|
230
|
+
byModel: boolean;
|
|
231
|
+
days: RangeDays;
|
|
232
|
+
onByModelChange: (v: boolean) => void;
|
|
233
|
+
onDaysChange: (v: RangeDays) => void;
|
|
234
|
+
empty: boolean;
|
|
235
|
+
children: React.ReactNode;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function ChartWrapper({ byModel, days, onByModelChange, onDaysChange, empty, children }: ChartWrapperProps) {
|
|
239
|
+
return (
|
|
240
|
+
<div className="surface overflow-hidden">
|
|
241
|
+
<div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
|
|
242
|
+
<div>
|
|
243
|
+
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Daily Cost</h3>
|
|
244
|
+
<p className="text-xs text-[var(--text-muted)] mt-1">API spending over time</p>
|
|
245
|
+
</div>
|
|
246
|
+
<div className="flex items-center gap-2">
|
|
247
|
+
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={() => onByModelChange(false)}
|
|
251
|
+
className={`tab-btn text-xs ${!byModel ? "active" : ""}`}
|
|
252
|
+
>
|
|
253
|
+
All Models
|
|
254
|
+
</button>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={() => onByModelChange(true)}
|
|
258
|
+
className={`tab-btn text-xs ${byModel ? "active" : ""}`}
|
|
259
|
+
>
|
|
260
|
+
By Model
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--radius-sm)] border-[var(--border-subtle)]">
|
|
264
|
+
{RANGE_OPTIONS.map(d => (
|
|
265
|
+
<button
|
|
266
|
+
key={d}
|
|
267
|
+
type="button"
|
|
268
|
+
onClick={() => onDaysChange(d)}
|
|
269
|
+
className={`tab-btn text-xs ${days === d ? "active" : ""}`}
|
|
270
|
+
>
|
|
271
|
+
{d}d
|
|
272
|
+
</button>
|
|
273
|
+
))}
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
<div className="p-5 min-h-[320px]">
|
|
278
|
+
{empty ? (
|
|
279
|
+
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
|
|
280
|
+
No cost data available
|
|
281
|
+
</div>
|
|
282
|
+
) : (
|
|
283
|
+
<div className="h-[280px]">{children}</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
interface ChartSeries {
|
|
291
|
+
labels: string[];
|
|
292
|
+
datasets: Array<{ label: string; data: number[] }>;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries {
|
|
296
|
+
if (points.length === 0) return { labels: [], datasets: [] };
|
|
297
|
+
|
|
298
|
+
const byDay = new Map<number, number>();
|
|
299
|
+
for (const point of points) {
|
|
300
|
+
byDay.set(point.timestamp, (byDay.get(point.timestamp) ?? 0) + point.cost);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]);
|
|
304
|
+
return {
|
|
305
|
+
labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")),
|
|
306
|
+
datasets: [{ label: "Cost", data: sorted.map(([, cost]) => cost) }],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function buildByModelSeries(points: CostTimeSeriesPoint[], topN = 5): ChartSeries {
|
|
311
|
+
if (points.length === 0) return { labels: [], datasets: [] };
|
|
312
|
+
|
|
313
|
+
// Rank models by total cost
|
|
314
|
+
const totals = new Map<string, { model: string; provider: string; total: number }>();
|
|
315
|
+
for (const point of points) {
|
|
316
|
+
const key = `${point.model}::${point.provider}`;
|
|
317
|
+
const existing = totals.get(key);
|
|
318
|
+
if (existing) {
|
|
319
|
+
existing.total += point.cost;
|
|
320
|
+
} else {
|
|
321
|
+
totals.set(key, { model: point.model, provider: point.provider, total: point.cost });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const sorted = [...totals.entries()].sort((a, b) => b[1].total - a[1].total);
|
|
326
|
+
const topEntries = sorted.slice(0, topN);
|
|
327
|
+
const topKeys = new Set(topEntries.map(([key]) => key));
|
|
328
|
+
|
|
329
|
+
// Disambiguate model labels when same model name appears from multiple providers
|
|
330
|
+
const modelCount = new Map<string, number>();
|
|
331
|
+
for (const [, { model }] of topEntries) {
|
|
332
|
+
modelCount.set(model, (modelCount.get(model) ?? 0) + 1);
|
|
333
|
+
}
|
|
334
|
+
const labelByKey = new Map<string, string>();
|
|
335
|
+
for (const [key, { model, provider }] of topEntries) {
|
|
336
|
+
labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Collect all day buckets
|
|
340
|
+
const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
|
|
341
|
+
|
|
342
|
+
// Build per-day, per-series totals
|
|
343
|
+
const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key);
|
|
344
|
+
const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`));
|
|
345
|
+
if (hasOther) seriesNames.push("Other");
|
|
346
|
+
|
|
347
|
+
const dayMap = new Map<number, Record<string, number>>();
|
|
348
|
+
for (const day of allDays) {
|
|
349
|
+
dayMap.set(day, {});
|
|
350
|
+
}
|
|
351
|
+
for (const point of points) {
|
|
352
|
+
const key = `${point.model}::${point.provider}`;
|
|
353
|
+
const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other";
|
|
354
|
+
const row = dayMap.get(point.timestamp)!;
|
|
355
|
+
row[label] = (row[label] ?? 0) + point.cost;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
labels: allDays.map(ts => format(new Date(ts), "MMM d")),
|
|
360
|
+
datasets: seriesNames.map(name => ({
|
|
361
|
+
label: name,
|
|
362
|
+
data: allDays.map(day => dayMap.get(day)?.[name] ?? 0),
|
|
363
|
+
})),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { CostTimeSeriesPoint } from "../types";
|
|
3
|
+
|
|
4
|
+
interface CostSummaryProps {
|
|
5
|
+
costSeries: CostTimeSeriesPoint[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const SUMMARY_DAYS = 30;
|
|
9
|
+
|
|
10
|
+
function formatCost(value: number): string {
|
|
11
|
+
return `$${Math.round(value)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function CostSummary({ costSeries }: CostSummaryProps) {
|
|
15
|
+
const cutoff = Date.now() - SUMMARY_DAYS * 86400000;
|
|
16
|
+
const prevCutoff = cutoff - SUMMARY_DAYS * 86400000;
|
|
17
|
+
|
|
18
|
+
const current = useMemo(() => costSeries.filter(p => p.timestamp >= cutoff), [costSeries, cutoff]);
|
|
19
|
+
const previous = useMemo(
|
|
20
|
+
() => costSeries.filter(p => p.timestamp >= prevCutoff && p.timestamp < cutoff),
|
|
21
|
+
[costSeries, prevCutoff, cutoff],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const totalCost = current.reduce((sum, p) => sum + p.cost, 0);
|
|
25
|
+
const prevTotalCost = previous.reduce((sum, p) => sum + p.cost, 0);
|
|
26
|
+
|
|
27
|
+
const dayBuckets = new Set(current.map(p => p.timestamp)).size;
|
|
28
|
+
const avgDaily = dayBuckets > 0 ? totalCost / dayBuckets : 0;
|
|
29
|
+
|
|
30
|
+
// Most expensive model over current period
|
|
31
|
+
const modelTotals = new Map<string, number>();
|
|
32
|
+
for (const point of current) {
|
|
33
|
+
modelTotals.set(point.model, (modelTotals.get(point.model) ?? 0) + point.cost);
|
|
34
|
+
}
|
|
35
|
+
let topModel = "";
|
|
36
|
+
let topModelCost = 0;
|
|
37
|
+
for (const [model, cost] of modelTotals) {
|
|
38
|
+
if (cost > topModelCost) {
|
|
39
|
+
topModel = model;
|
|
40
|
+
topModelCost = cost;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const trend = prevTotalCost > 0 ? ((totalCost - prevTotalCost) / prevTotalCost) * 100 : null;
|
|
45
|
+
|
|
46
|
+
const cards = [
|
|
47
|
+
{
|
|
48
|
+
label: "Total (30d)",
|
|
49
|
+
value: formatCost(totalCost),
|
|
50
|
+
positive: null as boolean | null,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
label: "Avg / day",
|
|
54
|
+
value: formatCost(avgDaily),
|
|
55
|
+
positive: null as boolean | null,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
label: "Top model",
|
|
59
|
+
value: topModel || "—",
|
|
60
|
+
sub: topModel ? formatCost(topModelCost) : undefined,
|
|
61
|
+
positive: null as boolean | null,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
label: "vs prev 30d",
|
|
65
|
+
value: trend !== null ? `${trend >= 0 ? "+" : ""}${Math.round(trend)}%` : "—",
|
|
66
|
+
sub: undefined as string | undefined,
|
|
67
|
+
positive: trend !== null ? trend <= 0 : null,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
73
|
+
{cards.map(card => (
|
|
74
|
+
<div key={card.label} className="surface px-4 py-3">
|
|
75
|
+
<p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
|
|
76
|
+
<p
|
|
77
|
+
className={`text-lg font-semibold ${
|
|
78
|
+
card.positive === true
|
|
79
|
+
? "text-[var(--accent-green,#4ade80)]"
|
|
80
|
+
: card.positive === false
|
|
81
|
+
? "text-[var(--accent-pink)]"
|
|
82
|
+
: "text-[var(--text-primary)]"
|
|
83
|
+
}`}
|
|
84
|
+
>
|
|
85
|
+
{card.value}
|
|
86
|
+
</p>
|
|
87
|
+
{card.sub && <p className="text-xs text-[var(--text-muted)] mt-0.5">{card.sub}</p>}
|
|
88
|
+
</div>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Activity, RefreshCw } from "lucide-react";
|
|
2
2
|
|
|
3
|
-
type Tab = "overview" | "requests" | "errors" | "models";
|
|
3
|
+
type Tab = "overview" | "requests" | "errors" | "models" | "costs";
|
|
4
4
|
|
|
5
5
|
interface HeaderProps {
|
|
6
6
|
activeTab: Tab;
|
|
@@ -9,7 +9,7 @@ interface HeaderProps {
|
|
|
9
9
|
syncing: boolean;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
const tabs: Tab[] = ["overview", "requests", "errors", "models"];
|
|
12
|
+
const tabs: Tab[] = ["overview", "requests", "errors", "models", "costs"];
|
|
13
13
|
|
|
14
14
|
export function Header({ activeTab, onTabChange, onSync, syncing }: HeaderProps) {
|
|
15
15
|
return (
|
package/src/client/types.ts
CHANGED
|
@@ -92,6 +92,18 @@ export interface ModelPerformancePoint {
|
|
|
92
92
|
avgTokensPerSecond: number | null;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
export interface CostTimeSeriesPoint {
|
|
96
|
+
timestamp: number;
|
|
97
|
+
model: string;
|
|
98
|
+
provider: string;
|
|
99
|
+
cost: number;
|
|
100
|
+
costInput: number;
|
|
101
|
+
costOutput: number;
|
|
102
|
+
costCacheRead: number;
|
|
103
|
+
costCacheWrite: number;
|
|
104
|
+
requests: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
95
107
|
export interface DashboardStats {
|
|
96
108
|
overall: AggregatedStats;
|
|
97
109
|
byModel: ModelStats[];
|
|
@@ -99,4 +111,5 @@ export interface DashboardStats {
|
|
|
99
111
|
timeSeries: TimeSeriesPoint[];
|
|
100
112
|
modelSeries: ModelTimeSeriesPoint[];
|
|
101
113
|
modelPerformanceSeries: ModelPerformancePoint[];
|
|
114
|
+
costSeries: CostTimeSeriesPoint[];
|
|
102
115
|
}
|
package/src/db.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
|
|
4
4
|
import type {
|
|
5
5
|
AggregatedStats,
|
|
6
|
+
CostTimeSeriesPoint,
|
|
6
7
|
FolderStats,
|
|
7
8
|
MessageStats,
|
|
8
9
|
ModelPerformancePoint,
|
|
@@ -480,3 +481,42 @@ export function getMessageById(id: number): MessageStats | null {
|
|
|
480
481
|
const row = stmt.get(id);
|
|
481
482
|
return row ? rowToMessageStats(row) : null;
|
|
482
483
|
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Get daily cost time series data for the last N days, broken down by model.
|
|
487
|
+
*/
|
|
488
|
+
export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] {
|
|
489
|
+
if (!db) return [];
|
|
490
|
+
|
|
491
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
492
|
+
|
|
493
|
+
const stmt = db.prepare(`
|
|
494
|
+
SELECT
|
|
495
|
+
(timestamp / 86400000) * 86400000 as bucket,
|
|
496
|
+
model,
|
|
497
|
+
provider,
|
|
498
|
+
SUM(cost_total) as cost,
|
|
499
|
+
SUM(cost_input) as cost_input,
|
|
500
|
+
SUM(cost_output) as cost_output,
|
|
501
|
+
SUM(cost_cache_read) as cost_cache_read,
|
|
502
|
+
SUM(cost_cache_write) as cost_cache_write,
|
|
503
|
+
COUNT(*) as requests
|
|
504
|
+
FROM messages
|
|
505
|
+
WHERE timestamp >= ?
|
|
506
|
+
GROUP BY bucket, model, provider
|
|
507
|
+
ORDER BY bucket ASC
|
|
508
|
+
`);
|
|
509
|
+
|
|
510
|
+
const rows = stmt.all(cutoff) as any[];
|
|
511
|
+
return rows.map(row => ({
|
|
512
|
+
timestamp: row.bucket,
|
|
513
|
+
model: row.model,
|
|
514
|
+
provider: row.provider,
|
|
515
|
+
cost: row.cost,
|
|
516
|
+
costInput: row.cost_input,
|
|
517
|
+
costOutput: row.cost_output,
|
|
518
|
+
costCacheRead: row.cost_cache_read,
|
|
519
|
+
costCacheWrite: row.cost_cache_write,
|
|
520
|
+
requests: row.requests,
|
|
521
|
+
}));
|
|
522
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -140,6 +140,27 @@ export interface ModelPerformancePoint {
|
|
|
140
140
|
avgTokensPerSecond: number | null;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Cost time series data point (daily buckets).
|
|
145
|
+
*/
|
|
146
|
+
export interface CostTimeSeriesPoint {
|
|
147
|
+
/** Bucket timestamp (start of day) */
|
|
148
|
+
timestamp: number;
|
|
149
|
+
/** Model name */
|
|
150
|
+
model: string;
|
|
151
|
+
/** Provider name */
|
|
152
|
+
provider: string;
|
|
153
|
+
/** Total cost for this bucket */
|
|
154
|
+
cost: number;
|
|
155
|
+
/** Cost breakdown */
|
|
156
|
+
costInput: number;
|
|
157
|
+
costOutput: number;
|
|
158
|
+
costCacheRead: number;
|
|
159
|
+
costCacheWrite: number;
|
|
160
|
+
/** Request count */
|
|
161
|
+
requests: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
143
164
|
/**
|
|
144
165
|
* Overall dashboard stats.
|
|
145
166
|
*/
|
|
@@ -150,6 +171,7 @@ export interface DashboardStats {
|
|
|
150
171
|
timeSeries: TimeSeriesPoint[];
|
|
151
172
|
modelSeries: ModelTimeSeriesPoint[];
|
|
152
173
|
modelPerformanceSeries: ModelPerformancePoint[];
|
|
174
|
+
costSeries: CostTimeSeriesPoint[];
|
|
153
175
|
}
|
|
154
176
|
|
|
155
177
|
/**
|