@oh-my-pi/omp-stats 17.1.1 → 17.1.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/CHANGELOG.md +6 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +91 -91
- package/dist/client/styles.css +6 -0
- package/dist/types/aggregator.d.ts +7 -1
- package/dist/types/client/api.d.ts +2 -1
- package/dist/types/client/app/routes.d.ts +1 -1
- package/dist/types/client/routes/ProvidersRoute.d.ts +7 -0
- package/dist/types/client/routes/index.d.ts +1 -0
- package/dist/types/db.d.ts +15 -1
- package/dist/types/shared-types.d.ts +102 -0
- package/dist/types/usage-windows.d.ts +45 -0
- package/package.json +4 -4
- package/src/aggregator.ts +33 -1
- package/src/client/App.tsx +3 -0
- package/src/client/api.ts +10 -0
- package/src/client/app/routes.ts +18 -1
- package/src/client/data/useHashRoute.ts +1 -0
- package/src/client/routes/ProvidersRoute.tsx +492 -0
- package/src/client/routes/index.ts +1 -0
- package/src/client/styles.css +1 -0
- package/src/db.ts +141 -0
- package/src/server.ts +6 -0
- package/src/shared-types.ts +109 -0
- package/src/usage-windows.ts +303 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { Bar } from "react-chartjs-2";
|
|
3
|
+
import { getProviderDashboardStats } from "../api";
|
|
4
|
+
import {
|
|
5
|
+
barDatasetStyle,
|
|
6
|
+
buildSharedPlugins,
|
|
7
|
+
buildSharedScales,
|
|
8
|
+
buildTopNByModelSeries,
|
|
9
|
+
CHART_THEMES,
|
|
10
|
+
MODEL_COLORS,
|
|
11
|
+
styleDatasets,
|
|
12
|
+
} from "../components/chart-shared";
|
|
13
|
+
import {
|
|
14
|
+
formatCompact,
|
|
15
|
+
formatCost,
|
|
16
|
+
formatInteger,
|
|
17
|
+
formatPercent,
|
|
18
|
+
formatRelativeTime,
|
|
19
|
+
formatTokensPerSecond,
|
|
20
|
+
} from "../data/formatters";
|
|
21
|
+
import { useResource } from "../data/useResource";
|
|
22
|
+
import type {
|
|
23
|
+
ProviderAggregate,
|
|
24
|
+
ProviderDashboardStats,
|
|
25
|
+
ProviderHourlyPoint,
|
|
26
|
+
ProviderWindowInsight,
|
|
27
|
+
TimeRange,
|
|
28
|
+
UsageWindowSeries,
|
|
29
|
+
} from "../types";
|
|
30
|
+
import { AsyncBoundary, DataTable, type DataTableColumn, EmptyState, Panel, SegmentedControl } from "../ui";
|
|
31
|
+
import { useSystemTheme } from "../useSystemTheme";
|
|
32
|
+
|
|
33
|
+
export interface ProvidersRouteProps {
|
|
34
|
+
active: boolean;
|
|
35
|
+
range: TimeRange;
|
|
36
|
+
refreshTrigger: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ProvidersRoute({ active, range, refreshTrigger }: ProvidersRouteProps) {
|
|
40
|
+
const {
|
|
41
|
+
data: stats,
|
|
42
|
+
error,
|
|
43
|
+
loading,
|
|
44
|
+
} = useResource(["providers", range, refreshTrigger], signal => getProviderDashboardStats(range, signal), {
|
|
45
|
+
pollMs: 30000,
|
|
46
|
+
enabled: active,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="stats-route-container space-y-6">
|
|
51
|
+
<AsyncBoundary loading={loading} error={error} data={stats}>
|
|
52
|
+
{stats && (
|
|
53
|
+
<>
|
|
54
|
+
<ProviderTotalsPanel providers={stats.providers} />
|
|
55
|
+
<ProviderTrendPanel stats={stats} />
|
|
56
|
+
<PeakHoursPanel hourly={stats.hourly} providers={stats.providers} />
|
|
57
|
+
<WindowInsightsPanel insights={stats.windowInsights} />
|
|
58
|
+
<WindowUtilizationPanel usageSeries={stats.usageSeries} />
|
|
59
|
+
</>
|
|
60
|
+
)}
|
|
61
|
+
</AsyncBoundary>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Provider totals
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
function ProviderTotalsPanel({ providers }: { providers: ProviderAggregate[] }) {
|
|
71
|
+
const grandTotal = useMemo(() => providers.reduce((sum, p) => sum + p.totalTokens, 0), [providers]);
|
|
72
|
+
|
|
73
|
+
const columns: DataTableColumn<ProviderAggregate>[] = [
|
|
74
|
+
{ key: "provider", header: "Provider", render: p => <span className="font-medium">{p.provider}</span> },
|
|
75
|
+
{ key: "requests", header: "Requests", numeric: true, render: p => formatInteger(p.totalRequests) },
|
|
76
|
+
{
|
|
77
|
+
key: "errors",
|
|
78
|
+
header: "Error Rate",
|
|
79
|
+
numeric: true,
|
|
80
|
+
render: p => formatPercent(p.totalRequests > 0 ? p.failedRequests / p.totalRequests : 0),
|
|
81
|
+
},
|
|
82
|
+
{ key: "models", header: "Models", numeric: true, render: p => formatInteger(p.models) },
|
|
83
|
+
{
|
|
84
|
+
key: "tokens",
|
|
85
|
+
header: "Tokens",
|
|
86
|
+
numeric: true,
|
|
87
|
+
render: p => (
|
|
88
|
+
<span
|
|
89
|
+
title={`Input ${formatCompact(p.totalInputTokens)} · Output ${formatCompact(p.totalOutputTokens)} · Cache read ${formatCompact(p.totalCacheReadTokens)} · Cache write ${formatCompact(p.totalCacheWriteTokens)}`}
|
|
90
|
+
>
|
|
91
|
+
{formatCompact(p.totalTokens)}
|
|
92
|
+
</span>
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: "share",
|
|
97
|
+
header: "Share",
|
|
98
|
+
numeric: true,
|
|
99
|
+
render: p => formatPercent(grandTotal > 0 ? p.totalTokens / grandTotal : 0),
|
|
100
|
+
},
|
|
101
|
+
{ key: "cost", header: "Cost", numeric: true, render: p => formatCost(p.totalCost) },
|
|
102
|
+
{ key: "tps", header: "Tok/s", numeric: true, render: p => formatTokensPerSecond(p.avgTokensPerSecond) },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<Panel title="Provider Totals" subtitle="Token, request, and cost totals per provider over the active range">
|
|
107
|
+
<DataTable
|
|
108
|
+
columns={columns}
|
|
109
|
+
data={providers}
|
|
110
|
+
keyExtractor={p => p.provider}
|
|
111
|
+
emptyText="No requests recorded in this range"
|
|
112
|
+
/>
|
|
113
|
+
</Panel>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Token / cost trend by provider
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
function ProviderTrendPanel({ stats }: { stats: ProviderDashboardStats }) {
|
|
122
|
+
const [metric, setMetric] = useState<"tokens" | "cost">("tokens");
|
|
123
|
+
const theme = useSystemTheme();
|
|
124
|
+
const chartTheme = CHART_THEMES[theme];
|
|
125
|
+
|
|
126
|
+
// buildTopNByModelSeries keys on `model`; feed it the provider name so we
|
|
127
|
+
// get the same top-N + "Other" rollup without a parallel implementation.
|
|
128
|
+
const chartData = useMemo(() => {
|
|
129
|
+
const points = stats.series.map(p => ({ ...p, model: p.provider }));
|
|
130
|
+
return buildTopNByModelSeries<(typeof points)[number], { total: number }>(points, {
|
|
131
|
+
topN: 6,
|
|
132
|
+
rankWeight: p => (metric === "tokens" ? p.totalTokens : p.cost),
|
|
133
|
+
initBucket: () => ({ total: 0 }),
|
|
134
|
+
accumulate: (bucket, p) => {
|
|
135
|
+
bucket.total += metric === "tokens" ? p.totalTokens : p.cost;
|
|
136
|
+
},
|
|
137
|
+
bucketToValue: bucket => bucket.total,
|
|
138
|
+
});
|
|
139
|
+
}, [stats.series, metric]);
|
|
140
|
+
|
|
141
|
+
const formatValue = metric === "tokens" ? formatCompact : (v: number) => formatCost(v);
|
|
142
|
+
const options = useMemo(() => {
|
|
143
|
+
const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatValue });
|
|
144
|
+
return {
|
|
145
|
+
responsive: true,
|
|
146
|
+
maintainAspectRatio: false,
|
|
147
|
+
interaction: { mode: "index" as const, intersect: false },
|
|
148
|
+
plugins: buildSharedPlugins({
|
|
149
|
+
chartTheme,
|
|
150
|
+
showLegend: true,
|
|
151
|
+
defaultLabel: metric === "tokens" ? "Tokens" : "Cost",
|
|
152
|
+
formatValue,
|
|
153
|
+
footer: items => {
|
|
154
|
+
if (items.length < 2) return undefined;
|
|
155
|
+
const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
|
|
156
|
+
return `Total: ${formatValue(total)}`;
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
scales: {
|
|
160
|
+
x: { ...sharedScaleBase, stacked: true },
|
|
161
|
+
y: { ...yScale, stacked: true },
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}, [chartTheme, metric, formatValue]);
|
|
165
|
+
|
|
166
|
+
const data = useMemo(
|
|
167
|
+
() => ({
|
|
168
|
+
labels: chartData.labels,
|
|
169
|
+
datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
170
|
+
}),
|
|
171
|
+
[chartData],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Panel
|
|
176
|
+
title="Burn by Provider"
|
|
177
|
+
subtitle="Stacked token/cost burn per provider over time"
|
|
178
|
+
actions={
|
|
179
|
+
<SegmentedControl
|
|
180
|
+
options={[
|
|
181
|
+
{ value: "tokens" as const, label: "Tokens" },
|
|
182
|
+
{ value: "cost" as const, label: "Cost" },
|
|
183
|
+
]}
|
|
184
|
+
value={metric}
|
|
185
|
+
onChange={setMetric}
|
|
186
|
+
/>
|
|
187
|
+
}
|
|
188
|
+
>
|
|
189
|
+
<div className="h-[300px]">
|
|
190
|
+
{chartData.labels.length === 0 ? (
|
|
191
|
+
<EmptyState message="No provider activity in this range" />
|
|
192
|
+
) : (
|
|
193
|
+
<Bar data={data} options={options} />
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
</Panel>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Peak burn hours
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
const ALL_PROVIDERS = "__all__";
|
|
205
|
+
|
|
206
|
+
function PeakHoursPanel({ hourly, providers }: { hourly: ProviderHourlyPoint[]; providers: ProviderAggregate[] }) {
|
|
207
|
+
const [provider, setProvider] = useState(ALL_PROVIDERS);
|
|
208
|
+
const theme = useSystemTheme();
|
|
209
|
+
const chartTheme = CHART_THEMES[theme];
|
|
210
|
+
|
|
211
|
+
const { tokensByHour, peakHour } = useMemo(() => {
|
|
212
|
+
const tokens = new Array<number>(24).fill(0);
|
|
213
|
+
for (const point of hourly) {
|
|
214
|
+
if (provider !== ALL_PROVIDERS && point.provider !== provider) continue;
|
|
215
|
+
tokens[point.hour] += point.totalTokens;
|
|
216
|
+
}
|
|
217
|
+
let peak = 0;
|
|
218
|
+
for (let hour = 1; hour < 24; hour++) {
|
|
219
|
+
if (tokens[hour] > tokens[peak]) peak = hour;
|
|
220
|
+
}
|
|
221
|
+
return { tokensByHour: tokens, peakHour: peak };
|
|
222
|
+
}, [hourly, provider]);
|
|
223
|
+
|
|
224
|
+
const hasData = tokensByHour.some(v => v > 0);
|
|
225
|
+
|
|
226
|
+
const data = useMemo(
|
|
227
|
+
() => ({
|
|
228
|
+
labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, "0")}:00`),
|
|
229
|
+
datasets: [
|
|
230
|
+
{
|
|
231
|
+
label: "Tokens",
|
|
232
|
+
data: tokensByHour,
|
|
233
|
+
...barDatasetStyle(MODEL_COLORS[2]),
|
|
234
|
+
// Highlight the peak hour in the brand accent color.
|
|
235
|
+
backgroundColor: tokensByHour.map((_, hour) => (hour === peakHour ? MODEL_COLORS[0] : MODEL_COLORS[2])),
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
}),
|
|
239
|
+
[tokensByHour, peakHour],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const options = useMemo(() => {
|
|
243
|
+
const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatCompact });
|
|
244
|
+
return {
|
|
245
|
+
responsive: true,
|
|
246
|
+
maintainAspectRatio: false,
|
|
247
|
+
plugins: buildSharedPlugins({
|
|
248
|
+
chartTheme,
|
|
249
|
+
showLegend: false,
|
|
250
|
+
defaultLabel: "Tokens",
|
|
251
|
+
formatValue: formatCompact,
|
|
252
|
+
}),
|
|
253
|
+
scales: { x: sharedScaleBase, y: yScale },
|
|
254
|
+
};
|
|
255
|
+
}, [chartTheme]);
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<Panel
|
|
259
|
+
title="Peak Burn Hours"
|
|
260
|
+
subtitle={
|
|
261
|
+
hasData
|
|
262
|
+
? `Token burn by local hour of day — peak at ${String(peakHour).padStart(2, "0")}:00`
|
|
263
|
+
: "Token burn by local hour of day"
|
|
264
|
+
}
|
|
265
|
+
actions={
|
|
266
|
+
<select
|
|
267
|
+
className="stats-select"
|
|
268
|
+
value={provider}
|
|
269
|
+
onChange={e => setProvider(e.target.value)}
|
|
270
|
+
aria-label="Provider"
|
|
271
|
+
>
|
|
272
|
+
<option value={ALL_PROVIDERS}>All providers</option>
|
|
273
|
+
{providers.map(p => (
|
|
274
|
+
<option key={p.provider} value={p.provider}>
|
|
275
|
+
{p.provider}
|
|
276
|
+
</option>
|
|
277
|
+
))}
|
|
278
|
+
</select>
|
|
279
|
+
}
|
|
280
|
+
>
|
|
281
|
+
<div className="h-[260px]">
|
|
282
|
+
{hasData ? <Bar data={data} options={options} /> : <EmptyState message="No activity in this range" />}
|
|
283
|
+
</div>
|
|
284
|
+
</Panel>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// Subscription window insights
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
function WindowInsightsPanel({ insights }: { insights: ProviderWindowInsight[] }) {
|
|
293
|
+
const columns: DataTableColumn<ProviderWindowInsight>[] = [
|
|
294
|
+
{ key: "provider", header: "Provider", render: i => <span className="font-medium">{i.provider}</span> },
|
|
295
|
+
{ key: "window", header: "Window", render: i => i.windowLabel },
|
|
296
|
+
{ key: "accounts", header: "Accounts", numeric: true, render: i => formatInteger(i.accounts) },
|
|
297
|
+
{
|
|
298
|
+
key: "consumed",
|
|
299
|
+
header: "Windows Burned",
|
|
300
|
+
numeric: true,
|
|
301
|
+
render: i => (
|
|
302
|
+
<span title="Subscription-window equivalents consumed in range (sum of used-fraction increases across accounts)">
|
|
303
|
+
{i.fractionConsumed.toFixed(2)}
|
|
304
|
+
</span>
|
|
305
|
+
),
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
key: "capacity",
|
|
309
|
+
header: "Est. Tokens / Window",
|
|
310
|
+
numeric: true,
|
|
311
|
+
render: i => (
|
|
312
|
+
<span title="Provider tokens burned in range ÷ windows burned — what one full window is worth">
|
|
313
|
+
{i.estTokensPerWindow !== null ? formatCompact(i.estTokensPerWindow) : "—"}
|
|
314
|
+
</span>
|
|
315
|
+
),
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
key: "peak",
|
|
319
|
+
header: "Peak Utilization",
|
|
320
|
+
numeric: true,
|
|
321
|
+
render: i => (
|
|
322
|
+
<span title="Peak of summed used fraction across accounts at any sampled instant">
|
|
323
|
+
{formatPercent(i.peakConcurrentFraction)}
|
|
324
|
+
</span>
|
|
325
|
+
),
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
key: "ideal",
|
|
329
|
+
header: "Ideal Accounts",
|
|
330
|
+
numeric: true,
|
|
331
|
+
render: i => (
|
|
332
|
+
<span
|
|
333
|
+
title="Accounts needed to keep peak demand under 90% of fleet capacity"
|
|
334
|
+
className={i.idealAccounts > i.accounts ? "stats-text-warning font-semibold" : undefined}
|
|
335
|
+
>
|
|
336
|
+
{formatInteger(i.idealAccounts)}
|
|
337
|
+
{i.idealAccounts > i.accounts ? ` (have ${i.accounts})` : ""}
|
|
338
|
+
</span>
|
|
339
|
+
),
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
key: "exhausted",
|
|
343
|
+
header: "Exhaustions",
|
|
344
|
+
numeric: true,
|
|
345
|
+
render: i => (
|
|
346
|
+
<span className={i.exhaustedEvents > 0 ? "stats-text-warning" : undefined}>
|
|
347
|
+
{formatInteger(i.exhaustedEvents)}
|
|
348
|
+
</span>
|
|
349
|
+
),
|
|
350
|
+
},
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<Panel
|
|
355
|
+
title="Subscription Windows"
|
|
356
|
+
subtitle="What each usage window buys you, and how many accounts peak demand needs"
|
|
357
|
+
>
|
|
358
|
+
<DataTable
|
|
359
|
+
columns={columns}
|
|
360
|
+
data={insights}
|
|
361
|
+
keyExtractor={i => `${i.provider}::${i.windowKey}`}
|
|
362
|
+
emptyText="No usage snapshots recorded yet — they accumulate whenever usage is fetched (TUI footer, /usage, omp usage)"
|
|
363
|
+
/>
|
|
364
|
+
</Panel>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Window utilization
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
const UTILIZATION_COLORS = {
|
|
373
|
+
ok: "#62d394",
|
|
374
|
+
warning: "#f5c14b",
|
|
375
|
+
exhausted: "#ff6b7d",
|
|
376
|
+
} as const;
|
|
377
|
+
|
|
378
|
+
function WindowUtilizationPanel({ usageSeries }: { usageSeries: UsageWindowSeries[] }) {
|
|
379
|
+
const providers = useMemo(() => [...new Set(usageSeries.map(s => s.provider))], [usageSeries]);
|
|
380
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
381
|
+
const provider = selected !== null && providers.includes(selected) ? selected : (providers[0] ?? null);
|
|
382
|
+
const theme = useSystemTheme();
|
|
383
|
+
const chartTheme = CHART_THEMES[theme];
|
|
384
|
+
|
|
385
|
+
// One row per (window, account): the latest recorded fraction. Snapshot
|
|
386
|
+
// history is bursty (rows appear whenever usage is fetched), so a "how full
|
|
387
|
+
// is each window right now" bar reads far better than a time axis.
|
|
388
|
+
const rows = useMemo(() => {
|
|
389
|
+
return usageSeries
|
|
390
|
+
.filter(s => s.provider === provider)
|
|
391
|
+
.map(s => {
|
|
392
|
+
const latest = [...s.points].reverse().find(p => p.usedFraction !== null);
|
|
393
|
+
return latest
|
|
394
|
+
? {
|
|
395
|
+
label: `${s.windowLabel} · ${s.accountLabel}`,
|
|
396
|
+
fraction: latest.usedFraction ?? 0,
|
|
397
|
+
exhausted: latest.exhausted,
|
|
398
|
+
recordedAt: latest.timestamp,
|
|
399
|
+
}
|
|
400
|
+
: null;
|
|
401
|
+
})
|
|
402
|
+
.filter(row => row !== null)
|
|
403
|
+
.sort((a, b) => b.fraction - a.fraction);
|
|
404
|
+
}, [usageSeries, provider]);
|
|
405
|
+
|
|
406
|
+
const data = useMemo(
|
|
407
|
+
() => ({
|
|
408
|
+
labels: rows.map(r => r.label),
|
|
409
|
+
datasets: [
|
|
410
|
+
{
|
|
411
|
+
label: "Used",
|
|
412
|
+
data: rows.map(r => r.fraction * 100),
|
|
413
|
+
backgroundColor: rows.map(r =>
|
|
414
|
+
r.exhausted
|
|
415
|
+
? UTILIZATION_COLORS.exhausted
|
|
416
|
+
: r.fraction >= 0.8
|
|
417
|
+
? UTILIZATION_COLORS.warning
|
|
418
|
+
: UTILIZATION_COLORS.ok,
|
|
419
|
+
),
|
|
420
|
+
borderWidth: 0,
|
|
421
|
+
borderRadius: 4,
|
|
422
|
+
barThickness: 18,
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
}),
|
|
426
|
+
[rows],
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const options = useMemo(() => {
|
|
430
|
+
const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: v => `${Math.round(v)}%` });
|
|
431
|
+
const xMax = Math.max(100, ...rows.map(r => r.fraction * 100));
|
|
432
|
+
const shared = buildSharedPlugins({
|
|
433
|
+
chartTheme,
|
|
434
|
+
showLegend: false,
|
|
435
|
+
defaultLabel: "Used",
|
|
436
|
+
formatValue: v => `${v.toFixed(1)}%`,
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
indexAxis: "y" as const,
|
|
440
|
+
responsive: true,
|
|
441
|
+
maintainAspectRatio: false,
|
|
442
|
+
plugins: {
|
|
443
|
+
...shared,
|
|
444
|
+
tooltip: {
|
|
445
|
+
...shared.tooltip,
|
|
446
|
+
callbacks: {
|
|
447
|
+
label: (ctx: { dataIndex: number; parsed: { x: number | null } }) => {
|
|
448
|
+
const row = rows[ctx.dataIndex];
|
|
449
|
+
const used = `${(ctx.parsed.x ?? 0).toFixed(1)}% used`;
|
|
450
|
+
return row ? `${used} · recorded ${formatRelativeTime(row.recordedAt)}` : used;
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
scales: {
|
|
456
|
+
x: { ...yScale, max: xMax },
|
|
457
|
+
y: { ...sharedScaleBase, grid: { display: false } },
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}, [chartTheme, rows]);
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<Panel
|
|
464
|
+
title="Window Utilization"
|
|
465
|
+
subtitle="Latest recorded limit utilization per account and window — red bars are exhausted, amber above 80%"
|
|
466
|
+
actions={
|
|
467
|
+
providers.length > 1 ? (
|
|
468
|
+
<select
|
|
469
|
+
className="stats-select"
|
|
470
|
+
value={provider ?? ""}
|
|
471
|
+
onChange={e => setSelected(e.target.value)}
|
|
472
|
+
aria-label="Provider"
|
|
473
|
+
>
|
|
474
|
+
{providers.map(p => (
|
|
475
|
+
<option key={p} value={p}>
|
|
476
|
+
{p}
|
|
477
|
+
</option>
|
|
478
|
+
))}
|
|
479
|
+
</select>
|
|
480
|
+
) : undefined
|
|
481
|
+
}
|
|
482
|
+
>
|
|
483
|
+
<div style={{ height: Math.max(160, rows.length * 34 + 60) }}>
|
|
484
|
+
{rows.length === 0 ? (
|
|
485
|
+
<EmptyState message="No usage snapshots recorded yet — they accumulate whenever usage is fetched" />
|
|
486
|
+
) : (
|
|
487
|
+
<Bar data={data} options={options} />
|
|
488
|
+
)}
|
|
489
|
+
</div>
|
|
490
|
+
</Panel>
|
|
491
|
+
);
|
|
492
|
+
}
|
package/src/client/styles.css
CHANGED
|
@@ -1172,6 +1172,7 @@
|
|
|
1172
1172
|
.stats-text-primary { color: var(--text); }
|
|
1173
1173
|
.stats-text-secondary { color: var(--muted); }
|
|
1174
1174
|
.stats-text-muted { color: var(--dim); }
|
|
1175
|
+
.stats-text-warning { color: var(--warning); }
|
|
1175
1176
|
.stats-text-xs { font-size: 12px; }
|
|
1176
1177
|
.stats-font-medium { font-weight: 500; }
|
|
1177
1178
|
.stats-font-semibold { font-weight: 600; }
|
package/src/db.ts
CHANGED
|
@@ -18,6 +18,9 @@ import type {
|
|
|
18
18
|
ModelPerformancePoint,
|
|
19
19
|
ModelStats,
|
|
20
20
|
ModelTimeSeriesPoint,
|
|
21
|
+
ProviderAggregate,
|
|
22
|
+
ProviderHourlyPoint,
|
|
23
|
+
ProviderTimeSeriesPoint,
|
|
21
24
|
TimeSeriesPoint,
|
|
22
25
|
ToolCallStats,
|
|
23
26
|
ToolModelStats,
|
|
@@ -723,6 +726,144 @@ export function getModelTimeSeries(
|
|
|
723
726
|
}));
|
|
724
727
|
}
|
|
725
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Get request/token/cost totals grouped by provider.
|
|
731
|
+
*/
|
|
732
|
+
export function getStatsByProvider(cutoff?: number | null): ProviderAggregate[] {
|
|
733
|
+
if (!db) return [];
|
|
734
|
+
|
|
735
|
+
const hasCutoff = cutoff !== undefined && cutoff !== null && cutoff > 0;
|
|
736
|
+
const stmt = db.prepare(`
|
|
737
|
+
SELECT
|
|
738
|
+
provider,
|
|
739
|
+
COUNT(*) as total_requests,
|
|
740
|
+
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
|
|
741
|
+
COUNT(DISTINCT model) as models,
|
|
742
|
+
SUM(input_tokens) as total_input_tokens,
|
|
743
|
+
SUM(output_tokens) as total_output_tokens,
|
|
744
|
+
SUM(cache_read_tokens) as total_cache_read_tokens,
|
|
745
|
+
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
746
|
+
SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
|
|
747
|
+
SUM(cost_total) as total_cost,
|
|
748
|
+
SUM(premium_requests) as total_premium_requests,
|
|
749
|
+
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
|
|
750
|
+
FROM messages
|
|
751
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
752
|
+
GROUP BY provider
|
|
753
|
+
ORDER BY total_tokens DESC
|
|
754
|
+
`);
|
|
755
|
+
|
|
756
|
+
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as Array<{
|
|
757
|
+
provider: string;
|
|
758
|
+
total_requests: number;
|
|
759
|
+
failed_requests: number;
|
|
760
|
+
models: number;
|
|
761
|
+
total_input_tokens: number | null;
|
|
762
|
+
total_output_tokens: number | null;
|
|
763
|
+
total_cache_read_tokens: number | null;
|
|
764
|
+
total_cache_write_tokens: number | null;
|
|
765
|
+
total_tokens: number | null;
|
|
766
|
+
total_cost: number | null;
|
|
767
|
+
total_premium_requests: number | null;
|
|
768
|
+
avg_tokens_per_second: number | null;
|
|
769
|
+
}>;
|
|
770
|
+
return rows.map(row => ({
|
|
771
|
+
provider: row.provider,
|
|
772
|
+
totalRequests: row.total_requests,
|
|
773
|
+
failedRequests: row.failed_requests,
|
|
774
|
+
models: row.models,
|
|
775
|
+
totalInputTokens: row.total_input_tokens ?? 0,
|
|
776
|
+
totalOutputTokens: row.total_output_tokens ?? 0,
|
|
777
|
+
totalCacheReadTokens: row.total_cache_read_tokens ?? 0,
|
|
778
|
+
totalCacheWriteTokens: row.total_cache_write_tokens ?? 0,
|
|
779
|
+
totalTokens: row.total_tokens ?? 0,
|
|
780
|
+
totalCost: row.total_cost ?? 0,
|
|
781
|
+
totalPremiumRequests: row.total_premium_requests ?? 0,
|
|
782
|
+
avgTokensPerSecond: row.avg_tokens_per_second,
|
|
783
|
+
}));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Get token burn grouped by provider and local hour of day (0-23).
|
|
788
|
+
* Hours use the server's timezone — the dashboard is a localhost tool, so
|
|
789
|
+
* server-local and viewer-local time coincide.
|
|
790
|
+
*/
|
|
791
|
+
export function getProviderHourlyBurn(cutoff?: number | null): ProviderHourlyPoint[] {
|
|
792
|
+
if (!db) return [];
|
|
793
|
+
|
|
794
|
+
const hasCutoff = cutoff !== undefined && cutoff !== null && cutoff > 0;
|
|
795
|
+
const stmt = db.prepare(`
|
|
796
|
+
SELECT
|
|
797
|
+
provider,
|
|
798
|
+
CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER) as hour,
|
|
799
|
+
SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
|
|
800
|
+
SUM(output_tokens) as output_tokens,
|
|
801
|
+
COUNT(*) as requests
|
|
802
|
+
FROM messages
|
|
803
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
804
|
+
GROUP BY provider, hour
|
|
805
|
+
ORDER BY provider, hour
|
|
806
|
+
`);
|
|
807
|
+
|
|
808
|
+
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as Array<{
|
|
809
|
+
provider: string;
|
|
810
|
+
hour: number;
|
|
811
|
+
total_tokens: number | null;
|
|
812
|
+
output_tokens: number | null;
|
|
813
|
+
requests: number;
|
|
814
|
+
}>;
|
|
815
|
+
return rows.map(row => ({
|
|
816
|
+
provider: row.provider,
|
|
817
|
+
hour: row.hour,
|
|
818
|
+
totalTokens: row.total_tokens ?? 0,
|
|
819
|
+
outputTokens: row.output_tokens ?? 0,
|
|
820
|
+
requests: row.requests,
|
|
821
|
+
}));
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Get token/cost time series grouped by provider (bucketed like the model series).
|
|
826
|
+
*/
|
|
827
|
+
export function getProviderTimeSeries(
|
|
828
|
+
days = 14,
|
|
829
|
+
cutoff?: number | null,
|
|
830
|
+
bucketMs = 24 * 60 * 60 * 1000,
|
|
831
|
+
): ProviderTimeSeriesPoint[] {
|
|
832
|
+
if (!db) return [];
|
|
833
|
+
|
|
834
|
+
const hasCutoff = cutoff !== null;
|
|
835
|
+
const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
|
|
836
|
+
|
|
837
|
+
const stmt = db.prepare(`
|
|
838
|
+
SELECT
|
|
839
|
+
(timestamp / ?) * ? as bucket,
|
|
840
|
+
provider,
|
|
841
|
+
SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
|
|
842
|
+
SUM(cost_total) as cost,
|
|
843
|
+
COUNT(*) as requests
|
|
844
|
+
FROM messages
|
|
845
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
846
|
+
GROUP BY bucket, provider
|
|
847
|
+
ORDER BY bucket ASC
|
|
848
|
+
`);
|
|
849
|
+
|
|
850
|
+
const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
|
|
851
|
+
const rows = rowsRaw as Array<{
|
|
852
|
+
bucket: number;
|
|
853
|
+
provider: string;
|
|
854
|
+
total_tokens: number | null;
|
|
855
|
+
cost: number | null;
|
|
856
|
+
requests: number;
|
|
857
|
+
}>;
|
|
858
|
+
return rows.map(row => ({
|
|
859
|
+
timestamp: row.bucket,
|
|
860
|
+
provider: row.provider,
|
|
861
|
+
totalTokens: row.total_tokens ?? 0,
|
|
862
|
+
cost: row.cost ?? 0,
|
|
863
|
+
requests: row.requests,
|
|
864
|
+
}));
|
|
865
|
+
}
|
|
866
|
+
|
|
726
867
|
/**
|
|
727
868
|
* Get daily model performance time series data for the last N days.
|
|
728
869
|
*/
|
package/src/server.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getDashboardStats,
|
|
11
11
|
getModelDashboardStats,
|
|
12
12
|
getOverviewStats,
|
|
13
|
+
getProviderDashboardStats,
|
|
13
14
|
getRecentErrors,
|
|
14
15
|
getRecentRequests,
|
|
15
16
|
getRequestDetails,
|
|
@@ -222,6 +223,11 @@ export async function handleApi(req: Request): Promise<Response> {
|
|
|
222
223
|
return Response.json(stats);
|
|
223
224
|
}
|
|
224
225
|
|
|
226
|
+
if (path === "/api/stats/providers") {
|
|
227
|
+
const stats = await getProviderDashboardStats(range);
|
|
228
|
+
return Response.json(stats);
|
|
229
|
+
}
|
|
230
|
+
|
|
225
231
|
if (path === "/api/stats/recent") {
|
|
226
232
|
const limit = url.searchParams.get("limit");
|
|
227
233
|
const stats = await getRecentRequests(limit ? parseInt(limit, 10) : undefined);
|