@oh-my-pi/omp-stats 15.0.0 → 15.0.2
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 +6 -6
- package/src/client/components/BehaviorChart.tsx +85 -270
- package/src/client/components/BehaviorModelsTable.tsx +151 -274
- package/src/client/components/CostChart.tsx +85 -246
- package/src/client/components/ModelsTable.tsx +130 -246
- package/src/client/components/RequestDetail.tsx +0 -2
- package/src/client/components/chart-shared.tsx +320 -0
- package/src/client/components/models-table-shared.tsx +275 -0
- package/src/client/types.ts +21 -121
- package/src/db.ts +41 -1
- package/src/parser.ts +44 -5
- package/src/shared-types.ts +204 -0
- package/src/types.ts +16 -201
|
@@ -12,45 +12,30 @@ import {
|
|
|
12
12
|
Title,
|
|
13
13
|
Tooltip,
|
|
14
14
|
} from "chart.js";
|
|
15
|
-
import { format } from "date-fns";
|
|
16
15
|
import { useMemo, useState } from "react";
|
|
17
16
|
import { Bar, Line } from "react-chartjs-2";
|
|
18
17
|
import type { CostTimeSeriesPoint } from "../types";
|
|
19
18
|
import { useSystemTheme } from "../useSystemTheme";
|
|
19
|
+
import {
|
|
20
|
+
barDatasetStyle,
|
|
21
|
+
buildAggregateTimeSeries,
|
|
22
|
+
buildSharedPlugins,
|
|
23
|
+
buildSharedScales,
|
|
24
|
+
buildTopNByModelSeries,
|
|
25
|
+
CHART_THEMES,
|
|
26
|
+
ChartFrame,
|
|
27
|
+
type ChartSeries,
|
|
28
|
+
lineDatasetStyle,
|
|
29
|
+
MODEL_COLORS,
|
|
30
|
+
styleDatasets,
|
|
31
|
+
} from "./chart-shared";
|
|
20
32
|
|
|
21
33
|
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
"
|
|
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
|
-
},
|
|
35
|
+
/** Cost bar labels need a per-theme color that the generic chart theme doesn't carry. */
|
|
36
|
+
const BAR_LABEL_COLORS = {
|
|
37
|
+
dark: "rgba(248, 250, 252, 0.7)",
|
|
38
|
+
light: "rgba(15, 23, 42, 0.6)",
|
|
54
39
|
} as const;
|
|
55
40
|
|
|
56
41
|
interface CostChartProps {
|
|
@@ -83,6 +68,28 @@ function makeBarLabelPlugin(color: string): Plugin<"bar"> {
|
|
|
83
68
|
};
|
|
84
69
|
}
|
|
85
70
|
|
|
71
|
+
function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries {
|
|
72
|
+
return buildAggregateTimeSeries<CostTimeSeriesPoint, { total: number }>(points, "Cost", {
|
|
73
|
+
initBucket: () => ({ total: 0 }),
|
|
74
|
+
accumulate: (bucket, point) => {
|
|
75
|
+
bucket.total += point.cost;
|
|
76
|
+
},
|
|
77
|
+
bucketToValue: bucket => bucket.total,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildByModelSeries(points: CostTimeSeriesPoint[]): ChartSeries {
|
|
82
|
+
// Rank models by total cost; per-day buckets are simple cost sums.
|
|
83
|
+
return buildTopNByModelSeries<CostTimeSeriesPoint, { total: number }>(points, {
|
|
84
|
+
rankWeight: point => point.cost,
|
|
85
|
+
initBucket: () => ({ total: 0 }),
|
|
86
|
+
accumulate: (bucket, point) => {
|
|
87
|
+
bucket.total += point.cost;
|
|
88
|
+
},
|
|
89
|
+
bucketToValue: bucket => bucket.total,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
86
93
|
export function CostChart({ costSeries }: CostChartProps) {
|
|
87
94
|
const [byModel, setByModel] = useState(false);
|
|
88
95
|
const theme = useSystemTheme();
|
|
@@ -93,70 +100,28 @@ export function CostChart({ costSeries }: CostChartProps) {
|
|
|
93
100
|
[costSeries, byModel],
|
|
94
101
|
);
|
|
95
102
|
|
|
96
|
-
const sharedPlugins = {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
font: { size: 12 },
|
|
106
|
-
boxWidth: 8,
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
tooltip: {
|
|
110
|
-
backgroundColor: chartTheme.tooltipBackground,
|
|
111
|
-
titleColor: chartTheme.tooltipTitle,
|
|
112
|
-
bodyColor: chartTheme.tooltipBody,
|
|
113
|
-
borderColor: chartTheme.tooltipBorder,
|
|
114
|
-
borderWidth: 1,
|
|
115
|
-
padding: 12,
|
|
116
|
-
cornerRadius: 8,
|
|
117
|
-
callbacks: {
|
|
118
|
-
label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
|
119
|
-
const label = context.dataset.label ?? "Cost";
|
|
120
|
-
const value = context.parsed.y ?? 0;
|
|
121
|
-
return `${label}: $${Math.round(value)}`;
|
|
122
|
-
},
|
|
123
|
-
footer: (items: { parsed: { y: number | null } }[]) => {
|
|
124
|
-
if (!byModel || items.length < 2) return undefined;
|
|
125
|
-
const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
|
|
126
|
-
return `Total: $${Math.round(total)}`;
|
|
127
|
-
},
|
|
128
|
-
},
|
|
103
|
+
const sharedPlugins = buildSharedPlugins({
|
|
104
|
+
chartTheme,
|
|
105
|
+
showLegend: byModel,
|
|
106
|
+
defaultLabel: "Cost",
|
|
107
|
+
formatValue: v => `$${Math.round(v)}`,
|
|
108
|
+
footer: items => {
|
|
109
|
+
if (!byModel || items.length < 2) return undefined;
|
|
110
|
+
const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
|
|
111
|
+
return `Total: $${Math.round(total)}`;
|
|
129
112
|
},
|
|
130
|
-
};
|
|
113
|
+
});
|
|
131
114
|
|
|
132
|
-
const sharedScaleBase = {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const yScale = {
|
|
138
|
-
...sharedScaleBase,
|
|
139
|
-
ticks: {
|
|
140
|
-
...sharedScaleBase.ticks,
|
|
141
|
-
callback: (value: number | string) => `$${Math.round(Number(value))}`,
|
|
142
|
-
},
|
|
143
|
-
min: 0,
|
|
144
|
-
};
|
|
115
|
+
const { sharedScaleBase, yScale } = buildSharedScales({
|
|
116
|
+
chartTheme,
|
|
117
|
+
formatY: v => `$${Math.round(v)}`,
|
|
118
|
+
});
|
|
145
119
|
|
|
120
|
+
let chartNode: React.ReactNode;
|
|
146
121
|
if (byModel) {
|
|
147
122
|
const lineData = {
|
|
148
123
|
labels: chartData.labels,
|
|
149
|
-
datasets: chartData
|
|
150
|
-
label: ds.label,
|
|
151
|
-
data: ds.data,
|
|
152
|
-
borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
153
|
-
backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`,
|
|
154
|
-
fill: true,
|
|
155
|
-
tension: 0,
|
|
156
|
-
pointRadius: 3,
|
|
157
|
-
pointHoverRadius: 4,
|
|
158
|
-
borderWidth: 2,
|
|
159
|
-
})),
|
|
124
|
+
datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
160
125
|
};
|
|
161
126
|
|
|
162
127
|
const lineOptions: ChartOptions<"line"> = {
|
|
@@ -167,166 +132,40 @@ export function CostChart({ costSeries }: CostChartProps) {
|
|
|
167
132
|
scales: { x: sharedScaleBase, y: yScale },
|
|
168
133
|
};
|
|
169
134
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const barData = {
|
|
178
|
-
labels: chartData.labels,
|
|
179
|
-
datasets: chartData.datasets.map((ds, index) => ({
|
|
180
|
-
label: ds.label,
|
|
181
|
-
data: ds.data,
|
|
182
|
-
backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
183
|
-
borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
184
|
-
borderWidth: 0,
|
|
185
|
-
borderRadius: 3,
|
|
186
|
-
})),
|
|
187
|
-
};
|
|
135
|
+
chartNode = <Line data={lineData} options={lineOptions} />;
|
|
136
|
+
} else {
|
|
137
|
+
const barData = {
|
|
138
|
+
labels: chartData.labels,
|
|
139
|
+
datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
140
|
+
};
|
|
188
141
|
|
|
189
|
-
|
|
142
|
+
const barLabelPlugin = makeBarLabelPlugin(BAR_LABEL_COLORS[theme]);
|
|
190
143
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return (
|
|
204
|
-
<ChartWrapper byModel={byModel} onByModelChange={setByModel} empty={chartData.labels.length === 0}>
|
|
205
|
-
<Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />
|
|
206
|
-
</ChartWrapper>
|
|
207
|
-
);
|
|
208
|
-
}
|
|
144
|
+
const barOptions: ChartOptions<"bar"> = {
|
|
145
|
+
responsive: true,
|
|
146
|
+
maintainAspectRatio: false,
|
|
147
|
+
interaction: { mode: "index", intersect: false },
|
|
148
|
+
plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"],
|
|
149
|
+
scales: {
|
|
150
|
+
x: { ...sharedScaleBase, stacked: true },
|
|
151
|
+
y: { ...yScale, stacked: true },
|
|
152
|
+
},
|
|
153
|
+
layout: { padding: { top: 24 } },
|
|
154
|
+
};
|
|
209
155
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
onByModelChange: (v: boolean) => void;
|
|
213
|
-
empty: boolean;
|
|
214
|
-
children: React.ReactNode;
|
|
215
|
-
}
|
|
156
|
+
chartNode = <Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />;
|
|
157
|
+
}
|
|
216
158
|
|
|
217
|
-
function ChartWrapper({ byModel, onByModelChange, empty, children }: ChartWrapperProps) {
|
|
218
159
|
return (
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
onClick={() => onByModelChange(false)}
|
|
230
|
-
className={`tab-btn text-xs ${!byModel ? "active" : ""}`}
|
|
231
|
-
>
|
|
232
|
-
All Models
|
|
233
|
-
</button>
|
|
234
|
-
<button
|
|
235
|
-
type="button"
|
|
236
|
-
onClick={() => onByModelChange(true)}
|
|
237
|
-
className={`tab-btn text-xs ${byModel ? "active" : ""}`}
|
|
238
|
-
>
|
|
239
|
-
By Model
|
|
240
|
-
</button>
|
|
241
|
-
</div>
|
|
242
|
-
</div>
|
|
243
|
-
</div>
|
|
244
|
-
<div className="p-5 min-h-[320px]">
|
|
245
|
-
{empty ? (
|
|
246
|
-
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
|
|
247
|
-
No cost data available
|
|
248
|
-
</div>
|
|
249
|
-
) : (
|
|
250
|
-
<div className="h-[280px]">{children}</div>
|
|
251
|
-
)}
|
|
252
|
-
</div>
|
|
253
|
-
</div>
|
|
160
|
+
<ChartFrame
|
|
161
|
+
title="Daily Cost"
|
|
162
|
+
subtitle="API spending over time"
|
|
163
|
+
empty={chartData.labels.length === 0}
|
|
164
|
+
emptyMessage="No cost data available"
|
|
165
|
+
byModel={byModel}
|
|
166
|
+
onByModelChange={setByModel}
|
|
167
|
+
>
|
|
168
|
+
{chartNode}
|
|
169
|
+
</ChartFrame>
|
|
254
170
|
);
|
|
255
171
|
}
|
|
256
|
-
|
|
257
|
-
interface ChartSeries {
|
|
258
|
-
labels: string[];
|
|
259
|
-
datasets: Array<{ label: string; data: number[] }>;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries {
|
|
263
|
-
if (points.length === 0) return { labels: [], datasets: [] };
|
|
264
|
-
|
|
265
|
-
const byDay = new Map<number, number>();
|
|
266
|
-
for (const point of points) {
|
|
267
|
-
byDay.set(point.timestamp, (byDay.get(point.timestamp) ?? 0) + point.cost);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]);
|
|
271
|
-
return {
|
|
272
|
-
labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")),
|
|
273
|
-
datasets: [{ label: "Cost", data: sorted.map(([, cost]) => cost) }],
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function buildByModelSeries(points: CostTimeSeriesPoint[], topN = 5): ChartSeries {
|
|
278
|
-
if (points.length === 0) return { labels: [], datasets: [] };
|
|
279
|
-
|
|
280
|
-
// Rank models by total cost
|
|
281
|
-
const totals = new Map<string, { model: string; provider: string; total: number }>();
|
|
282
|
-
for (const point of points) {
|
|
283
|
-
const key = `${point.model}::${point.provider}`;
|
|
284
|
-
const existing = totals.get(key);
|
|
285
|
-
if (existing) {
|
|
286
|
-
existing.total += point.cost;
|
|
287
|
-
} else {
|
|
288
|
-
totals.set(key, { model: point.model, provider: point.provider, total: point.cost });
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const sorted = [...totals.entries()].sort((a, b) => b[1].total - a[1].total);
|
|
293
|
-
const topEntries = sorted.slice(0, topN);
|
|
294
|
-
const topKeys = new Set(topEntries.map(([key]) => key));
|
|
295
|
-
|
|
296
|
-
// Disambiguate model labels when same model name appears from multiple providers
|
|
297
|
-
const modelCount = new Map<string, number>();
|
|
298
|
-
for (const [, { model }] of topEntries) {
|
|
299
|
-
modelCount.set(model, (modelCount.get(model) ?? 0) + 1);
|
|
300
|
-
}
|
|
301
|
-
const labelByKey = new Map<string, string>();
|
|
302
|
-
for (const [key, { model, provider }] of topEntries) {
|
|
303
|
-
labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Collect all day buckets
|
|
307
|
-
const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
|
|
308
|
-
|
|
309
|
-
// Build per-day, per-series totals
|
|
310
|
-
const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key);
|
|
311
|
-
const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`));
|
|
312
|
-
if (hasOther) seriesNames.push("Other");
|
|
313
|
-
|
|
314
|
-
const dayMap = new Map<number, Record<string, number>>();
|
|
315
|
-
for (const day of allDays) {
|
|
316
|
-
dayMap.set(day, {});
|
|
317
|
-
}
|
|
318
|
-
for (const point of points) {
|
|
319
|
-
const key = `${point.model}::${point.provider}`;
|
|
320
|
-
const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other";
|
|
321
|
-
const row = dayMap.get(point.timestamp)!;
|
|
322
|
-
row[label] = (row[label] ?? 0) + point.cost;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
return {
|
|
326
|
-
labels: allDays.map(ts => format(new Date(ts), "MMM d")),
|
|
327
|
-
datasets: seriesNames.map(name => ({
|
|
328
|
-
label: name,
|
|
329
|
-
data: allDays.map(day => dayMap.get(day)?.[name] ?? 0),
|
|
330
|
-
})),
|
|
331
|
-
};
|
|
332
|
-
}
|