@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,467 +1,576 @@
1
+ import "./models.css";
2
+ import { ChevronRight } from "lucide-react";
1
3
  import { useMemo, useState } from "react";
2
- import { Line } from "react-chartjs-2";
3
4
  import { getModelDashboardStats } from "../api";
4
- import { buildModelColorLookup, CHART_THEMES, MODEL_COLORS } from "../components/chart-shared";
5
+ import { type ChartSeries, Legend, Sparkline, TimeChart, useHiddenSeries } from "../charts";
6
+ import { buildModelColorLookup, modelKey, OTHER_COLOR } from "../data/colors";
5
7
  import {
6
- DetailChartEmpty,
7
- detailChartPlugins,
8
- detailChartScalesDualAxis,
9
- ExpandableModelRow,
10
- lineSeriesStyle,
11
- MiniSparkline,
12
- ModelNameCell,
13
- ModelTableBody,
14
- ModelTableHeader,
15
- ModelTableShell,
16
- TABLE_CHART_THEMES,
17
- type TableChartTheme,
18
- TrendEmpty,
19
- } from "../components/models-table-shared";
20
- import { formatRangeTick, rangeMeta } from "../components/range-meta";
21
- import { formatEstimatedCost } from "../data/formatters";
22
- import { useResource } from "../data/useResource";
23
- import { buildModelPerformanceLookup } from "../data/view-models";
24
- import type { ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeRange } from "../types";
25
- import { AsyncBoundary, Panel } from "../ui";
26
- import { useSystemTheme } from "../useSystemTheme";
8
+ formatCompact,
9
+ formatDurationMs,
10
+ formatEstimatedCost,
11
+ formatInteger,
12
+ formatPercent,
13
+ formatRelativeTime,
14
+ formatTokensPerSecond,
15
+ } from "../data/formatters";
16
+ import { useQuery } from "../data/query";
17
+ import { bucketAxis, rangeMeta } from "../data/range";
18
+ import { densify, pivotSeries } from "../data/series";
19
+ import {
20
+ buildModelPerformanceLookup,
21
+ type ModelPerformanceDataPoint,
22
+ sumConversationTokens,
23
+ } from "../data/view-models";
24
+ import type { ModelDashboardStats, ModelStats, TimeRange } from "../types";
25
+ import {
26
+ Badge,
27
+ Card,
28
+ ChartSkeleton,
29
+ type Column,
30
+ EmptyState,
31
+ errorRateTone,
32
+ KeyValues,
33
+ LabelCell,
34
+ MeterCell,
35
+ PageHeader,
36
+ QueryView,
37
+ Segmented,
38
+ Skeleton,
39
+ Stat,
40
+ StatGrid,
41
+ Swatch,
42
+ Table,
43
+ TableSkeleton,
44
+ } from "../ui";
27
45
 
28
46
  export interface ModelsRouteProps {
29
47
  active: boolean;
30
48
  range: TimeRange;
31
- refreshTrigger: number;
32
49
  }
33
50
 
34
- export function ModelsRoute({ active, range, refreshTrigger }: ModelsRouteProps) {
35
- const {
36
- data: modelStats,
37
- error,
38
- loading,
39
- } = useResource(["models", range, refreshTrigger], signal => getModelDashboardStats(range, signal), {
40
- pollMs: 30000,
41
- enabled: active,
42
- });
43
- const modelColorLookup = useMemo(() => buildModelColorLookup(modelStats?.byModel ?? []), [modelStats?.byModel]);
51
+ type ShareMode = "share" | "requests";
44
52
 
45
- return (
46
- <div className="stats-route-container space-y-6">
47
- <AsyncBoundary loading={loading} error={error} data={modelStats}>
48
- {modelStats && (
49
- <>
50
- <ModelShareChart
51
- modelSeries={modelStats.modelSeries}
52
- timeRange={range}
53
- colorLookup={modelColorLookup}
54
- />
55
- <ModelsTable
56
- models={modelStats.byModel}
57
- performanceSeries={modelStats.modelPerformanceSeries}
58
- timeRange={range}
59
- colorLookup={modelColorLookup}
60
- />
61
- </>
62
- )}
63
- </AsyncBoundary>
64
- </div>
65
- );
66
- }
53
+ const SHARE_OPTIONS = [
54
+ { value: "share" as const, label: "Share" },
55
+ { value: "requests" as const, label: "Requests" },
56
+ ];
67
57
 
68
- function ModelShareChart({
69
- modelSeries,
70
- timeRange,
71
- colorLookup,
72
- }: {
73
- modelSeries: ModelTimeSeriesPoint[];
74
- timeRange: TimeRange;
75
- colorLookup: ReadonlyMap<string, string>;
76
- }) {
77
- const theme = useSystemTheme();
78
- const chartTheme = CHART_THEMES[theme];
79
- const meta = rangeMeta(timeRange);
58
+ /** Models plotted individually in the share chart; the rest fold into "Other". */
59
+ const SHARE_LIMIT = 6;
80
60
 
81
- const chartData = useMemo(() => buildModelPreferenceSeries(modelSeries), [modelSeries]);
61
+ export function ModelsRoute({ active, range }: ModelsRouteProps) {
62
+ const query = useQuery(["models", range], () => getModelDashboardStats(range), { enabled: active });
63
+ const meta = rangeMeta(range);
64
+ const [mode, setMode] = useState<ShareMode>("share");
65
+ const [hidden, toggleSeries] = useHiddenSeries();
66
+ const [expandedKey, setExpandedKey] = useState<string | null>(null);
67
+ const view = useMemo(() => (query.data ? buildModelsView(query.data, range) : null), [query.data, range]);
68
+ const bucketWord = bucketName(meta.bucketMs);
69
+ const isEmpty = (data: ModelDashboardStats) => data.byModel.length === 0;
70
+ const empty = <EmptyState title="No model usage in this range" hint="Pick a wider range in the top bar." />;
82
71
 
83
- const data = useMemo(() => {
84
- return {
85
- labels: chartData.data.map(d => formatRangeTick(d.timestamp, timeRange)),
86
- datasets: chartData.series.map((series, index) => {
87
- const fallbackColor = MODEL_COLORS[index % MODEL_COLORS.length];
88
- const color = series.key ? (colorLookup.get(series.key) ?? fallbackColor) : fallbackColor;
89
- const dataKey = series.key ?? series.label;
72
+ return (
73
+ <div className="page">
74
+ <PageHeader
75
+ title="Models"
76
+ description={`Which models did the work in ${meta.windowLabel}, and how fast they answered.`}
77
+ />
90
78
 
91
- return {
92
- label: series.label,
93
- data: chartData.data.map(d => d[dataKey] ?? 0),
94
- borderColor: color,
95
- backgroundColor: `${color}20`,
96
- fill: true,
97
- tension: 0.4,
98
- pointRadius: 0,
99
- pointHoverRadius: 4,
100
- borderWidth: 2,
101
- };
102
- }),
103
- };
104
- }, [chartData, colorLookup, timeRange]);
79
+ <QueryView
80
+ query={query}
81
+ skeleton={<Skeleton height={112} style={{ borderRadius: 12 }} />}
82
+ isEmpty={isEmpty}
83
+ empty={empty}
84
+ >
85
+ {() =>
86
+ view && (
87
+ <div data-stale={query.stale}>
88
+ <StatGrid min={200}>
89
+ <Stat
90
+ label="Models used"
91
+ value={formatInteger(view.models.length)}
92
+ hint={`across ${formatInteger(view.providerCount)} provider${view.providerCount === 1 ? "" : "s"}`}
93
+ />
94
+ <Stat
95
+ label="Most used"
96
+ title={view.top ? `${view.top.model} (${view.top.provider})` : undefined}
97
+ value={view.top ? view.top.model : "–"}
98
+ hint={
99
+ view.top
100
+ ? `${formatPercent(view.top.totalRequests / Math.max(1, view.totalRequests))} of requests · ${view.top.provider}`
101
+ : undefined
102
+ }
103
+ />
104
+ <Stat
105
+ label="Requests"
106
+ value={formatInteger(view.totalRequests)}
107
+ hint={`${formatInteger(view.failedRequests)} failed`}
108
+ spark={view.requestTotals}
109
+ />
110
+ <Stat
111
+ label="API-equivalent cost"
112
+ title="What this usage would cost at public API rates"
113
+ value={formatEstimatedCost(view.totalCost, view.unpricedRequests)}
114
+ hint={
115
+ view.unpricedRequests > 0 ? `${formatInteger(view.unpricedRequests)} unpriced` : undefined
116
+ }
117
+ />
118
+ </StatGrid>
119
+ </div>
120
+ )
121
+ }
122
+ </QueryView>
105
123
 
106
- const options = useMemo(() => {
107
- return {
108
- responsive: true,
109
- maintainAspectRatio: false,
110
- interaction: {
111
- mode: "index" as const,
112
- intersect: false,
113
- },
114
- plugins: {
115
- legend: {
116
- position: "top" as const,
117
- align: "start" as const,
118
- labels: {
119
- color: chartTheme.legendLabel,
120
- usePointStyle: true,
121
- padding: 16,
122
- font: { size: 12 },
123
- boxWidth: 8,
124
- },
125
- },
126
- tooltip: {
127
- backgroundColor: chartTheme.tooltipBackground,
128
- titleColor: chartTheme.tooltipTitle,
129
- bodyColor: chartTheme.tooltipBody,
130
- borderColor: chartTheme.tooltipBorder,
131
- borderWidth: 1,
132
- padding: 12,
133
- cornerRadius: 8,
134
- callbacks: {
135
- label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
136
- const label = context.dataset.label ?? "";
137
- const value = context.parsed.y;
138
- return `${label}: ${(value ?? 0).toFixed(1)}%`;
139
- },
140
- },
141
- },
142
- },
143
- scales: {
144
- x: {
145
- grid: {
146
- color: chartTheme.grid,
147
- drawBorder: false,
148
- },
149
- ticks: {
150
- color: chartTheme.tick,
151
- font: { size: 11 },
152
- },
153
- },
154
- y: {
155
- grid: {
156
- color: chartTheme.grid,
157
- drawBorder: false,
158
- },
159
- ticks: {
160
- color: chartTheme.tick,
161
- font: { size: 11 },
162
- callback: (value: number | string) => `${value}%`,
163
- },
164
- min: 0,
165
- max: 100,
166
- },
167
- },
168
- };
169
- }, [chartTheme]);
124
+ <Card
125
+ index={1}
126
+ title="Request share"
127
+ description={
128
+ mode === "share"
129
+ ? `Each model's share of requests per ${bucketWord}`
130
+ : `Requests per ${bucketWord}, stacked by model`
131
+ }
132
+ actions={
133
+ <Segmented
134
+ size="sm"
135
+ options={SHARE_OPTIONS}
136
+ value={mode}
137
+ onChange={setMode}
138
+ aria-label="Share chart mode"
139
+ />
140
+ }
141
+ stale={query.stale}
142
+ >
143
+ <QueryView query={query} skeleton={<ChartSkeleton height={260} />} isEmpty={isEmpty} empty={empty}>
144
+ {() =>
145
+ view && (
146
+ <div className="stack" style={{ gap: 12 }}>
147
+ <TimeChart
148
+ buckets={view.buckets}
149
+ bucketMs={meta.bucketMs}
150
+ series={mode === "share" ? view.shareSeries : view.countSeries}
151
+ hidden={hidden}
152
+ height={260}
153
+ yMax={mode === "share" ? 1 : undefined}
154
+ format={mode === "share" ? v => formatPercent(v, 0) : formatCompact}
155
+ formatTooltip={mode === "share" ? v => formatPercent(v) : formatInteger}
156
+ showTotal={mode === "requests"}
157
+ />
158
+ <Legend
159
+ items={view.countSeries.map(s => ({
160
+ key: s.key,
161
+ label: s.label,
162
+ color: s.color,
163
+ value: formatPercent(sumValues(s.values) / Math.max(1, sumValues(view.requestTotals))),
164
+ }))}
165
+ hidden={hidden}
166
+ onToggle={toggleSeries}
167
+ />
168
+ </div>
169
+ )
170
+ }
171
+ </QueryView>
172
+ </Card>
170
173
 
171
- return (
172
- <Panel title="Model Preference" subtitle={`Share of requests over ${meta.windowLabel}`}>
173
- <div className="h-[280px]">
174
- {chartData.data.length === 0 ? (
175
- <div className="h-full flex items-center justify-center text-stats-muted text-sm">No data available</div>
176
- ) : (
177
- <Line data={data} options={options} />
178
- )}
179
- </div>
180
- </Panel>
174
+ <Card
175
+ index={2}
176
+ title="All models"
177
+ description="Click a row for latency and throughput over time"
178
+ stale={query.stale}
179
+ flush
180
+ >
181
+ <QueryView query={query} skeleton={<TableSkeleton rows={8} />} isEmpty={isEmpty} empty={empty}>
182
+ {() =>
183
+ view && (
184
+ <Table
185
+ rows={view.models}
186
+ rowKey={row => modelKey(row.model, row.provider)}
187
+ columns={buildModelColumns(view, expandedKey, bucketWord)}
188
+ initialSort={{ key: "requests", dir: "desc" }}
189
+ limit={25}
190
+ onRowClick={row => {
191
+ const key = modelKey(row.model, row.provider);
192
+ setExpandedKey(prev => (prev === key ? null : key));
193
+ }}
194
+ selectedKey={expandedKey}
195
+ expanded={row => {
196
+ const key = modelKey(row.model, row.provider);
197
+ return key === expandedKey ? (
198
+ <ModelDetail
199
+ model={row}
200
+ color={view.swatches.get(key) ?? "var(--chart-primary)"}
201
+ points={view.performance.get(key) ?? []}
202
+ bucketMs={meta.bucketMs}
203
+ bucketWord={bucketWord}
204
+ />
205
+ ) : null;
206
+ }}
207
+ />
208
+ )
209
+ }
210
+ </QueryView>
211
+ </Card>
212
+ </div>
181
213
  );
182
214
  }
183
215
 
184
- function buildModelPreferenceSeries(
185
- points: ModelTimeSeriesPoint[],
186
- topN = 5,
187
- ): {
188
- data: Array<Record<string, number>>;
189
- series: Array<{ key?: string; label: string }>;
190
- } {
191
- if (points.length === 0) return { data: [], series: [] };
192
-
193
- const totals = new Map<string, { model: string; provider: string; total: number }>();
194
- for (const point of points) {
195
- const key = `${point.model}::${point.provider}`;
196
- const existing = totals.get(key);
197
- if (existing) {
198
- existing.total += point.requests;
199
- } else {
200
- totals.set(key, {
201
- model: point.model,
202
- provider: point.provider,
203
- total: point.requests,
204
- });
205
- }
206
- }
207
-
208
- const sorted = [...totals.entries()].map(([key, value]) => ({ key, ...value })).sort((a, b) => b.total - a.total);
209
- const topEntries = sorted.slice(0, topN);
210
- const topKeys = new Set(topEntries.map(entry => entry.key));
211
-
212
- const topModelCounts = new Map<string, number>();
213
- for (const entry of topEntries) {
214
- topModelCounts.set(entry.model, (topModelCounts.get(entry.model) ?? 0) + 1);
215
- }
216
-
217
- const labelByKey = new Map<string, string>();
218
- for (const entry of topEntries) {
219
- const showProvider = (topModelCounts.get(entry.model) ?? 0) > 1;
220
- labelByKey.set(entry.key, showProvider ? `${entry.model} (${entry.provider})` : entry.model);
221
- }
222
-
223
- const dataMap = new Map<number, Record<string, number>>();
224
-
225
- for (const point of points) {
226
- const key = `${point.model}::${point.provider}`;
227
- const bucket = dataMap.get(point.timestamp) ?? {
228
- timestamp: point.timestamp,
229
- total: 0,
230
- };
231
- bucket.total += point.requests;
232
- const seriesKey = topKeys.has(key) ? key : "Other";
233
- bucket[seriesKey] = (bucket[seriesKey] ?? 0) + point.requests;
234
- dataMap.set(point.timestamp, bucket);
235
- }
216
+ interface ModelsView {
217
+ models: ModelStats[];
218
+ top: ModelStats | undefined;
219
+ /** Chart color of each individually plotted model; the rest share "Other" gray. */
220
+ swatches: Map<string, string>;
221
+ labels: Map<string, string>;
222
+ providerCount: number;
223
+ totalRequests: number;
224
+ failedRequests: number;
225
+ totalCost: number;
226
+ unpricedRequests: number;
227
+ maxRequests: number;
228
+ buckets: number[];
229
+ requestTotals: number[];
230
+ /** Top-N + Other requests per bucket (stacked counts). */
231
+ countSeries: ChartSeries[];
232
+ /** `countSeries` as a fraction of each bucket's total. */
233
+ shareSeries: ChartSeries[];
234
+ /** Dense requests per bucket for every model, for the table sparkline. */
235
+ trends: Map<string, readonly (number | null)[]>;
236
+ performance: Map<string, ModelPerformanceDataPoint[]>;
237
+ }
236
238
 
237
- const series: Array<{ key?: string; label: string }> = topEntries.map(entry => ({
238
- key: entry.key,
239
- label: labelByKey.get(entry.key) ?? entry.model,
239
+ function buildModelsView(data: ModelDashboardStats, range: TimeRange): ModelsView {
240
+ const models = data.byModel;
241
+ const colors = buildModelColorLookup(models);
242
+ const labels = modelLabels(models);
243
+ const buckets = bucketAxis(
244
+ range,
245
+ data.modelSeries.map(p => p.timestamp),
246
+ );
247
+ const requestTotals = densify(data.modelSeries, buckets, p => p.requests);
248
+ const pivot = {
249
+ buckets,
250
+ key: (p: { model: string; provider: string }) => modelKey(p.model, p.provider),
251
+ label: (key: string) => labels.get(key) ?? key,
252
+ value: (p: { requests: number }) => p.requests,
253
+ colors,
254
+ };
255
+ // Zero slots become gaps so tooltips list only the models active in that bucket.
256
+ const countSeries = pivotSeries(data.modelSeries, { ...pivot, limit: SHARE_LIMIT }).map(s => ({
257
+ ...s,
258
+ values: s.values.map(v => v || null),
240
259
  }));
241
- if ([...dataMap.values()].some(row => (row.Other ?? 0) > 0)) {
242
- series.push({ label: "Other" });
243
- }
260
+ const shareSeries = countSeries.map(s => ({
261
+ ...s,
262
+ values: s.values.map((v, i) => (v === null ? null : v / requestTotals[i])),
263
+ }));
264
+ const trends = new Map(pivotSeries(data.modelSeries, pivot).map(s => [s.key, s.values]));
244
265
 
245
- const data = [...dataMap.values()]
246
- .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
247
- .map(row => {
248
- const total = row.total ?? 0;
249
- for (const seriesItem of series) {
250
- const seriesKey = seriesItem.key ?? seriesItem.label;
251
- row[seriesKey] = total > 0 ? ((row[seriesKey] ?? 0) / total) * 100 : 0;
252
- }
253
- return row;
254
- });
266
+ let top: ModelStats | undefined;
267
+ let totalRequests = 0;
268
+ let failedRequests = 0;
269
+ let totalCost = 0;
270
+ let unpricedRequests = 0;
271
+ for (const m of models) {
272
+ if (!top || m.totalRequests > top.totalRequests) top = m;
273
+ totalRequests += m.totalRequests;
274
+ failedRequests += m.failedRequests;
275
+ totalCost += m.totalCost;
276
+ unpricedRequests += m.unpricedRequests;
277
+ }
255
278
 
256
- return { data, series };
279
+ return {
280
+ models,
281
+ top,
282
+ swatches: new Map(countSeries.filter(s => s.key !== "__other__").map(s => [s.key, s.color])),
283
+ labels,
284
+ providerCount: new Set(models.map(m => m.provider)).size,
285
+ totalRequests,
286
+ failedRequests,
287
+ totalCost,
288
+ unpricedRequests,
289
+ maxRequests: top?.totalRequests ?? 0,
290
+ buckets,
291
+ requestTotals,
292
+ countSeries,
293
+ shareSeries,
294
+ trends,
295
+ performance: buildModelPerformanceLookup(data.modelPerformanceSeries),
296
+ };
257
297
  }
258
298
 
259
- const GRID_TEMPLATE = "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px";
260
-
261
- function ModelsTable({
262
- models,
263
- performanceSeries,
264
- timeRange,
265
- colorLookup,
266
- }: {
267
- models: ModelStats[];
268
- performanceSeries: ModelPerformancePoint[];
269
- timeRange: TimeRange;
270
- colorLookup: ReadonlyMap<string, string>;
271
- }) {
272
- const [expandedKey, setExpandedKey] = useState<string | null>(null);
273
- const meta = rangeMeta(timeRange);
274
-
275
- const performanceSeriesByKey = useMemo(
276
- () => buildModelPerformanceLookup(performanceSeries, timeRange),
277
- [performanceSeries, timeRange],
299
+ /** Model name, qualified by provider only when the same model id is served by several. */
300
+ function modelLabels(models: readonly ModelStats[]): Map<string, string> {
301
+ const providersPerModel = new Map<string, number>();
302
+ for (const m of models) providersPerModel.set(m.model, (providersPerModel.get(m.model) ?? 0) + 1);
303
+ return new Map(
304
+ models.map(m => [
305
+ modelKey(m.model, m.provider),
306
+ (providersPerModel.get(m.model) ?? 0) > 1 ? `${m.model} · ${m.provider}` : m.model,
307
+ ]),
278
308
  );
309
+ }
279
310
 
280
- const theme = useSystemTheme();
281
- const chartTheme = TABLE_CHART_THEMES[theme];
282
-
283
- const sortedModels = useMemo(() => {
284
- return [...models].sort(
285
- (a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens),
286
- );
287
- }, [models]);
288
-
289
- return (
290
- <ModelTableShell title="Model Statistics">
291
- <ModelTableHeader
292
- gridTemplate={GRID_TEMPLATE}
293
- columns={[
294
- { label: "Model" },
295
- { label: "Requests", align: "right" },
296
- { label: "API-equivalent estimate", align: "right" },
297
- { label: "Tokens", align: "right" },
298
- { label: "Tokens/s", align: "right" },
299
- { label: "TTFT", align: "right" },
300
- { label: meta.trendLabel, align: "center" },
301
- ]}
302
- />
303
-
304
- <ModelTableBody>
305
- {sortedModels.map((model, index) => {
306
- const key = `${model.model}::${model.provider}`;
307
- const performance = performanceSeriesByKey.get(key);
308
- const trendData = performance?.data ?? [];
309
- const trendColor = colorLookup.get(key) ?? MODEL_COLORS[index % MODEL_COLORS.length];
310
- const isExpanded = expandedKey === key;
311
- const errorRate = model.errorRate * 100;
312
-
313
- return (
314
- <ExpandableModelRow
315
- key={key}
316
- gridTemplate={GRID_TEMPLATE}
317
- isExpanded={isExpanded}
318
- onToggle={() => setExpandedKey(isExpanded ? null : key)}
319
- cells={[
320
- <ModelNameCell key="name" model={model.model} provider={model.provider} />,
321
- <div key="requests" className="text-right text-[var(--text-secondary)] font-mono text-sm">
322
- {model.totalRequests.toLocaleString()}
323
- </div>,
324
- <div key="cost" className="text-right text-[var(--text-secondary)] font-mono text-sm">
325
- {formatEstimatedCost(model.totalCost, model.unpricedRequests)}
326
- </div>,
327
- <div key="tokens" className="text-right text-[var(--text-secondary)] font-mono text-sm">
328
- {(model.totalInputTokens + model.totalOutputTokens).toLocaleString()}
329
- </div>,
330
- <div key="tps" className="text-right text-[var(--text-secondary)] font-mono text-sm">
331
- {model.avgTokensPerSecond?.toFixed(1) ?? "-"}
332
- </div>,
333
- <div key="ttft" className="text-right text-[var(--text-secondary)] font-mono text-sm">
334
- {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
335
- </div>,
336
- ]}
337
- trendCell={
338
- trendData.length === 0 ? (
339
- <TrendEmpty />
340
- ) : (
341
- <MiniSparkline
342
- timestamps={trendData.map(d => d.timestamp)}
343
- values={trendData.map(d => d.avgTokensPerSecond ?? 0)}
344
- color={trendColor}
345
- />
346
- )
347
- }
348
- expandedContent={
349
- <div className="grid gap-4" style={{ gridTemplateColumns: "200px 1fr" }}>
350
- <div className="space-y-4 text-sm">
351
- <div>
352
- <div className="text-[var(--text-primary)] font-medium mb-2">Efficiency</div>
353
- <div className="space-y-1 text-[var(--text-secondary)]">
354
- <div className="flex items-center justify-between">
355
- <span>Error rate</span>
356
- <span
357
- className={
358
- errorRate > 5 ? "text-[var(--accent-red)]" : "text-[var(--accent-green)]"
359
- }
360
- >
361
- {errorRate.toFixed(1)}%
362
- </span>
363
- </div>
364
- <div className="flex items-center justify-between">
365
- <span>Cache rate</span>
366
- <span className="font-mono">{(model.cacheRate * 100).toFixed(1)}%</span>
367
- </div>
368
- <div className="flex items-center justify-between">
369
- <span>Cache savings</span>
370
- <span
371
- className={
372
- model.cacheSavings < 0
373
- ? "text-[var(--accent-red)]"
374
- : "text-[var(--accent-green)]"
375
- }
376
- >
377
- {(model.cacheSavings * 100).toFixed(1)}%
378
- </span>
379
- </div>
380
- </div>
381
- </div>
382
- <div>
383
- <div className="text-[var(--text-primary)] font-medium mb-2">Latency</div>
384
- <div className="space-y-1 text-[var(--text-secondary)]">
385
- <div className="flex items-center justify-between">
386
- <span>Avg duration</span>
387
- <span className="font-mono">
388
- {model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"}
389
- </span>
390
- </div>
391
- <div className="flex items-center justify-between">
392
- <span>Avg TTFT</span>
393
- <span className="font-mono">
394
- {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
395
- </span>
396
- </div>
397
- </div>
398
- </div>
399
- </div>
400
- <div className="h-[200px]">
401
- {trendData.length === 0 ? (
402
- <DetailChartEmpty />
403
- ) : (
404
- <PerformanceChart
405
- data={trendData}
406
- color={trendColor}
407
- chartTheme={chartTheme}
408
- timeRange={timeRange}
409
- />
410
- )}
411
- </div>
412
- </div>
413
- }
414
- />
415
- );
416
- })}
417
- </ModelTableBody>
418
- </ModelTableShell>
419
- );
311
+ function buildModelColumns(view: ModelsView, expandedKey: string | null, bucketWord: string): Column<ModelStats>[] {
312
+ return [
313
+ {
314
+ key: "expand",
315
+ header: "",
316
+ width: 28,
317
+ render: row => (
318
+ <span className="models-chevron" data-open={modelKey(row.model, row.provider) === expandedKey}>
319
+ <ChevronRight size={14} />
320
+ </span>
321
+ ),
322
+ },
323
+ {
324
+ key: "model",
325
+ header: "Model",
326
+ sort: row => row.model,
327
+ render: row => (
328
+ <LabelCell
329
+ lead={<Swatch color={view.swatches.get(modelKey(row.model, row.provider)) ?? OTHER_COLOR} />}
330
+ primary={<span className="mono">{row.model}</span>}
331
+ secondary={row.provider}
332
+ />
333
+ ),
334
+ },
335
+ {
336
+ key: "requests",
337
+ header: "Requests",
338
+ align: "right",
339
+ sort: row => row.totalRequests,
340
+ render: row => (
341
+ <MeterCell
342
+ value={row.totalRequests}
343
+ max={view.maxRequests}
344
+ display={formatInteger(row.totalRequests)}
345
+ color={view.swatches.get(modelKey(row.model, row.provider)) ?? OTHER_COLOR}
346
+ />
347
+ ),
348
+ },
349
+ {
350
+ key: "cost",
351
+ header: "Cost",
352
+ title: "API-equivalent estimate at public rates",
353
+ align: "right",
354
+ sort: row => row.totalCost,
355
+ render: row => <span className="num">{formatEstimatedCost(row.totalCost, row.unpricedRequests)}</span>,
356
+ },
357
+ {
358
+ key: "tokens",
359
+ header: "Tokens",
360
+ title: "Uncached input + cache reads + cache writes + output",
361
+ align: "right",
362
+ sort: row => sumConversationTokens(row),
363
+ render: row => {
364
+ const tokens = sumConversationTokens(row);
365
+ return (
366
+ <span className="num" title={formatInteger(tokens)}>
367
+ {formatCompact(tokens)}
368
+ </span>
369
+ );
370
+ },
371
+ },
372
+ {
373
+ key: "cache",
374
+ header: "Cache rate",
375
+ title: "Cache reads ÷ (uncached input + cache reads)",
376
+ align: "right",
377
+ sort: row => row.cacheRate,
378
+ render: row => <span className="num">{formatPercent(row.cacheRate)}</span>,
379
+ },
380
+ {
381
+ key: "errors",
382
+ header: "Errors",
383
+ align: "right",
384
+ sort: row => row.errorRate,
385
+ render: row =>
386
+ row.failedRequests === 0 ? (
387
+ <span className="num dim">0%</span>
388
+ ) : (
389
+ <span title={`${formatInteger(row.failedRequests)} failed`}>
390
+ <Badge tone={errorRateTone(row.errorRate)} mono>
391
+ {formatPercent(row.errorRate)}
392
+ </Badge>
393
+ </span>
394
+ ),
395
+ },
396
+ {
397
+ key: "tps",
398
+ header: "Tokens/s",
399
+ title: "Average output tokens per second",
400
+ align: "right",
401
+ sort: row => row.avgTokensPerSecond ?? -1,
402
+ render: row => <span className="num">{formatTokensPerSecond(row.avgTokensPerSecond)}</span>,
403
+ },
404
+ {
405
+ key: "ttft",
406
+ header: "TTFT",
407
+ title: "Average time to first token",
408
+ align: "right",
409
+ sort: row => row.avgTtft ?? -1,
410
+ render: row => <span className="num">{formatDurationMs(row.avgTtft)}</span>,
411
+ },
412
+ {
413
+ key: "trend",
414
+ header: "Trend",
415
+ title: `Requests per ${bucketWord}`,
416
+ width: 112,
417
+ render: row => {
418
+ const key = modelKey(row.model, row.provider);
419
+ const values = view.trends.get(key);
420
+ return values ? (
421
+ <Sparkline
422
+ values={values.map(v => v ?? 0)}
423
+ color={view.swatches.get(key) ?? OTHER_COLOR}
424
+ width={96}
425
+ height={22}
426
+ />
427
+ ) : (
428
+ <span className="dim">–</span>
429
+ );
430
+ },
431
+ },
432
+ ];
420
433
  }
421
434
 
422
- function PerformanceChart({
423
- data,
435
+ function ModelDetail({
436
+ model,
424
437
  color,
425
- chartTheme,
426
- timeRange,
438
+ points,
439
+ bucketMs,
440
+ bucketWord,
427
441
  }: {
428
- data: Array<{
429
- timestamp: number;
430
- avgTtftSeconds: number | null;
431
- avgTokensPerSecond: number | null;
432
- }>;
442
+ model: ModelStats;
433
443
  color: string;
434
- chartTheme: TableChartTheme;
435
- timeRange: TimeRange;
444
+ points: readonly ModelPerformanceDataPoint[];
445
+ bucketMs: number;
446
+ bucketWord: string;
436
447
  }) {
437
- const chartData = useMemo(() => {
438
- return {
439
- labels: data.map(d => formatRangeTick(d.timestamp, timeRange)),
440
- datasets: [
441
- {
442
- label: "TTFT",
443
- data: data.map(d => d.avgTtftSeconds ?? null),
444
- ...lineSeriesStyle("#5ad8e6"),
445
- yAxisID: "y" as const,
446
- },
447
- {
448
- label: "Tokens/s",
449
- data: data.map(d => d.avgTokensPerSecond ?? null),
450
- ...lineSeriesStyle(color),
451
- yAxisID: "y1" as const,
452
- },
453
- ],
454
- };
455
- }, [data, color, timeRange]);
448
+ const [hidden, toggle] = useHiddenSeries();
449
+ const series: ChartSeries[] = [
450
+ {
451
+ key: "tps",
452
+ label: "Tokens/s",
453
+ color,
454
+ kind: points.length > 1 ? "line" : "bars",
455
+ values: points.map(p => p.avgTokensPerSecond),
456
+ },
457
+ {
458
+ key: "ttft",
459
+ label: "TTFT",
460
+ color: OTHER_COLOR,
461
+ kind: points.length > 1 ? "line" : "bars",
462
+ axis: "right",
463
+ dashed: true,
464
+ values: points.map(p => p.avgTtftSeconds),
465
+ },
466
+ ];
467
+
468
+ return (
469
+ <div className="models-detail">
470
+ <div className="models-detail-facts">
471
+ <div>
472
+ <div className="section-label">Efficiency</div>
473
+ <KeyValues
474
+ items={[
475
+ {
476
+ key: "err",
477
+ label: "Error rate",
478
+ value: (
479
+ <span className={`tone-${errorRateTone(model.errorRate)}`}>
480
+ {formatPercent(model.errorRate)}{" "}
481
+ <span className="dim">({formatInteger(model.failedRequests)} failed)</span>
482
+ </span>
483
+ ),
484
+ },
485
+ { key: "cache", label: "Cache rate", value: formatPercent(model.cacheRate) },
486
+ {
487
+ key: "savings",
488
+ label: "Cache savings",
489
+ value: (
490
+ <span className={model.cacheSavings < 0 ? "tone-bad" : undefined}>
491
+ {formatPercent(model.cacheSavings)}
492
+ </span>
493
+ ),
494
+ },
495
+ {
496
+ key: "premium",
497
+ label: "Premium requests",
498
+ value: formatInteger(Math.round(model.totalPremiumRequests * 100) / 100),
499
+ },
500
+ ]}
501
+ />
502
+ </div>
503
+ <div>
504
+ <div className="section-label">Latency</div>
505
+ <KeyValues
506
+ items={[
507
+ { key: "dur", label: "Avg duration", value: formatDurationMs(model.avgDuration) },
508
+ { key: "ttft", label: "Avg TTFT", value: formatDurationMs(model.avgTtft) },
509
+ { key: "tps", label: "Tokens/s", value: formatTokensPerSecond(model.avgTokensPerSecond) },
510
+ ]}
511
+ />
512
+ </div>
513
+ <div>
514
+ <div className="section-label">Tokens</div>
515
+ <KeyValues
516
+ items={[
517
+ { key: "in", label: "Uncached input", value: formatCompact(model.totalInputTokens) },
518
+ { key: "cr", label: "Cache read", value: formatCompact(model.totalCacheReadTokens) },
519
+ { key: "cw", label: "Cache write", value: formatCompact(model.totalCacheWriteTokens) },
520
+ { key: "out", label: "Output", value: formatCompact(model.totalOutputTokens) },
521
+ ]}
522
+ />
523
+ </div>
524
+ <div className="micro dim">
525
+ First seen {formatRelativeTime(model.firstTimestamp)} · last seen{" "}
526
+ {formatRelativeTime(model.lastTimestamp)}
527
+ </div>
528
+ </div>
529
+ <div className="models-detail-chart">
530
+ <div className="row" style={{ justifyContent: "space-between" }}>
531
+ <div className="section-label" style={{ marginBottom: 0 }}>
532
+ Performance per active {bucketWord}
533
+ </div>
534
+ <Legend
535
+ items={series.map(s => ({ key: s.key, label: s.label, color: s.color }))}
536
+ hidden={hidden}
537
+ onToggle={toggle}
538
+ />
539
+ </div>
540
+ {points.length === 0 ? (
541
+ <EmptyState title="No performance samples" hint="Timing is recorded for streamed responses." />
542
+ ) : (
543
+ <TimeChart
544
+ buckets={points.map(p => p.timestamp)}
545
+ bucketMs={bucketMs}
546
+ series={series}
547
+ hidden={hidden}
548
+ stacked={false}
549
+ height={240}
550
+ format={formatCompact}
551
+ formatTooltip={v => `${formatTokensPerSecond(v)} tok/s`}
552
+ formatRight={v => `${Number(v.toFixed(2))}s`}
553
+ tooltipExtra={i => (
554
+ <div className="chart-tooltip-row chart-tooltip-total">
555
+ <span className="chart-tooltip-label">Requests</span>
556
+ <span className="chart-tooltip-value">{formatInteger(points[i].requests)}</span>
557
+ </div>
558
+ )}
559
+ />
560
+ )}
561
+ </div>
562
+ </div>
563
+ );
564
+ }
456
565
 
457
- const options = useMemo(() => {
458
- return {
459
- responsive: true,
460
- maintainAspectRatio: false,
461
- plugins: detailChartPlugins(chartTheme),
462
- scales: detailChartScalesDualAxis(chartTheme),
463
- };
464
- }, [chartTheme]);
566
+ function bucketName(bucketMs: number): string {
567
+ if (bucketMs < 3_600_000) return "5 minutes";
568
+ if (bucketMs < 86_400_000) return "hour";
569
+ return "day";
570
+ }
465
571
 
466
- return <Line data={chartData} options={options} />;
572
+ function sumValues(values: readonly (number | null)[]): number {
573
+ let total = 0;
574
+ for (const v of values) total += v ?? 0;
575
+ return total;
467
576
  }