@linxiraos/pi-stats 1.0.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.
Files changed (126) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +82 -0
  3. package/build.ts +95 -0
  4. package/dist/client/index.css +1 -0
  5. package/dist/client/index.html +24 -0
  6. package/dist/client/index.js +257 -0
  7. package/dist/client/styles.css +1695 -0
  8. package/dist/types/aggregator.d.ts +98 -0
  9. package/dist/types/client/App.d.ts +1 -0
  10. package/dist/types/client/api.d.ts +22 -0
  11. package/dist/types/client/app/AppLayout.d.ts +16 -0
  12. package/dist/types/client/app/NavRail.d.ts +7 -0
  13. package/dist/types/client/app/RangeControl.d.ts +7 -0
  14. package/dist/types/client/app/SyncButton.d.ts +14 -0
  15. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  16. package/dist/types/client/app/TopBar.d.ts +15 -0
  17. package/dist/types/client/app/routes.d.ts +12 -0
  18. package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
  19. package/dist/types/client/components/chart-shared.d.ts +173 -0
  20. package/dist/types/client/components/models-table-shared.d.ts +175 -0
  21. package/dist/types/client/components/range-meta.d.ts +21 -0
  22. package/dist/types/client/data/charts.d.ts +1 -0
  23. package/dist/types/client/data/formatters.d.ts +8 -0
  24. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  25. package/dist/types/client/data/useResource.d.ts +13 -0
  26. package/dist/types/client/data/view-models.d.ts +74 -0
  27. package/dist/types/client/index.d.ts +2 -0
  28. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  29. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  30. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  31. package/dist/types/client/routes/GainRoute.d.ts +7 -0
  32. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  33. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  34. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  35. package/dist/types/client/routes/ProvidersRoute.d.ts +7 -0
  36. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  37. package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
  38. package/dist/types/client/routes/index.d.ts +10 -0
  39. package/dist/types/client/types.d.ts +63 -0
  40. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  41. package/dist/types/client/ui/DataTable.d.ts +17 -0
  42. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  43. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  44. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  45. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  46. package/dist/types/client/ui/Panel.d.ts +7 -0
  47. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  48. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  49. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  50. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  51. package/dist/types/client/ui/index.d.ts +11 -0
  52. package/dist/types/client/useSystemTheme.d.ts +11 -0
  53. package/dist/types/db.d.ts +159 -0
  54. package/dist/types/embedded-client.d.ts +18 -0
  55. package/dist/types/gain-aggregator.d.ts +26 -0
  56. package/dist/types/index.d.ts +7 -0
  57. package/dist/types/parser.d.ts +53 -0
  58. package/dist/types/port-conflict.d.ts +14 -0
  59. package/dist/types/server.d.ts +12 -0
  60. package/dist/types/shared-types.d.ts +403 -0
  61. package/dist/types/sync-worker.d.ts +31 -0
  62. package/dist/types/types.d.ts +164 -0
  63. package/dist/types/usage-windows.d.ts +45 -0
  64. package/dist/types/user-metrics.d.ts +81 -0
  65. package/package.json +94 -0
  66. package/src/aggregator.ts +615 -0
  67. package/src/client/App.tsx +112 -0
  68. package/src/client/api.ts +118 -0
  69. package/src/client/app/AppLayout.tsx +93 -0
  70. package/src/client/app/NavRail.tsx +44 -0
  71. package/src/client/app/RangeControl.tsx +39 -0
  72. package/src/client/app/SyncButton.tsx +75 -0
  73. package/src/client/app/ThemeToggle.tsx +37 -0
  74. package/src/client/app/TopBar.tsx +73 -0
  75. package/src/client/app/routes.ts +86 -0
  76. package/src/client/components/AgentTokenShare.tsx +68 -0
  77. package/src/client/components/chart-shared.tsx +257 -0
  78. package/src/client/components/models-table-shared.tsx +255 -0
  79. package/src/client/components/range-meta.ts +73 -0
  80. package/src/client/css.d.ts +1 -0
  81. package/src/client/data/charts.ts +14 -0
  82. package/src/client/data/formatters.ts +45 -0
  83. package/src/client/data/useHashRoute.ts +88 -0
  84. package/src/client/data/useResource.ts +154 -0
  85. package/src/client/data/view-models.ts +259 -0
  86. package/src/client/index.tsx +10 -0
  87. package/src/client/routes/BehaviorRoute.tsx +623 -0
  88. package/src/client/routes/CostsRoute.tsx +234 -0
  89. package/src/client/routes/ErrorsRoute.tsx +118 -0
  90. package/src/client/routes/GainRoute.tsx +226 -0
  91. package/src/client/routes/ModelsRoute.tsx +430 -0
  92. package/src/client/routes/OverviewRoute.tsx +342 -0
  93. package/src/client/routes/ProjectsRoute.tsx +163 -0
  94. package/src/client/routes/ProvidersRoute.tsx +492 -0
  95. package/src/client/routes/RequestsRoute.tsx +123 -0
  96. package/src/client/routes/ToolsRoute.tsx +463 -0
  97. package/src/client/routes/index.ts +10 -0
  98. package/src/client/styles.css +1384 -0
  99. package/src/client/types.ts +80 -0
  100. package/src/client/ui/AsyncBoundary.tsx +54 -0
  101. package/src/client/ui/DataTable.tsx +122 -0
  102. package/src/client/ui/EmptyState.tsx +16 -0
  103. package/src/client/ui/ErrorState.tsx +25 -0
  104. package/src/client/ui/JsonBlock.tsx +75 -0
  105. package/src/client/ui/MetricCluster.tsx +78 -0
  106. package/src/client/ui/Panel.tsx +24 -0
  107. package/src/client/ui/RequestDrawer.tsx +208 -0
  108. package/src/client/ui/SegmentedControl.tsx +36 -0
  109. package/src/client/ui/Skeleton.tsx +17 -0
  110. package/src/client/ui/StatusPill.tsx +15 -0
  111. package/src/client/ui/index.ts +11 -0
  112. package/src/client/useSystemTheme.ts +87 -0
  113. package/src/db.ts +1702 -0
  114. package/src/embedded-client.generated.txt +0 -0
  115. package/src/embedded-client.ts +26 -0
  116. package/src/gain-aggregator.ts +311 -0
  117. package/src/index.ts +194 -0
  118. package/src/parser.ts +499 -0
  119. package/src/port-conflict.ts +263 -0
  120. package/src/server.ts +396 -0
  121. package/src/shared-types.ts +432 -0
  122. package/src/sync-worker.ts +40 -0
  123. package/src/types.ts +171 -0
  124. package/src/usage-windows.ts +304 -0
  125. package/src/user-metrics.ts +695 -0
  126. package/tailwind.config.js +40 -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
+ }
@@ -0,0 +1,123 @@
1
+ import { useMemo } from "react";
2
+ import { getRecentRequests } from "../api";
3
+ import { formatCost, formatDurationMs, formatInteger, formatRelativeTime } from "../data/formatters";
4
+ import { useResource } from "../data/useResource";
5
+ import type { MessageStats, TimeRange } from "../types";
6
+ import { AsyncBoundary, DataTable, Panel, StatusPill } from "../ui";
7
+
8
+ export interface RequestsRouteProps {
9
+ active: boolean;
10
+ range: TimeRange;
11
+ refreshTrigger: number;
12
+ onRequestClick: (id: number) => void;
13
+ }
14
+
15
+ export function RequestsRoute({ active, refreshTrigger, onRequestClick }: RequestsRouteProps) {
16
+ const {
17
+ data: recentRequests,
18
+ error,
19
+ loading,
20
+ } = useResource(["recent-requests-dense", refreshTrigger], signal => getRecentRequests(50, signal), {
21
+ pollMs: 30000,
22
+ enabled: active,
23
+ });
24
+
25
+ const columns = useMemo(
26
+ () => [
27
+ {
28
+ key: "model",
29
+ header: "Model",
30
+ render: (item: MessageStats) => (
31
+ <div>
32
+ <div className="stats-font-medium stats-text-primary">{item.model}</div>
33
+ <div className="stats-text-xs stats-text-muted">{item.provider}</div>
34
+ </div>
35
+ ),
36
+ },
37
+ {
38
+ key: "timestamp",
39
+ header: "Time",
40
+ render: (item: MessageStats) => formatRelativeTime(item.timestamp),
41
+ },
42
+ {
43
+ key: "tokens",
44
+ header: "Tokens",
45
+ numeric: true,
46
+ render: (item: MessageStats) => formatInteger(item.usage.totalTokens),
47
+ },
48
+ {
49
+ key: "cost",
50
+ header: "Cost",
51
+ numeric: true,
52
+ render: (item: MessageStats) => formatCost(item.usage.cost.total, 4),
53
+ },
54
+ {
55
+ key: "duration",
56
+ header: "Duration",
57
+ numeric: true,
58
+ render: (item: MessageStats) => formatDurationMs(item.duration),
59
+ },
60
+ {
61
+ key: "status",
62
+ header: "Status",
63
+ className: "stats-text-center",
64
+ render: (item: MessageStats) => (
65
+ <StatusPill variant={item.errorMessage ? "danger" : "success"}>
66
+ {item.errorMessage ? "Failed" : "Success"}
67
+ </StatusPill>
68
+ ),
69
+ },
70
+ ],
71
+ [],
72
+ );
73
+
74
+ const renderMobileCard = (item: MessageStats, onClick?: () => void) => (
75
+ <div className="stats-mobile-card" onClick={onClick}>
76
+ <div className="stats-mobile-card-header">
77
+ <div>
78
+ <div className="stats-font-semibold stats-text-primary">{item.model}</div>
79
+ <div className="stats-text-xs stats-text-muted">{item.provider}</div>
80
+ </div>
81
+ <StatusPill variant={item.errorMessage ? "danger" : "success"}>
82
+ {item.errorMessage ? "Failed" : "Success"}
83
+ </StatusPill>
84
+ </div>
85
+ <div className="stats-mobile-card-grid">
86
+ <div>
87
+ <div className="stats-mobile-card-label">Time</div>
88
+ <div className="stats-mobile-card-value">{formatRelativeTime(item.timestamp)}</div>
89
+ </div>
90
+ <div>
91
+ <div className="stats-mobile-card-label">Cost</div>
92
+ <div className="stats-mobile-card-value">{formatCost(item.usage.cost.total, 4)}</div>
93
+ </div>
94
+ <div>
95
+ <div className="stats-mobile-card-label">Tokens</div>
96
+ <div className="stats-mobile-card-value">{formatInteger(item.usage.totalTokens)}</div>
97
+ </div>
98
+ <div>
99
+ <div className="stats-mobile-card-label">Duration</div>
100
+ <div className="stats-mobile-card-value">{formatDurationMs(item.duration)}</div>
101
+ </div>
102
+ </div>
103
+ {item.errorMessage && <div className="stats-mobile-card-error truncate mt-2">{item.errorMessage}</div>}
104
+ </div>
105
+ );
106
+
107
+ return (
108
+ <div className="stats-route-container">
109
+ <Panel title="All Recent Requests" subtitle="Up to 50 most recent requests processed by OMP">
110
+ <AsyncBoundary loading={loading} error={error} data={recentRequests}>
111
+ <DataTable
112
+ columns={columns}
113
+ data={recentRequests || []}
114
+ keyExtractor={item => item.id || `${item.sessionFile}-${item.entryId}`}
115
+ onRowClick={item => item.id && onRequestClick(item.id)}
116
+ renderMobileCard={renderMobileCard}
117
+ emptyText="No recent requests found"
118
+ />
119
+ </AsyncBoundary>
120
+ </Panel>
121
+ </div>
122
+ );
123
+ }