@oh-my-pi/omp-stats 16.3.0 → 16.3.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.
@@ -0,0 +1,463 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Line } from "react-chartjs-2";
3
+ import { getToolDashboardStats } from "../api";
4
+ import { CHART_THEMES, MODEL_COLORS } from "../components/chart-shared";
5
+ import { formatRangeTick, rangeMeta } from "../components/range-meta";
6
+ import { formatCompact, formatCost, formatInteger, formatPercent, formatRelativeTime } from "../data/formatters";
7
+ import { useResource } from "../data/useResource";
8
+ import { buildToolRows, type ToolRowView } from "../data/view-models";
9
+ import type { TimeRange, ToolModelStats, ToolTimeSeriesPoint, ToolUsageStats } from "../types";
10
+ import { AsyncBoundary, DataTable, Panel, StatusPill } from "../ui";
11
+ import { useSystemTheme } from "../useSystemTheme";
12
+
13
+ export interface ToolsRouteProps {
14
+ active: boolean;
15
+ range: TimeRange;
16
+ refreshTrigger: number;
17
+ }
18
+
19
+ export function ToolsRoute({ active, range, refreshTrigger }: ToolsRouteProps) {
20
+ const {
21
+ data: stats,
22
+ error,
23
+ loading,
24
+ } = useResource(["tools", range, refreshTrigger], signal => getToolDashboardStats(range, signal), {
25
+ pollMs: 30000,
26
+ enabled: active,
27
+ });
28
+
29
+ return (
30
+ <div className="stats-route-container space-y-6">
31
+ <AsyncBoundary loading={loading} error={error} data={stats} emptyText="No tool calls recorded for this range.">
32
+ {stats && (
33
+ <>
34
+ <ToolsSummaryPanel byTool={stats.byTool} />
35
+ <ToolCallsChart series={stats.series} timeRange={range} />
36
+ <ToolsTable byTool={stats.byTool} />
37
+ <ToolModelPanel byToolModel={stats.byToolModel} />
38
+ </>
39
+ )}
40
+ </AsyncBoundary>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Summary metrics
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function ToolsSummaryPanel({ byTool }: { byTool: ToolUsageStats[] }) {
50
+ const totals = useMemo(() => {
51
+ let calls = 0;
52
+ let errors = 0;
53
+ let tokens = 0;
54
+ let output = 0;
55
+ let cost = 0;
56
+ let resultChars = 0;
57
+ let argsChars = 0;
58
+ for (const t of byTool) {
59
+ calls += t.calls;
60
+ errors += t.errors;
61
+ tokens += t.totalTokensShare;
62
+ output += t.outputTokensShare;
63
+ cost += t.costShare;
64
+ resultChars += t.resultChars;
65
+ argsChars += t.argsChars;
66
+ }
67
+ return { calls, errors, tokens, output, cost, resultChars, argsChars, tools: byTool.length };
68
+ }, [byTool]);
69
+
70
+ return (
71
+ <Panel
72
+ title="Tool Usage"
73
+ subtitle="Tokens/cost are the invoking turns' real provider usage, split across each turn's tool calls"
74
+ >
75
+ <div className="stats-metric-cluster">
76
+ <div className="stats-metric-primary-grid">
77
+ <div className="stats-metric-card primary">
78
+ <div className="stats-metric-label">Tool Calls</div>
79
+ <div className="stats-metric-value">{formatInteger(totals.calls)}</div>
80
+ </div>
81
+ <div className="stats-metric-card primary">
82
+ <div className="stats-metric-label">Tools Used</div>
83
+ <div className="stats-metric-value">{formatInteger(totals.tools)}</div>
84
+ </div>
85
+ <div className="stats-metric-card primary">
86
+ <div className="stats-metric-label">Error Rate</div>
87
+ <div className="stats-metric-value">
88
+ {formatPercent(totals.calls > 0 ? totals.errors / totals.calls : 0)}
89
+ </div>
90
+ </div>
91
+ <div className="stats-metric-card primary">
92
+ <div className="stats-metric-label">Attributed Cost</div>
93
+ <div className="stats-metric-value">{formatCost(totals.cost)}</div>
94
+ </div>
95
+ </div>
96
+
97
+ <div className="stats-metric-secondary-grid">
98
+ <div className="stats-metric-card secondary">
99
+ <div className="stats-metric-label">Attributed Tokens</div>
100
+ <div className="stats-metric-value">{formatCompact(Math.round(totals.tokens))}</div>
101
+ </div>
102
+ <div className="stats-metric-card secondary">
103
+ <div className="stats-metric-label">Attributed Output</div>
104
+ <div className="stats-metric-value">{formatCompact(Math.round(totals.output))}</div>
105
+ </div>
106
+ <div className="stats-metric-card secondary">
107
+ <div className="stats-metric-label">Result Text</div>
108
+ <div className="stats-metric-value">{formatCompact(totals.resultChars)} chars</div>
109
+ </div>
110
+ <div className="stats-metric-card secondary">
111
+ <div className="stats-metric-label">Call Arguments</div>
112
+ <div className="stats-metric-value">{formatCompact(totals.argsChars)} chars</div>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </Panel>
117
+ );
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Calls over time (stacked by top tools)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ const TOP_TOOLS = 6;
125
+
126
+ function buildToolCallSeries(points: ToolTimeSeriesPoint[]): {
127
+ buckets: number[];
128
+ tools: string[];
129
+ data: Map<number, Record<string, number>>;
130
+ } {
131
+ const totals = new Map<string, number>();
132
+ for (const p of points) totals.set(p.tool, (totals.get(p.tool) ?? 0) + p.calls);
133
+ const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]);
134
+ const top = ranked.slice(0, TOP_TOOLS).map(([tool]) => tool);
135
+ const topSet = new Set(top);
136
+ const hasOther = ranked.length > top.length;
137
+ const tools = hasOther ? [...top, "Other"] : top;
138
+
139
+ const buckets = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
140
+ const data = new Map<number, Record<string, number>>();
141
+ for (const bucket of buckets) data.set(bucket, {});
142
+ for (const p of points) {
143
+ const label = topSet.has(p.tool) ? p.tool : "Other";
144
+ const row = data.get(p.timestamp);
145
+ if (row) row[label] = (row[label] ?? 0) + p.calls;
146
+ }
147
+ return { buckets, tools, data };
148
+ }
149
+
150
+ function ToolCallsChart({ series, timeRange }: { series: ToolTimeSeriesPoint[]; timeRange: TimeRange }) {
151
+ const theme = useSystemTheme();
152
+ const chartTheme = CHART_THEMES[theme];
153
+ const meta = rangeMeta(timeRange);
154
+
155
+ const chartSeries = useMemo(() => buildToolCallSeries(series), [series]);
156
+
157
+ const data = useMemo(
158
+ () => ({
159
+ labels: chartSeries.buckets.map(ts => formatRangeTick(ts, timeRange)),
160
+ datasets: chartSeries.tools.map((tool, index) => ({
161
+ label: tool,
162
+ data: chartSeries.buckets.map(bucket => chartSeries.data.get(bucket)?.[tool] ?? 0),
163
+ borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
164
+ backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}30`,
165
+ fill: true,
166
+ tension: 0.4,
167
+ pointRadius: 0,
168
+ pointHoverRadius: 4,
169
+ borderWidth: 2,
170
+ })),
171
+ }),
172
+ [chartSeries, timeRange],
173
+ );
174
+
175
+ const options = useMemo(
176
+ () => ({
177
+ responsive: true,
178
+ maintainAspectRatio: false,
179
+ interaction: { mode: "index" as const, intersect: false },
180
+ plugins: {
181
+ legend: {
182
+ position: "top" as const,
183
+ align: "start" as const,
184
+ labels: {
185
+ color: chartTheme.legendLabel,
186
+ usePointStyle: true,
187
+ padding: 16,
188
+ font: { size: 12 },
189
+ boxWidth: 8,
190
+ },
191
+ },
192
+ tooltip: {
193
+ backgroundColor: chartTheme.tooltipBackground,
194
+ titleColor: chartTheme.tooltipTitle,
195
+ bodyColor: chartTheme.tooltipBody,
196
+ borderColor: chartTheme.tooltipBorder,
197
+ borderWidth: 1,
198
+ padding: 12,
199
+ cornerRadius: 8,
200
+ callbacks: {
201
+ label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) =>
202
+ `${context.dataset.label ?? ""}: ${formatInteger(context.parsed.y ?? 0)} calls`,
203
+ },
204
+ },
205
+ },
206
+ scales: {
207
+ x: {
208
+ stacked: true,
209
+ grid: { color: chartTheme.grid, drawBorder: false },
210
+ ticks: { color: chartTheme.tick, font: { size: 11 } },
211
+ },
212
+ y: {
213
+ stacked: true,
214
+ grid: { color: chartTheme.grid, drawBorder: false },
215
+ ticks: { color: chartTheme.tick, font: { size: 11 }, precision: 0 },
216
+ min: 0,
217
+ },
218
+ },
219
+ }),
220
+ [chartTheme],
221
+ );
222
+
223
+ return (
224
+ <Panel title="Calls Over Time" subtitle={`Tool calls over ${meta.windowLabel}, stacked by tool`}>
225
+ <div className="h-[280px]">
226
+ {chartSeries.buckets.length === 0 ? (
227
+ <div className="h-full flex items-center justify-center text-stats-muted text-sm">No data available</div>
228
+ ) : (
229
+ <Line data={data} options={options} />
230
+ )}
231
+ </div>
232
+ </Panel>
233
+ );
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Per-tool table
238
+ // ---------------------------------------------------------------------------
239
+
240
+ function errorPillVariant(errorRate: number): "danger" | "warning" | "success" {
241
+ return errorRate > 0.1 ? "danger" : errorRate > 0 ? "warning" : "success";
242
+ }
243
+
244
+ function ToolsTable({ byTool }: { byTool: ToolUsageStats[] }) {
245
+ const rows = useMemo(() => buildToolRows(byTool), [byTool]);
246
+
247
+ const columns = useMemo(
248
+ () => [
249
+ {
250
+ key: "tool",
251
+ header: "Tool",
252
+ render: (item: ToolRowView) => (
253
+ <div className="stats-font-medium stats-text-primary font-mono truncate max-w-[280px]" title={item.tool}>
254
+ {item.tool}
255
+ </div>
256
+ ),
257
+ },
258
+ {
259
+ key: "calls",
260
+ header: "Calls",
261
+ numeric: true,
262
+ render: (item: ToolRowView) => (
263
+ <div className="stats-text-right">
264
+ <div className="font-mono">{formatInteger(item.calls)}</div>
265
+ <div className="stats-progress-bar-track mt-1 ml-auto w-24 h-1">
266
+ <div
267
+ className="stats-progress-bar-fill"
268
+ data-variant="link"
269
+ style={{ width: `${item.callsPercentage}%` }}
270
+ />
271
+ </div>
272
+ </div>
273
+ ),
274
+ },
275
+ {
276
+ key: "errorRate",
277
+ header: "Error Rate",
278
+ numeric: true,
279
+ render: (item: ToolRowView) => (
280
+ <StatusPill variant={errorPillVariant(item.errorRate)}>{formatPercent(item.errorRate)}</StatusPill>
281
+ ),
282
+ },
283
+ {
284
+ key: "tokens",
285
+ header: "Attr. Tokens",
286
+ numeric: true,
287
+ render: (item: ToolRowView) => (
288
+ <span className="font-mono" title="Invoking turns' total tokens, split across each turn's calls">
289
+ {formatCompact(Math.round(item.totalTokensShare))}
290
+ </span>
291
+ ),
292
+ },
293
+ {
294
+ key: "cost",
295
+ header: "Attr. Cost",
296
+ numeric: true,
297
+ render: (item: ToolRowView) => <span className="font-mono">{formatCost(item.costShare)}</span>,
298
+ },
299
+ {
300
+ key: "resultChars",
301
+ header: "Result Text",
302
+ numeric: true,
303
+ render: (item: ToolRowView) => (
304
+ <span className="font-mono" title="Characters of tool-result text fed back into context">
305
+ {formatCompact(item.resultChars)}
306
+ </span>
307
+ ),
308
+ },
309
+ {
310
+ key: "lastUsed",
311
+ header: "Last Used",
312
+ numeric: true,
313
+ render: (item: ToolRowView) => (
314
+ <span className="stats-text-secondary">{formatRelativeTime(item.lastUsed)}</span>
315
+ ),
316
+ },
317
+ ],
318
+ [],
319
+ );
320
+
321
+ const renderMobileCard = (item: ToolRowView) => (
322
+ <div className="stats-mobile-card">
323
+ <div className="stats-mobile-card-header mb-2">
324
+ <div className="stats-font-semibold stats-text-primary font-mono">{item.tool}</div>
325
+ <StatusPill variant={errorPillVariant(item.errorRate)}>{formatPercent(item.errorRate)} Err</StatusPill>
326
+ </div>
327
+ <div className="stats-mobile-card-grid">
328
+ <div>
329
+ <div className="stats-mobile-card-label">Calls</div>
330
+ <div className="stats-mobile-card-value font-mono">{formatInteger(item.calls)}</div>
331
+ </div>
332
+ <div>
333
+ <div className="stats-mobile-card-label">Attr. Tokens</div>
334
+ <div className="stats-mobile-card-value font-mono">
335
+ {formatCompact(Math.round(item.totalTokensShare))}
336
+ </div>
337
+ </div>
338
+ <div>
339
+ <div className="stats-mobile-card-label">Attr. Cost</div>
340
+ <div className="stats-mobile-card-value font-mono">{formatCost(item.costShare)}</div>
341
+ </div>
342
+ <div>
343
+ <div className="stats-mobile-card-label">Result Text</div>
344
+ <div className="stats-mobile-card-value font-mono">{formatCompact(item.resultChars)}</div>
345
+ </div>
346
+ </div>
347
+ </div>
348
+ );
349
+
350
+ return (
351
+ <Panel title="By Tool" subtitle="Usage per tool, most called first">
352
+ <DataTable
353
+ columns={columns}
354
+ data={rows}
355
+ keyExtractor={item => item.tool}
356
+ renderMobileCard={renderMobileCard}
357
+ emptyText="No tool calls recorded for this range."
358
+ />
359
+ </Panel>
360
+ );
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Per-(tool, model) breakdown
365
+ // ---------------------------------------------------------------------------
366
+
367
+ function ToolModelPanel({ byToolModel }: { byToolModel: ToolModelStats[] }) {
368
+ const [tool, setTool] = useState<string | null>(null);
369
+
370
+ const tools = useMemo(() => [...new Set(byToolModel.map(row => row.tool))].sort(), [byToolModel]);
371
+
372
+ const rows = useMemo(() => {
373
+ const filtered = tool ? byToolModel.filter(row => row.tool === tool) : byToolModel;
374
+ return filtered.map(row => ({
375
+ ...row,
376
+ errorRate: row.calls > 0 ? row.errors / row.calls : 0,
377
+ }));
378
+ }, [byToolModel, tool]);
379
+
380
+ const columns = useMemo(
381
+ () => [
382
+ {
383
+ key: "tool",
384
+ header: "Tool",
385
+ render: (item: ToolModelStats & { errorRate: number }) => (
386
+ <span className="stats-font-medium stats-text-primary font-mono">{item.tool}</span>
387
+ ),
388
+ },
389
+ {
390
+ key: "model",
391
+ header: "Model",
392
+ render: (item: ToolModelStats & { errorRate: number }) => (
393
+ <div>
394
+ <div className="stats-text-primary">{item.model || "(unknown)"}</div>
395
+ <div className="stats-text-secondary text-xs">{item.provider}</div>
396
+ </div>
397
+ ),
398
+ },
399
+ {
400
+ key: "calls",
401
+ header: "Calls",
402
+ numeric: true,
403
+ render: (item: ToolModelStats & { errorRate: number }) => (
404
+ <span className="font-mono">{formatInteger(item.calls)}</span>
405
+ ),
406
+ },
407
+ {
408
+ key: "errorRate",
409
+ header: "Error Rate",
410
+ numeric: true,
411
+ render: (item: ToolModelStats & { errorRate: number }) => (
412
+ <StatusPill variant={errorPillVariant(item.errorRate)}>{formatPercent(item.errorRate)}</StatusPill>
413
+ ),
414
+ },
415
+ {
416
+ key: "tokens",
417
+ header: "Attr. Tokens",
418
+ numeric: true,
419
+ render: (item: ToolModelStats & { errorRate: number }) => (
420
+ <span className="font-mono">{formatCompact(Math.round(item.totalTokensShare))}</span>
421
+ ),
422
+ },
423
+ {
424
+ key: "cost",
425
+ header: "Attr. Cost",
426
+ numeric: true,
427
+ render: (item: ToolModelStats & { errorRate: number }) => (
428
+ <span className="font-mono">{formatCost(item.costShare)}</span>
429
+ ),
430
+ },
431
+ ],
432
+ [],
433
+ );
434
+
435
+ return (
436
+ <Panel title="By Model" subtitle="Which models call which tools">
437
+ <div className="mb-4" style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
438
+ <span className="stats-text-secondary" style={{ fontSize: "0.875rem", whiteSpace: "nowrap" }}>
439
+ Tool
440
+ </span>
441
+ <select
442
+ className="stats-select"
443
+ value={tool ?? ""}
444
+ onChange={e => setTool(e.target.value || null)}
445
+ style={{ maxWidth: "320px", flex: 1 }}
446
+ >
447
+ <option value="">All tools</option>
448
+ {tools.map(name => (
449
+ <option key={name} value={name}>
450
+ {name}
451
+ </option>
452
+ ))}
453
+ </select>
454
+ </div>
455
+ <DataTable
456
+ columns={columns}
457
+ data={rows}
458
+ keyExtractor={item => `${item.tool}::${item.model}::${item.provider}`}
459
+ emptyText="No tool calls recorded for this range."
460
+ />
461
+ </Panel>
462
+ );
463
+ }
@@ -6,3 +6,4 @@ export * from "./ModelsRoute";
6
6
  export * from "./OverviewRoute";
7
7
  export * from "./ProjectsRoute";
8
8
  export * from "./RequestsRoute";
9
+ export * from "./ToolsRoute";