@oh-my-pi/omp-stats 18.3.4 → 18.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.
Files changed (192) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +13 -11
  3. package/build.ts +5 -49
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +2 -2
  6. package/dist/client/index.js +105 -126
  7. package/dist/types/aggregator.d.ts +25 -22
  8. package/dist/types/client/api.d.ts +11 -7
  9. package/dist/types/client/app/LiveChip.d.ts +2 -0
  10. package/dist/types/client/app/Shell.d.ts +16 -0
  11. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  12. package/dist/types/client/app/nav.d.ts +18 -0
  13. package/dist/types/client/charts/BarList.d.ts +17 -0
  14. package/dist/types/client/charts/Chart.d.ts +49 -0
  15. package/dist/types/client/charts/Legend.d.ts +16 -0
  16. package/dist/types/client/charts/ShareBar.d.ts +11 -0
  17. package/dist/types/client/charts/Sparkline.d.ts +11 -0
  18. package/dist/types/client/charts/TimeChart.d.ts +9 -0
  19. package/dist/types/client/charts/index.d.ts +7 -0
  20. package/dist/types/client/charts/types.d.ts +25 -0
  21. package/dist/types/client/charts/useWidth.d.ts +3 -0
  22. package/dist/types/client/data/colors.d.ts +25 -0
  23. package/dist/types/client/data/formatters.d.ts +9 -1
  24. package/dist/types/client/data/live.d.ts +29 -0
  25. package/dist/types/client/data/query.d.ts +35 -0
  26. package/dist/types/client/data/range.d.ts +31 -0
  27. package/dist/types/client/data/series.d.ts +28 -0
  28. package/dist/types/client/data/useHashRoute.d.ts +18 -4
  29. package/dist/types/client/data/view-models.d.ts +111 -28
  30. package/dist/types/client/index.d.ts +0 -1
  31. package/dist/types/client/routes/CostsRoute.d.ts +2 -2
  32. package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
  33. package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
  34. package/dist/types/client/routes/GainRoute.d.ts +1 -2
  35. package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
  36. package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
  37. package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
  38. package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
  39. package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
  40. package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
  41. package/dist/types/client/routes/TracesRoute.d.ts +2 -2
  42. package/dist/types/client/routes/index.d.ts +1 -1
  43. package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
  44. package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
  45. package/dist/types/client/traces/trace-colors.d.ts +18 -12
  46. package/dist/types/client/ui/Badge.d.ts +19 -0
  47. package/dist/types/client/ui/Card.d.ts +25 -0
  48. package/dist/types/client/ui/Drawer.d.ts +25 -0
  49. package/dist/types/client/ui/JsonBlock.d.ts +2 -2
  50. package/dist/types/client/ui/Modal.d.ts +23 -0
  51. package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
  52. package/dist/types/client/ui/SearchInput.d.ts +8 -0
  53. package/dist/types/client/ui/Segmented.d.ts +15 -0
  54. package/dist/types/client/ui/Stat.d.ts +36 -0
  55. package/dist/types/client/ui/States.d.ts +41 -0
  56. package/dist/types/client/ui/Table.d.ts +48 -0
  57. package/dist/types/client/ui/index.d.ts +9 -10
  58. package/dist/types/db.d.ts +90 -106
  59. package/dist/types/frustration.d.ts +70 -0
  60. package/dist/types/index.d.ts +3 -1
  61. package/dist/types/live.d.ts +30 -0
  62. package/dist/types/rollup.d.ts +115 -0
  63. package/dist/types/server.d.ts +6 -1
  64. package/dist/types/shared-types.d.ts +116 -56
  65. package/dist/types/types.d.ts +4 -0
  66. package/dist/types/user-metrics.d.ts +8 -0
  67. package/package.json +7 -13
  68. package/src/aggregator.ts +276 -211
  69. package/src/client/App.tsx +38 -80
  70. package/src/client/api.ts +65 -7
  71. package/src/client/app/LiveChip.tsx +97 -0
  72. package/src/client/app/Shell.tsx +181 -0
  73. package/src/client/app/ThemeToggle.tsx +5 -2
  74. package/src/client/app/nav.ts +78 -0
  75. package/src/client/charts/BarList.tsx +48 -0
  76. package/src/client/charts/Chart.tsx +388 -0
  77. package/src/client/charts/Legend.tsx +61 -0
  78. package/src/client/charts/ShareBar.tsx +26 -0
  79. package/src/client/charts/Sparkline.tsx +38 -0
  80. package/src/client/charts/TimeChart.tsx +22 -0
  81. package/src/client/charts/index.ts +7 -0
  82. package/src/client/charts/types.ts +27 -0
  83. package/src/client/charts/useWidth.ts +19 -0
  84. package/src/client/data/colors.ts +45 -0
  85. package/src/client/data/formatters.ts +30 -9
  86. package/src/client/data/live.tsx +85 -0
  87. package/src/client/data/query.ts +166 -0
  88. package/src/client/data/range.ts +82 -0
  89. package/src/client/data/series.ts +85 -0
  90. package/src/client/data/useHashRoute.ts +41 -83
  91. package/src/client/data/view-models.ts +347 -140
  92. package/src/client/index.tsx +3 -6
  93. package/src/client/routes/CostsRoute.tsx +394 -222
  94. package/src/client/routes/ErrorsRoute.tsx +401 -98
  95. package/src/client/routes/FrustrationRoute.tsx +924 -0
  96. package/src/client/routes/GainRoute.tsx +209 -206
  97. package/src/client/routes/ModelsRoute.tsx +540 -431
  98. package/src/client/routes/OverviewRoute.tsx +283 -311
  99. package/src/client/routes/ProjectsRoute.tsx +298 -156
  100. package/src/client/routes/ProvidersRoute.tsx +948 -416
  101. package/src/client/routes/RequestsRoute.tsx +295 -107
  102. package/src/client/routes/ToolsRoute.tsx +495 -398
  103. package/src/client/routes/TracesRoute.tsx +136 -117
  104. package/src/client/routes/costs.css +24 -0
  105. package/src/client/routes/errors.css +60 -0
  106. package/src/client/routes/frustration.css +123 -0
  107. package/src/client/routes/index.ts +1 -1
  108. package/src/client/routes/models.css +43 -0
  109. package/src/client/routes/projects.css +6 -0
  110. package/src/client/routes/providers.css +28 -0
  111. package/src/client/routes/tools.css +14 -0
  112. package/src/client/styles.css +1396 -1299
  113. package/src/client/traces/AggregatesPanel.tsx +55 -74
  114. package/src/client/traces/Minimap.tsx +6 -15
  115. package/src/client/traces/SpanDrawer.tsx +128 -194
  116. package/src/client/traces/SummaryStrip.tsx +21 -24
  117. package/src/client/traces/TimelineCanvas.tsx +27 -44
  118. package/src/client/traces/TraceView.tsx +175 -130
  119. package/src/client/traces/TranscriptList.tsx +13 -41
  120. package/src/client/traces/trace-colors.ts +60 -53
  121. package/src/client/traces/traces.css +279 -0
  122. package/src/client/ui/Badge.tsx +29 -0
  123. package/src/client/ui/Card.tsx +61 -0
  124. package/src/client/ui/Drawer.tsx +93 -0
  125. package/src/client/ui/JsonBlock.tsx +26 -44
  126. package/src/client/ui/Modal.tsx +106 -0
  127. package/src/client/ui/RequestDrawer.tsx +163 -188
  128. package/src/client/ui/SearchInput.tsx +25 -0
  129. package/src/client/ui/Segmented.tsx +61 -0
  130. package/src/client/ui/Stat.tsx +104 -0
  131. package/src/client/ui/States.tsx +81 -0
  132. package/src/client/ui/Table.tsx +229 -0
  133. package/src/client/ui/index.ts +9 -10
  134. package/src/client/ui/modal.css +54 -0
  135. package/src/client/ui/request-drawer.css +40 -0
  136. package/src/db.ts +379 -983
  137. package/src/frustration.ts +473 -0
  138. package/src/index.ts +22 -22
  139. package/src/live.ts +259 -0
  140. package/src/parser.ts +55 -66
  141. package/src/rollup.ts +951 -0
  142. package/src/server.ts +123 -40
  143. package/src/shared-types.ts +120 -56
  144. package/src/trace.ts +14 -7
  145. package/src/types.ts +4 -0
  146. package/src/user-metrics.ts +13 -0
  147. package/dist/client/styles.css +0 -1976
  148. package/dist/types/client/app/AppLayout.d.ts +0 -16
  149. package/dist/types/client/app/NavRail.d.ts +0 -7
  150. package/dist/types/client/app/RangeControl.d.ts +0 -7
  151. package/dist/types/client/app/SyncButton.d.ts +0 -14
  152. package/dist/types/client/app/TopBar.d.ts +0 -15
  153. package/dist/types/client/app/routes.d.ts +0 -12
  154. package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
  155. package/dist/types/client/components/chart-shared.d.ts +0 -178
  156. package/dist/types/client/components/models-table-shared.d.ts +0 -175
  157. package/dist/types/client/components/range-meta.d.ts +0 -21
  158. package/dist/types/client/data/charts.d.ts +0 -1
  159. package/dist/types/client/data/useResource.d.ts +0 -13
  160. package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
  161. package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
  162. package/dist/types/client/ui/DataTable.d.ts +0 -17
  163. package/dist/types/client/ui/EmptyState.d.ts +0 -7
  164. package/dist/types/client/ui/ErrorState.d.ts +0 -6
  165. package/dist/types/client/ui/MetricCluster.d.ts +0 -5
  166. package/dist/types/client/ui/Panel.d.ts +0 -7
  167. package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
  168. package/dist/types/client/ui/Skeleton.d.ts +0 -8
  169. package/dist/types/client/ui/StatusPill.d.ts +0 -7
  170. package/src/client/app/AppLayout.tsx +0 -93
  171. package/src/client/app/NavRail.tsx +0 -44
  172. package/src/client/app/RangeControl.tsx +0 -39
  173. package/src/client/app/SyncButton.tsx +0 -75
  174. package/src/client/app/TopBar.tsx +0 -73
  175. package/src/client/app/routes.ts +0 -93
  176. package/src/client/components/AgentTokenShare.tsx +0 -68
  177. package/src/client/components/chart-shared.tsx +0 -273
  178. package/src/client/components/models-table-shared.tsx +0 -255
  179. package/src/client/components/range-meta.ts +0 -73
  180. package/src/client/data/charts.ts +0 -14
  181. package/src/client/data/useResource.ts +0 -154
  182. package/src/client/routes/BehaviorRoute.tsx +0 -623
  183. package/src/client/ui/AsyncBoundary.tsx +0 -54
  184. package/src/client/ui/DataTable.tsx +0 -122
  185. package/src/client/ui/EmptyState.tsx +0 -16
  186. package/src/client/ui/ErrorState.tsx +0 -25
  187. package/src/client/ui/MetricCluster.tsx +0 -92
  188. package/src/client/ui/Panel.tsx +0 -24
  189. package/src/client/ui/SegmentedControl.tsx +0 -36
  190. package/src/client/ui/Skeleton.tsx +0 -17
  191. package/src/client/ui/StatusPill.tsx +0 -15
  192. package/tailwind.config.js +0 -40
@@ -1,8 +1,8 @@
1
+ import { X } from "lucide-react";
1
2
  import { useMemo, useState } from "react";
2
- import { Line } from "react-chartjs-2";
3
3
  import { getToolDashboardStats } from "../api";
4
- import { CHART_THEMES, MODEL_COLORS } from "../components/chart-shared";
5
- import { formatRangeTick, rangeMeta } from "../components/range-meta";
4
+ import { Legend, Sparkline, TimeChart, useHiddenSeries } from "../charts";
5
+ import { buildColorLookup, OTHER_COLOR } from "../data/colors";
6
6
  import {
7
7
  formatCompact,
8
8
  formatEstimatedCost,
@@ -10,466 +10,563 @@ import {
10
10
  formatPercent,
11
11
  formatRelativeTime,
12
12
  } from "../data/formatters";
13
- import { useResource } from "../data/useResource";
13
+ import { useQuery } from "../data/query";
14
+ import { bucketAxis, rangeMeta } from "../data/range";
15
+ import { densify, pivotSeries } from "../data/series";
14
16
  import { buildToolRows, type ToolRowView } from "../data/view-models";
15
- import type { TimeRange, ToolModelStats, ToolTimeSeriesPoint, ToolUsageStats } from "../types";
16
- import { AsyncBoundary, DataTable, Panel, StatusPill } from "../ui";
17
- import { useSystemTheme } from "../useSystemTheme";
17
+ import type { TimeRange, ToolDashboardStats, ToolModelStats, ToolTimeSeriesPoint } from "../types";
18
+ import {
19
+ Badge,
20
+ Card,
21
+ ChartSkeleton,
22
+ type Column,
23
+ EmptyState,
24
+ errorRateTone,
25
+ LabelCell,
26
+ MeterCell,
27
+ PageHeader,
28
+ QueryView,
29
+ Segmented,
30
+ Stat,
31
+ StatGrid,
32
+ Swatch,
33
+ Table,
34
+ TableSkeleton,
35
+ } from "../ui";
36
+ import "./tools.css";
18
37
 
19
38
  export interface ToolsRouteProps {
20
39
  active: boolean;
21
40
  range: TimeRange;
22
- refreshTrigger: number;
23
41
  }
24
42
 
25
- export function ToolsRoute({ active, range, refreshTrigger }: ToolsRouteProps) {
26
- const {
27
- data: stats,
28
- error,
29
- loading,
30
- } = useResource(["tools", range, refreshTrigger], signal => getToolDashboardStats(range, signal), {
31
- pollMs: 30000,
32
- enabled: active,
33
- });
43
+ type CallMetric = "calls" | "errors";
34
44
 
35
- return (
36
- <div className="stats-route-container space-y-6">
37
- <AsyncBoundary loading={loading} error={error} data={stats} emptyText="No tool calls recorded for this range.">
38
- {stats && (
39
- <>
40
- <ToolsSummaryPanel byTool={stats.byTool} />
41
- <ToolCallsChart series={stats.series} timeRange={range} />
42
- <ToolsTable byTool={stats.byTool} />
43
- <ToolModelPanel byToolModel={stats.byToolModel} />
44
- </>
45
- )}
46
- </AsyncBoundary>
47
- </div>
48
- );
49
- }
45
+ const METRIC_OPTIONS = [
46
+ { value: "calls" as const, label: "Calls" },
47
+ { value: "errors" as const, label: "Errors" },
48
+ ];
50
49
 
51
- // ---------------------------------------------------------------------------
52
- // Summary metrics
53
- // ---------------------------------------------------------------------------
50
+ /** Tools stacked individually in the calls chart; the rest fold into "Other". */
51
+ const TOP_TOOLS = 6;
52
+
53
+ const ATTRIBUTION_NOTE =
54
+ "Tokens and API-equivalent cost of each invoking turn, split evenly across that turn's tool calls";
55
+
56
+ const NO_CALLS = <EmptyState title="No tool calls in this range" />;
57
+
58
+ export function ToolsRoute({ active, range }: ToolsRouteProps) {
59
+ const tools = useQuery(["tools", range], () => getToolDashboardStats(range), { enabled: active });
60
+ const [metric, setMetric] = useState<CallMetric>("calls");
61
+ const [hidden, toggleHidden] = useHiddenSeries();
62
+ const [pickedTool, setToolFilter] = useState<string | null>(null);
63
+ const meta = rangeMeta(range);
54
64
 
55
- function ToolsSummaryPanel({ byTool }: { byTool: ToolUsageStats[] }) {
56
- const totals = useMemo(() => {
57
- let calls = 0;
58
- let errors = 0;
59
- let tokens = 0;
60
- let output = 0;
61
- let cost = 0;
62
- let unpricedRequests = 0;
63
- let resultChars = 0;
64
- let argsChars = 0;
65
- for (const t of byTool) {
66
- calls += t.calls;
67
- errors += t.errors;
68
- tokens += t.totalTokensShare;
69
- output += t.outputTokensShare;
70
- cost += t.costShare;
71
- unpricedRequests += t.unpricedRequestsShare;
72
- resultChars += t.resultChars;
73
- argsChars += t.argsChars;
74
- }
75
- return { calls, errors, tokens, output, cost, unpricedRequests, resultChars, argsChars, tools: byTool.length };
76
- }, [byTool]);
65
+ const view = useMemo(() => buildToolsView(tools.data, range), [tools.data, range]);
66
+ // A pick from another range only applies while that tool still has calls.
67
+ const toolFilter = pickedTool !== null && view.toolNames.includes(pickedTool) ? pickedTool : null;
68
+
69
+ const chartSeries = metric === "calls" ? view.callSeries : view.errorSeries;
70
+ const isEmpty = (data: ToolDashboardStats) => data.byTool.length === 0;
77
71
 
78
72
  return (
79
- <Panel
80
- title="Tool Usage"
81
- subtitle="Tokens and API-equivalent estimates are split from invoking turns across each turn's tool calls"
82
- >
83
- <div className="stats-metric-cluster">
84
- <div className="stats-metric-primary-grid">
85
- <div className="stats-metric-card primary">
86
- <div className="stats-metric-label">Tool Calls</div>
87
- <div className="stats-metric-value">{formatInteger(totals.calls)}</div>
88
- </div>
89
- <div className="stats-metric-card primary">
90
- <div className="stats-metric-label">Tools Used</div>
91
- <div className="stats-metric-value">{formatInteger(totals.tools)}</div>
92
- </div>
93
- <div className="stats-metric-card primary">
94
- <div className="stats-metric-label">Error Rate</div>
95
- <div className="stats-metric-value">
96
- {formatPercent(totals.calls > 0 ? totals.errors / totals.calls : 0)}
73
+ <div className="page">
74
+ <PageHeader
75
+ title="Tools"
76
+ description={`Which tools omp called in ${meta.windowLabel}, how often they failed, and what they cost.`}
77
+ />
78
+
79
+ <QueryView query={tools} skeleton={<ChartSkeleton height={112} />}>
80
+ {() => {
81
+ const t = view.totals;
82
+ return (
83
+ <div data-stale={tools.stale} className="stack" style={{ gap: 16 }}>
84
+ <StatGrid min={190}>
85
+ <Stat
86
+ label="Tool calls"
87
+ value={formatInteger(t.calls)}
88
+ hint={`${formatInteger(t.errors)} failed`}
89
+ spark={view.totalCalls}
90
+ />
91
+ <Stat
92
+ label="Distinct tools"
93
+ value={formatInteger(t.tools)}
94
+ hint={view.rows[0] ? `Most used: ${view.rows[0].tool}` : undefined}
95
+ />
96
+ <Stat
97
+ label="Error rate"
98
+ title="Tool results that came back flagged as errors"
99
+ value={formatPercent(t.calls > 0 ? t.errors / t.calls : 0)}
100
+ hint={`${formatInteger(t.calls - t.errors)} succeeded`}
101
+ spark={view.totalErrors}
102
+ sparkColor="var(--bad)"
103
+ />
104
+ <Stat
105
+ label="Attributed tokens"
106
+ title={ATTRIBUTION_NOTE}
107
+ value={formatCompact(Math.round(t.tokens))}
108
+ hint={`${formatCompact(Math.round(t.output))} output`}
109
+ />
110
+ <Stat
111
+ label="Attributed cost"
112
+ title={`${ATTRIBUTION_NOTE}; API-equivalent estimate`}
113
+ value={formatEstimatedCost(t.cost, t.unpriced)}
114
+ hint={
115
+ t.unpriced > 0
116
+ ? `${formatInteger(Math.round(t.unpriced))} unpriced requests`
117
+ : "API-equivalent"
118
+ }
119
+ />
120
+ </StatGrid>
121
+ <StatGrid min={150}>
122
+ <Stat
123
+ size="sm"
124
+ label="Result text"
125
+ title="Characters of tool-result text fed back into context"
126
+ value={`${formatCompact(t.resultChars)} chars`}
127
+ />
128
+ <Stat
129
+ size="sm"
130
+ label="Call arguments"
131
+ title="Characters of serialized tool-call arguments"
132
+ value={`${formatCompact(t.argsChars)} chars`}
133
+ />
134
+ <Stat
135
+ size="sm"
136
+ label="Avg result per call"
137
+ value={`${formatCompact(t.calls > 0 ? Math.round(t.resultChars / t.calls) : 0)} chars`}
138
+ />
139
+ <Stat
140
+ size="sm"
141
+ label="Avg arguments per call"
142
+ value={`${formatCompact(t.calls > 0 ? Math.round(t.argsChars / t.calls) : 0)} chars`}
143
+ />
144
+ </StatGrid>
97
145
  </div>
98
- </div>
99
- <div className="stats-metric-card primary">
100
- <div className="stats-metric-label">Attributed API-equivalent estimate</div>
101
- <div className="stats-metric-value">{formatEstimatedCost(totals.cost, totals.unpricedRequests)}</div>
102
- </div>
103
- </div>
146
+ );
147
+ }}
148
+ </QueryView>
104
149
 
105
- <div className="stats-metric-secondary-grid">
106
- <div className="stats-metric-card secondary">
107
- <div className="stats-metric-label">Attributed Tokens</div>
108
- <div className="stats-metric-value">{formatCompact(Math.round(totals.tokens))}</div>
109
- </div>
110
- <div className="stats-metric-card secondary">
111
- <div className="stats-metric-label">Attributed Output</div>
112
- <div className="stats-metric-value">{formatCompact(Math.round(totals.output))}</div>
113
- </div>
114
- <div className="stats-metric-card secondary">
115
- <div className="stats-metric-label">Result Text</div>
116
- <div className="stats-metric-value">{formatCompact(totals.resultChars)} chars</div>
117
- </div>
118
- <div className="stats-metric-card secondary">
119
- <div className="stats-metric-label">Call Arguments</div>
120
- <div className="stats-metric-value">{formatCompact(totals.argsChars)} chars</div>
150
+ <Card
151
+ index={1}
152
+ title={metric === "calls" ? "Calls over time" : "Errors over time"}
153
+ description={`Per ${meta.bucketMs < 3_600_000 ? "5 minutes" : meta.bucketMs < 86_400_000 ? "hour" : "day"}, top ${TOP_TOOLS} tools stacked`}
154
+ actions={<Segmented size="sm" options={METRIC_OPTIONS} value={metric} onChange={setMetric} />}
155
+ stale={tools.stale}
156
+ >
157
+ <QueryView query={tools} skeleton={<ChartSkeleton height={260} />} isEmpty={isEmpty} empty={NO_CALLS}>
158
+ {() => (
159
+ <div className="stack" style={{ gap: 12 }}>
160
+ <TimeChart
161
+ buckets={view.buckets}
162
+ bucketMs={meta.bucketMs}
163
+ series={chartSeries}
164
+ hidden={hidden}
165
+ height={260}
166
+ formatTooltip={formatInteger}
167
+ emptyLabel={metric === "errors" ? "No tool errors in this range" : undefined}
168
+ />
169
+ <Legend
170
+ items={chartSeries.map(s => ({
171
+ key: s.key,
172
+ label: s.label,
173
+ color: s.color,
174
+ value: formatCompact(s.values.reduce<number>((sum, v) => sum + (v ?? 0), 0)),
175
+ }))}
176
+ hidden={hidden}
177
+ onToggle={toggleHidden}
178
+ />
179
+ </div>
180
+ )}
181
+ </QueryView>
182
+ </Card>
183
+
184
+ <Card
185
+ index={2}
186
+ title="By tool"
187
+ description={`${ATTRIBUTION_NOTE}. Select a tool to break it down by model.`}
188
+ flush
189
+ stale={tools.stale}
190
+ >
191
+ <QueryView query={tools} skeleton={<TableSkeleton rows={8} />} isEmpty={isEmpty} empty={NO_CALLS}>
192
+ {() => (
193
+ <Table
194
+ rows={view.rows}
195
+ rowKey={row => row.tool}
196
+ columns={view.toolColumns}
197
+ initialSort={{ key: "calls", dir: "desc" }}
198
+ limit={20}
199
+ selectedKey={toolFilter}
200
+ onRowClick={row => setToolFilter(prev => (prev === row.tool ? null : row.tool))}
201
+ dense
202
+ />
203
+ )}
204
+ </QueryView>
205
+ </Card>
206
+
207
+ <Card
208
+ index={3}
209
+ title="By tool and model"
210
+ description="Which models call which tools, and how often those calls fail"
211
+ flush
212
+ stale={tools.stale}
213
+ actions={
214
+ <div className="row" style={{ gap: 6 }}>
215
+ <select
216
+ className="input tools-filter"
217
+ value={toolFilter ?? ""}
218
+ onChange={e => setToolFilter(e.target.value || null)}
219
+ aria-label="Filter by tool"
220
+ >
221
+ <option value="">All tools</option>
222
+ {view.toolNames.map(name => (
223
+ <option key={name} value={name}>
224
+ {name}
225
+ </option>
226
+ ))}
227
+ </select>
228
+ {toolFilter !== null && (
229
+ <button
230
+ type="button"
231
+ className="btn"
232
+ data-size="sm"
233
+ data-variant="ghost"
234
+ data-icon="true"
235
+ title="Clear tool filter"
236
+ aria-label="Clear tool filter"
237
+ onClick={() => setToolFilter(null)}
238
+ >
239
+ <X size={13} />
240
+ </button>
241
+ )}
121
242
  </div>
122
- </div>
123
- </div>
124
- </Panel>
243
+ }
244
+ >
245
+ <QueryView query={tools} skeleton={<TableSkeleton rows={8} />} isEmpty={isEmpty} empty={NO_CALLS}>
246
+ {data => <ToolModelTable rows={data.byToolModel} tool={toolFilter} colors={view.colors} />}
247
+ </QueryView>
248
+ </Card>
249
+ </div>
125
250
  );
126
251
  }
127
252
 
128
253
  // ---------------------------------------------------------------------------
129
- // Calls over time (stacked by top tools)
254
+ // View model
130
255
  // ---------------------------------------------------------------------------
131
256
 
132
- const TOP_TOOLS = 6;
133
-
134
- function buildToolCallSeries(points: ToolTimeSeriesPoint[]): {
135
- buckets: number[];
136
- tools: string[];
137
- data: Map<number, Record<string, number>>;
138
- } {
139
- const totals = new Map<string, number>();
140
- for (const p of points) totals.set(p.tool, (totals.get(p.tool) ?? 0) + p.calls);
141
- const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]);
142
- const top = ranked.slice(0, TOP_TOOLS).map(([tool]) => tool);
143
- const topSet = new Set(top);
144
- const hasOther = ranked.length > top.length;
145
- const tools = hasOther ? [...top, "Other"] : top;
146
-
147
- const buckets = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
148
- const data = new Map<number, Record<string, number>>();
149
- for (const bucket of buckets) data.set(bucket, {});
150
- for (const p of points) {
151
- const label = topSet.has(p.tool) ? p.tool : "Other";
152
- const row = data.get(p.timestamp);
153
- if (row) row[label] = (row[label] ?? 0) + p.calls;
154
- }
155
- return { buckets, tools, data };
257
+ interface ToolTotals {
258
+ calls: number;
259
+ errors: number;
260
+ tools: number;
261
+ tokens: number;
262
+ output: number;
263
+ cost: number;
264
+ unpriced: number;
265
+ resultChars: number;
266
+ argsChars: number;
156
267
  }
157
268
 
158
- function ToolCallsChart({ series, timeRange }: { series: ToolTimeSeriesPoint[]; timeRange: TimeRange }) {
159
- const theme = useSystemTheme();
160
- const chartTheme = CHART_THEMES[theme];
161
- const meta = rangeMeta(timeRange);
269
+ function buildToolsView(data: ToolDashboardStats | null, range: TimeRange) {
270
+ const byTool = data?.byTool ?? [];
271
+ const points = data?.series ?? [];
272
+ const rows = buildToolRows(byTool).sort((a, b) => b.calls - a.calls);
273
+ const colors = buildColorLookup(byTool.map(t => ({ key: t.tool, weight: t.calls })));
274
+ const buckets = bucketAxis(
275
+ range,
276
+ points.map(p => p.timestamp),
277
+ );
162
278
 
163
- const chartSeries = useMemo(() => buildToolCallSeries(series), [series]);
279
+ const totals: ToolTotals = {
280
+ calls: 0,
281
+ errors: 0,
282
+ tools: byTool.length,
283
+ tokens: 0,
284
+ output: 0,
285
+ cost: 0,
286
+ unpriced: 0,
287
+ resultChars: 0,
288
+ argsChars: 0,
289
+ };
290
+ for (const t of byTool) {
291
+ totals.calls += t.calls;
292
+ totals.errors += t.errors;
293
+ totals.tokens += t.totalTokensShare;
294
+ totals.output += t.outputTokensShare;
295
+ totals.cost += t.costShare;
296
+ totals.unpriced += t.unpricedRequestsShare;
297
+ totals.resultChars += t.resultChars;
298
+ totals.argsChars += t.argsChars;
299
+ }
164
300
 
165
- const data = useMemo(
166
- () => ({
167
- labels: chartSeries.buckets.map(ts => formatRangeTick(ts, timeRange)),
168
- datasets: chartSeries.tools.map((tool, index) => ({
169
- label: tool,
170
- data: chartSeries.buckets.map(bucket => chartSeries.data.get(bucket)?.[tool] ?? 0),
171
- borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
172
- backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}30`,
173
- fill: true,
174
- tension: 0.4,
175
- pointRadius: 0,
176
- pointHoverRadius: 4,
177
- borderWidth: 2,
301
+ // Every tool keeps its table swatch color in both metrics; zero slots become
302
+ // gaps so the tooltip lists only tools active in that bucket.
303
+ const [callSeries, errorSeries] = [(p: ToolTimeSeriesPoint) => p.calls, (p: ToolTimeSeriesPoint) => p.errors].map(
304
+ value =>
305
+ pivotSeries(points, { buckets, key: p => p.tool, value, limit: TOP_TOOLS, colors }).map(s => ({
306
+ ...s,
307
+ values: s.values.map(v => v || null),
178
308
  })),
179
- }),
180
- [chartSeries, timeRange],
181
- );
182
-
183
- const options = useMemo(
184
- () => ({
185
- responsive: true,
186
- maintainAspectRatio: false,
187
- interaction: { mode: "index" as const, intersect: false },
188
- plugins: {
189
- legend: {
190
- position: "top" as const,
191
- align: "start" as const,
192
- labels: {
193
- color: chartTheme.legendLabel,
194
- usePointStyle: true,
195
- padding: 16,
196
- font: { size: 12 },
197
- boxWidth: 8,
198
- },
199
- },
200
- tooltip: {
201
- backgroundColor: chartTheme.tooltipBackground,
202
- titleColor: chartTheme.tooltipTitle,
203
- bodyColor: chartTheme.tooltipBody,
204
- borderColor: chartTheme.tooltipBorder,
205
- borderWidth: 1,
206
- padding: 12,
207
- cornerRadius: 8,
208
- callbacks: {
209
- label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) =>
210
- `${context.dataset.label ?? ""}: ${formatInteger(context.parsed.y ?? 0)} calls`,
211
- },
212
- },
213
- },
214
- scales: {
215
- x: {
216
- stacked: true,
217
- grid: { color: chartTheme.grid, drawBorder: false },
218
- ticks: { color: chartTheme.tick, font: { size: 11 } },
219
- },
220
- y: {
221
- stacked: true,
222
- grid: { color: chartTheme.grid, drawBorder: false },
223
- ticks: { color: chartTheme.tick, font: { size: 11 }, precision: 0 },
224
- min: 0,
225
- },
226
- },
227
- }),
228
- [chartTheme],
229
309
  );
230
-
231
- return (
232
- <Panel title="Calls Over Time" subtitle={`Tool calls over ${meta.windowLabel}, stacked by tool`}>
233
- <div className="h-[280px]">
234
- {chartSeries.buckets.length === 0 ? (
235
- <div className="h-full flex items-center justify-center text-stats-muted text-sm">No data available</div>
236
- ) : (
237
- <Line data={data} options={options} />
238
- )}
239
- </div>
240
- </Panel>
310
+ // Unlimited pivot for the per-row sparklines.
311
+ const trends = new Map(
312
+ pivotSeries(points, { buckets, key: p => p.tool, value: p => p.calls }).map(s => [s.key, s.values as number[]]),
241
313
  );
242
- }
243
314
 
244
- // ---------------------------------------------------------------------------
245
- // Per-tool table
246
- // ---------------------------------------------------------------------------
315
+ const maxCalls = rows[0]?.calls ?? 0;
316
+ const toolColumns = buildToolColumns(maxCalls, colors, trends);
247
317
 
248
- function errorPillVariant(errorRate: number): "danger" | "warning" | "success" {
249
- return errorRate > 0.1 ? "danger" : errorRate > 0 ? "warning" : "success";
318
+ return {
319
+ rows,
320
+ colors,
321
+ buckets,
322
+ totals,
323
+ callSeries,
324
+ errorSeries,
325
+ totalCalls: densify(points, buckets, p => p.calls),
326
+ totalErrors: densify(points, buckets, p => p.errors),
327
+ toolNames: rows.map(r => r.tool).sort((a, b) => a.localeCompare(b)),
328
+ toolColumns,
329
+ };
250
330
  }
251
331
 
252
- function ToolsTable({ byTool }: { byTool: ToolUsageStats[] }) {
253
- const rows = useMemo(() => buildToolRows(byTool), [byTool]);
254
-
255
- const columns = useMemo(
256
- () => [
257
- {
258
- key: "tool",
259
- header: "Tool",
260
- render: (item: ToolRowView) => (
261
- <div className="stats-font-medium stats-text-primary font-mono truncate max-w-[280px]" title={item.tool}>
262
- {item.tool}
263
- </div>
264
- ),
265
- },
266
- {
267
- key: "calls",
268
- header: "Calls",
269
- numeric: true,
270
- render: (item: ToolRowView) => (
271
- <div className="stats-text-right">
272
- <div className="font-mono">{formatInteger(item.calls)}</div>
273
- <div className="stats-progress-bar-track mt-1 ml-auto w-24 h-1">
274
- <div
275
- className="stats-progress-bar-fill"
276
- data-variant="link"
277
- style={{ width: `${item.callsPercentage}%` }}
278
- />
279
- </div>
280
- </div>
281
- ),
282
- },
283
- {
284
- key: "errorRate",
285
- header: "Error Rate",
286
- numeric: true,
287
- render: (item: ToolRowView) => (
288
- <StatusPill variant={errorPillVariant(item.errorRate)}>{formatPercent(item.errorRate)}</StatusPill>
289
- ),
290
- },
291
- {
292
- key: "tokens",
293
- header: "Attr. Tokens",
294
- numeric: true,
295
- render: (item: ToolRowView) => (
296
- <span className="font-mono" title="Invoking turns' total tokens, split across each turn's calls">
297
- {formatCompact(Math.round(item.totalTokensShare))}
298
- </span>
299
- ),
300
- },
301
- {
302
- key: "cost",
303
- header: "Attr. API-equivalent estimate",
304
- numeric: true,
305
- render: (item: ToolRowView) => (
306
- <span className="font-mono">{formatEstimatedCost(item.costShare, item.unpricedRequestsShare)}</span>
307
- ),
308
- },
309
- {
310
- key: "resultChars",
311
- header: "Result Text",
312
- numeric: true,
313
- render: (item: ToolRowView) => (
314
- <span className="font-mono" title="Characters of tool-result text fed back into context">
315
- {formatCompact(item.resultChars)}
332
+ function buildToolColumns(
333
+ maxCalls: number,
334
+ colors: ReadonlyMap<string, string>,
335
+ trends: ReadonlyMap<string, number[]>,
336
+ ): Column<ToolRowView>[] {
337
+ return [
338
+ {
339
+ key: "tool",
340
+ header: "Tool",
341
+ sort: row => row.tool,
342
+ render: row => (
343
+ <span className="row" style={{ gap: 8 }}>
344
+ <Swatch color={colors.get(row.tool) ?? OTHER_COLOR} />
345
+ <span className="mono truncate tools-name" title={row.tool}>
346
+ {row.tool}
316
347
  </span>
317
- ),
318
- },
319
- {
320
- key: "lastUsed",
321
- header: "Last Used",
322
- numeric: true,
323
- render: (item: ToolRowView) => (
324
- <span className="stats-text-secondary">{formatRelativeTime(item.lastUsed)}</span>
325
- ),
348
+ </span>
349
+ ),
350
+ },
351
+ {
352
+ key: "trend",
353
+ header: "Trend",
354
+ title: "Calls per bucket over the range",
355
+ render: row => {
356
+ const values = trends.get(row.tool);
357
+ return values && values.length > 1 ? (
358
+ <Sparkline values={values} width={80} height={20} color={colors.get(row.tool) ?? OTHER_COLOR} />
359
+ ) : (
360
+ <span className="dim">–</span>
361
+ );
326
362
  },
327
- ],
328
- [],
329
- );
330
-
331
- const renderMobileCard = (item: ToolRowView) => (
332
- <div className="stats-mobile-card">
333
- <div className="stats-mobile-card-header mb-2">
334
- <div className="stats-font-semibold stats-text-primary font-mono">{item.tool}</div>
335
- <StatusPill variant={errorPillVariant(item.errorRate)}>{formatPercent(item.errorRate)} Err</StatusPill>
336
- </div>
337
- <div className="stats-mobile-card-grid">
338
- <div>
339
- <div className="stats-mobile-card-label">Calls</div>
340
- <div className="stats-mobile-card-value font-mono">{formatInteger(item.calls)}</div>
341
- </div>
342
- <div>
343
- <div className="stats-mobile-card-label">Attr. Tokens</div>
344
- <div className="stats-mobile-card-value font-mono">
345
- {formatCompact(Math.round(item.totalTokensShare))}
346
- </div>
347
- </div>
348
- <div>
349
- <div className="stats-mobile-card-label">Attr. API-equivalent estimate</div>
350
- <div className="stats-mobile-card-value font-mono">
351
- {formatEstimatedCost(item.costShare, item.unpricedRequestsShare)}
352
- </div>
353
- </div>
354
- <div>
355
- <div className="stats-mobile-card-label">Result Text</div>
356
- <div className="stats-mobile-card-value font-mono">{formatCompact(item.resultChars)}</div>
357
- </div>
358
- </div>
359
- </div>
360
- );
361
-
362
- return (
363
- <Panel title="By Tool" subtitle="Usage per tool, most called first">
364
- <DataTable
365
- columns={columns}
366
- data={rows}
367
- keyExtractor={item => item.tool}
368
- renderMobileCard={renderMobileCard}
369
- emptyText="No tool calls recorded for this range."
370
- />
371
- </Panel>
372
- );
363
+ },
364
+ {
365
+ key: "calls",
366
+ header: "Calls",
367
+ align: "right",
368
+ sort: row => row.calls,
369
+ render: row => (
370
+ <MeterCell
371
+ value={row.calls}
372
+ max={maxCalls}
373
+ display={
374
+ <span title={`${formatPercent(row.callFraction)} of all tool calls`}>
375
+ {formatInteger(row.calls)}
376
+ <span className="dim tools-share">{formatPercent(row.callFraction, 0)}</span>
377
+ </span>
378
+ }
379
+ />
380
+ ),
381
+ },
382
+ {
383
+ key: "errorRate",
384
+ header: "Errors",
385
+ title: "Calls whose result came back flagged as an error",
386
+ align: "right",
387
+ sort: row => row.errorRate,
388
+ render: row => (
389
+ <span className="row" style={{ gap: 8, justifyContent: "flex-end" }}>
390
+ <span className="num dim">{formatInteger(row.errors)}</span>
391
+ <Badge tone={row.errors > 0 ? errorRateTone(row.errorRate) : "neutral"} mono>
392
+ {formatPercent(row.errorRate)}
393
+ </Badge>
394
+ </span>
395
+ ),
396
+ },
397
+ {
398
+ key: "args",
399
+ header: "Args",
400
+ title: "Characters of serialized tool-call arguments",
401
+ align: "right",
402
+ sort: row => row.argsChars,
403
+ render: row => <span className="num">{formatCompact(row.argsChars)}</span>,
404
+ },
405
+ {
406
+ key: "result",
407
+ header: "Result",
408
+ title: "Characters of tool-result text fed back into context",
409
+ align: "right",
410
+ sort: row => row.resultChars,
411
+ render: row => <span className="num">{formatCompact(row.resultChars)}</span>,
412
+ },
413
+ {
414
+ key: "avgResult",
415
+ header: "Result / call",
416
+ title: "Mean tool-result characters per call",
417
+ align: "right",
418
+ sort: row => row.avgResultChars,
419
+ render: row => <span className="num muted">{formatCompact(Math.round(row.avgResultChars))}</span>,
420
+ },
421
+ {
422
+ key: "tokens",
423
+ header: "Attr. tokens",
424
+ title: ATTRIBUTION_NOTE,
425
+ align: "right",
426
+ sort: row => row.totalTokensShare,
427
+ render: row => (
428
+ <span className="num" title={`${formatPercent(row.tokenFraction)} of attributed tokens`}>
429
+ {formatCompact(Math.round(row.totalTokensShare))}
430
+ <span className="dim tools-share">{formatPercent(row.tokenFraction, 0)}</span>
431
+ </span>
432
+ ),
433
+ },
434
+ {
435
+ key: "cost",
436
+ header: "Attr. cost",
437
+ title: `${ATTRIBUTION_NOTE}; API-equivalent estimate`,
438
+ align: "right",
439
+ sort: row => row.costShare,
440
+ render: row => (
441
+ <span className="num" title={`${formatPercent(row.costFraction)} of attributed cost`}>
442
+ {formatEstimatedCost(row.costShare, row.unpricedRequestsShare)}
443
+ <span className="dim tools-share">{formatPercent(row.costFraction, 0)}</span>
444
+ </span>
445
+ ),
446
+ },
447
+ {
448
+ key: "lastUsed",
449
+ header: "Last used",
450
+ align: "right",
451
+ sort: row => row.lastUsed,
452
+ render: row => <span className="muted">{formatRelativeTime(row.lastUsed)}</span>,
453
+ },
454
+ ];
373
455
  }
374
456
 
375
457
  // ---------------------------------------------------------------------------
376
- // Per-(tool, model) breakdown
458
+ // Tool × model breakdown
377
459
  // ---------------------------------------------------------------------------
378
460
 
379
- function ToolModelPanel({ byToolModel }: { byToolModel: ToolModelStats[] }) {
380
- const [tool, setTool] = useState<string | null>(null);
381
-
382
- const tools = useMemo(() => [...new Set(byToolModel.map(row => row.tool))].sort(), [byToolModel]);
383
-
384
- const rows = useMemo(() => {
385
- const filtered = tool ? byToolModel.filter(row => row.tool === tool) : byToolModel;
386
- return filtered.map(row => ({
387
- ...row,
388
- errorRate: row.calls > 0 ? row.errors / row.calls : 0,
389
- }));
390
- }, [byToolModel, tool]);
461
+ interface ToolModelRow extends ToolModelStats {
462
+ errorRate: number;
463
+ }
391
464
 
392
- const columns = useMemo(
393
- () => [
465
+ function ToolModelTable({
466
+ rows,
467
+ tool,
468
+ colors,
469
+ }: {
470
+ rows: ToolModelStats[];
471
+ tool: string | null;
472
+ colors: ReadonlyMap<string, string>;
473
+ }) {
474
+ const filtered = useMemo<ToolModelRow[]>(
475
+ () =>
476
+ (tool === null ? rows : rows.filter(r => r.tool === tool)).map(r => ({
477
+ ...r,
478
+ errorRate: r.calls > 0 ? r.errors / r.calls : 0,
479
+ })),
480
+ [rows, tool],
481
+ );
482
+ const columns = useMemo<Column<ToolModelRow>[]>(() => {
483
+ const maxCalls = filtered.reduce((max, r) => Math.max(max, r.calls), 0);
484
+ return [
394
485
  {
395
486
  key: "tool",
396
487
  header: "Tool",
397
- render: (item: ToolModelStats & { errorRate: number }) => (
398
- <span className="stats-font-medium stats-text-primary font-mono">{item.tool}</span>
488
+ sort: row => row.tool,
489
+ render: row => (
490
+ <span className="row" style={{ gap: 8 }}>
491
+ <Swatch color={colors.get(row.tool) ?? OTHER_COLOR} />
492
+ <span className="mono truncate tools-name" title={row.tool}>
493
+ {row.tool}
494
+ </span>
495
+ </span>
399
496
  ),
400
497
  },
401
498
  {
402
499
  key: "model",
403
500
  header: "Model",
404
- render: (item: ToolModelStats & { errorRate: number }) => (
405
- <div>
406
- <div className="stats-text-primary">{item.model || "(unknown)"}</div>
407
- <div className="stats-text-secondary text-xs">{item.provider}</div>
408
- </div>
501
+ sort: row => row.model,
502
+ render: row => (
503
+ <LabelCell primary={<span className="mono">{row.model || "(unknown)"}</span>} secondary={row.provider} />
409
504
  ),
410
505
  },
411
506
  {
412
507
  key: "calls",
413
508
  header: "Calls",
414
- numeric: true,
415
- render: (item: ToolModelStats & { errorRate: number }) => (
416
- <span className="font-mono">{formatInteger(item.calls)}</span>
417
- ),
509
+ align: "right",
510
+ sort: row => row.calls,
511
+ render: row => <MeterCell value={row.calls} max={maxCalls} display={formatInteger(row.calls)} />,
418
512
  },
419
513
  {
420
514
  key: "errorRate",
421
- header: "Error Rate",
422
- numeric: true,
423
- render: (item: ToolModelStats & { errorRate: number }) => (
424
- <StatusPill variant={errorPillVariant(item.errorRate)}>{formatPercent(item.errorRate)}</StatusPill>
515
+ header: "Errors",
516
+ align: "right",
517
+ sort: row => row.errorRate,
518
+ render: row => (
519
+ <span className="row" style={{ gap: 8, justifyContent: "flex-end" }}>
520
+ <span className="num dim">{formatInteger(row.errors)}</span>
521
+ <Badge tone={row.errors > 0 ? errorRateTone(row.errorRate) : "neutral"} mono>
522
+ {formatPercent(row.errorRate)}
523
+ </Badge>
524
+ </span>
425
525
  ),
426
526
  },
527
+ {
528
+ key: "result",
529
+ header: "Result",
530
+ title: "Characters of tool-result text fed back into context",
531
+ align: "right",
532
+ sort: row => row.resultChars,
533
+ render: row => <span className="num">{formatCompact(row.resultChars)}</span>,
534
+ },
427
535
  {
428
536
  key: "tokens",
429
- header: "Attr. Tokens",
430
- numeric: true,
431
- render: (item: ToolModelStats & { errorRate: number }) => (
432
- <span className="font-mono">{formatCompact(Math.round(item.totalTokensShare))}</span>
433
- ),
537
+ header: "Attr. tokens",
538
+ title: ATTRIBUTION_NOTE,
539
+ align: "right",
540
+ sort: row => row.totalTokensShare,
541
+ render: row => <span className="num">{formatCompact(Math.round(row.totalTokensShare))}</span>,
434
542
  },
435
543
  {
436
544
  key: "cost",
437
- header: "Attr. API-equivalent estimate",
438
- numeric: true,
439
- render: (item: ToolModelStats & { errorRate: number }) => (
440
- <span className="font-mono">{formatEstimatedCost(item.costShare, item.unpricedRequestsShare)}</span>
441
- ),
545
+ header: "Attr. cost",
546
+ title: `${ATTRIBUTION_NOTE}; API-equivalent estimate`,
547
+ align: "right",
548
+ sort: row => row.costShare,
549
+ render: row => <span className="num">{formatEstimatedCost(row.costShare, row.unpricedRequestsShare)}</span>,
442
550
  },
443
- ],
444
- [],
445
- );
551
+ {
552
+ key: "lastUsed",
553
+ header: "Last used",
554
+ align: "right",
555
+ sort: row => row.lastUsed,
556
+ render: row => <span className="muted">{formatRelativeTime(row.lastUsed)}</span>,
557
+ },
558
+ ];
559
+ }, [filtered, colors]);
446
560
 
447
561
  return (
448
- <Panel title="By Model" subtitle="Which models call which tools">
449
- <div className="mb-4" style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
450
- <span className="stats-text-secondary" style={{ fontSize: "0.875rem", whiteSpace: "nowrap" }}>
451
- Tool
452
- </span>
453
- <select
454
- className="stats-select"
455
- value={tool ?? ""}
456
- onChange={e => setTool(e.target.value || null)}
457
- style={{ maxWidth: "320px", flex: 1 }}
458
- >
459
- <option value="">All tools</option>
460
- {tools.map(name => (
461
- <option key={name} value={name}>
462
- {name}
463
- </option>
464
- ))}
465
- </select>
466
- </div>
467
- <DataTable
468
- columns={columns}
469
- data={rows}
470
- keyExtractor={item => `${item.tool}::${item.model}::${item.provider}`}
471
- emptyText="No tool calls recorded for this range."
472
- />
473
- </Panel>
562
+ <Table
563
+ rows={filtered}
564
+ rowKey={row => `${row.tool}::${row.model}::${row.provider}`}
565
+ columns={columns}
566
+ initialSort={{ key: "calls", dir: "desc" }}
567
+ limit={25}
568
+ dense
569
+ empty={<EmptyState title={tool ? `No calls to ${tool} in this range` : "No tool calls in this range"} />}
570
+ />
474
571
  );
475
572
  }