@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,15 +1,8 @@
1
- import { useMemo, useState } from "react";
2
- import { Bar } from "react-chartjs-2";
3
- import { getProviderDashboardStats } from "../api";
4
- import {
5
- barDatasetStyle,
6
- buildSharedPlugins,
7
- buildSharedScales,
8
- buildTopNByModelSeries,
9
- CHART_THEMES,
10
- MODEL_COLORS,
11
- styleDatasets,
12
- } from "../components/chart-shared";
1
+ import { format } from "@oh-my-pi/pi-utils/dates";
2
+ import { type ReactNode, useMemo, useState } from "react";
3
+ import { getProviderDashboardStats, getProviderWindowStats } from "../api";
4
+ import { Chart, type ChartSeries, Legend, ShareBar, TimeChart, useHiddenSeries } from "../charts";
5
+ import { buildColorLookup, OTHER_COLOR, SERIES_COLORS } from "../data/colors";
13
6
  import {
14
7
  formatCompact,
15
8
  formatCost,
@@ -19,206 +12,443 @@ import {
19
12
  formatRelativeTime,
20
13
  formatTokensPerSecond,
21
14
  } from "../data/formatters";
22
- import { useResource } from "../data/useResource";
15
+ import { type QueryResult, useQuery } from "../data/query";
16
+ import { bucketAxis, formatBucket, formatTick, rangeMeta } from "../data/range";
17
+ import { densify, pivotSeries } from "../data/series";
23
18
  import type {
24
19
  ProviderAggregate,
25
- ProviderDashboardStats,
26
20
  ProviderHourlyPoint,
27
21
  ProviderWindowInsight,
22
+ ProviderWindowStats,
28
23
  TimeRange,
29
24
  UsageWindowSeries,
30
25
  } from "../types";
31
- import { AsyncBoundary, DataTable, type DataTableColumn, EmptyState, Panel, SegmentedControl } from "../ui";
32
- import { useSystemTheme } from "../useSystemTheme";
26
+ import {
27
+ Badge,
28
+ Card,
29
+ ChartSkeleton,
30
+ type Column,
31
+ EmptyState,
32
+ errorRateTone,
33
+ LabelCell,
34
+ MeterCell,
35
+ PageHeader,
36
+ QueryView,
37
+ Segmented,
38
+ Stat,
39
+ StatGrid,
40
+ Swatch,
41
+ Table,
42
+ TableSkeleton,
43
+ } from "../ui";
44
+ import "./providers.css";
33
45
 
34
46
  export interface ProvidersRouteProps {
35
47
  active: boolean;
36
48
  range: TimeRange;
37
- refreshTrigger: number;
38
49
  }
39
50
 
40
- export function ProvidersRoute({ active, range, refreshTrigger }: ProvidersRouteProps) {
41
- const {
42
- data: stats,
43
- error,
44
- loading,
45
- } = useResource(["providers", range, refreshTrigger], signal => getProviderDashboardStats(range, signal), {
46
- pollMs: 30000,
47
- enabled: active,
48
- });
51
+ type BurnMetric = "tokens" | "cost" | "requests";
49
52
 
50
- return (
51
- <div className="stats-route-container space-y-6">
52
- <AsyncBoundary loading={loading} error={error} data={stats}>
53
- {stats && (
54
- <>
55
- <ProviderTotalsPanel providers={stats.providers} />
56
- <ProviderTrendPanel stats={stats} />
57
- <PeakHoursPanel hourly={stats.hourly} providers={stats.providers} />
58
- <WindowInsightsPanel insights={stats.windowInsights} />
59
- <WindowUtilizationPanel usageSeries={stats.usageSeries} />
60
- </>
61
- )}
62
- </AsyncBoundary>
63
- </div>
64
- );
53
+ const BURN_OPTIONS = [
54
+ { value: "tokens" as const, label: "Tokens" },
55
+ { value: "cost" as const, label: "Cost", title: "API-equivalent estimate" },
56
+ { value: "requests" as const, label: "Requests" },
57
+ ];
58
+
59
+ /** Providers stacked individually in the burn chart; the rest fold into "Other". */
60
+ const TOP_PROVIDERS = 6;
61
+
62
+ const TOKEN_MIX = [
63
+ { key: "input", label: "Uncached input", color: "#5b8cff" },
64
+ { key: "cacheRead", label: "Cache read", color: "var(--chart-primary)" },
65
+ { key: "cacheWrite", label: "Cache write", color: "#f5b54a" },
66
+ { key: "output", label: "Output", color: "var(--chart-secondary)" },
67
+ ] as const;
68
+
69
+ const SNAPSHOT_HINT = "Usage snapshots accumulate whenever usage limits are fetched (TUI footer, /usage, omp usage).";
70
+
71
+ interface WindowRef {
72
+ provider: string;
73
+ windowKey: string;
65
74
  }
66
75
 
67
- // ---------------------------------------------------------------------------
68
- // Provider totals
69
- // ---------------------------------------------------------------------------
76
+ export function ProvidersRoute({ active, range }: ProvidersRouteProps) {
77
+ const stats = useQuery(["providers", range], () => getProviderDashboardStats(range), { enabled: active });
78
+ const [metric, setMetric] = useState<BurnMetric>("tokens");
79
+ const [hidden, toggleHidden] = useHiddenSeries();
80
+ const meta = rangeMeta(range);
70
81
 
71
- function ProviderTotalsPanel({ providers }: { providers: ProviderAggregate[] }) {
72
- const grandTotal = useMemo(() => providers.reduce((sum, p) => sum + p.totalTokens, 0), [providers]);
73
- const unpricedRequests = useMemo(
74
- () => providers.reduce((sum, provider) => sum + provider.unpricedRequests, 0),
75
- [providers],
82
+ // Subscription windows load independently: insights for every provider
83
+ // first, then utilization series for the one provider being charted.
84
+ const windows = useQuery(["provider-windows", range], () => getProviderWindowStats(range, null), {
85
+ enabled: active,
86
+ });
87
+ const [picked, setPicked] = useState<WindowRef | null>(null);
88
+ const selected = resolveWindow(windows.data?.windowInsights ?? [], picked);
89
+ const selectedProvider = selected?.provider ?? null;
90
+ const accounts = useQuery(
91
+ ["provider-windows", range, selectedProvider],
92
+ () => getProviderWindowStats(range, selectedProvider),
93
+ { enabled: active && selectedProvider !== null },
76
94
  );
77
95
 
78
- const columns: DataTableColumn<ProviderAggregate>[] = [
79
- { key: "provider", header: "Provider", render: p => <span className="font-medium">{p.provider}</span> },
80
- { key: "requests", header: "Requests", numeric: true, render: p => formatInteger(p.totalRequests) },
81
- {
82
- key: "errors",
83
- header: "Error Rate",
84
- numeric: true,
85
- render: p => formatPercent(p.totalRequests > 0 ? p.failedRequests / p.totalRequests : 0),
86
- },
87
- { key: "models", header: "Models", numeric: true, render: p => formatInteger(p.models) },
88
- {
89
- key: "tokens",
90
- header: "Tokens",
91
- numeric: true,
92
- render: p => (
93
- <span
94
- title={`Input ${formatCompact(p.totalInputTokens)} · Output ${formatCompact(p.totalOutputTokens)} · Cache read ${formatCompact(p.totalCacheReadTokens)} · Cache write ${formatCompact(p.totalCacheWriteTokens)}`}
95
- >
96
- {formatCompact(p.totalTokens)}
97
- </span>
96
+ const view = useMemo(() => {
97
+ const providers = stats.data?.providers ?? [];
98
+ const points = stats.data?.series ?? [];
99
+ const colors = buildColorLookup(providers.map(p => ({ key: p.provider, weight: p.totalTokens })));
100
+ const buckets = bucketAxis(
101
+ range,
102
+ points.map(p => p.timestamp),
103
+ );
104
+ const totals = { requests: 0, failed: 0, tokens: 0, cost: 0, unpriced: 0 };
105
+ for (const p of providers) {
106
+ totals.requests += p.totalRequests;
107
+ totals.failed += p.failedRequests;
108
+ totals.tokens += p.totalTokens;
109
+ totals.cost += p.totalCost;
110
+ totals.unpriced += p.unpricedRequests;
111
+ }
112
+ // Zero slots become gaps so the tooltip lists only providers active in that bucket.
113
+ const pivot = (value: (p: (typeof points)[number]) => number) =>
114
+ pivotSeries(points, { buckets, key: p => p.provider, value, limit: TOP_PROVIDERS, colors }).map(s => ({
115
+ ...s,
116
+ values: s.values.map(v => v || null),
117
+ }));
118
+ return {
119
+ colors,
120
+ buckets,
121
+ totals,
122
+ burn: {
123
+ tokens: pivot(p => p.totalTokens),
124
+ cost: pivot(p => p.cost),
125
+ requests: pivot(p => p.requests),
126
+ } satisfies Record<BurnMetric, ChartSeries[]>,
127
+ spark: {
128
+ tokens: densify(points, buckets, p => p.totalTokens),
129
+ cost: densify(points, buckets, p => p.cost),
130
+ requests: densify(points, buckets, p => p.requests),
131
+ },
132
+ topProvider: providers.reduce<ProviderAggregate | null>(
133
+ (top, p) => (top === null || p.totalTokens > top.totalTokens ? p : top),
134
+ null,
98
135
  ),
99
- },
100
- {
101
- key: "share",
102
- header: "Share",
103
- numeric: true,
104
- render: p => formatPercent(grandTotal > 0 ? p.totalTokens / grandTotal : 0),
105
- },
106
- {
107
- key: "cost",
108
- header: "API-equivalent estimate",
109
- numeric: true,
110
- render: provider => formatEstimatedCost(provider.totalCost, provider.unpricedRequests),
111
- },
112
- { key: "tps", header: "Tok/s", numeric: true, render: p => formatTokensPerSecond(p.avgTokensPerSecond) },
113
- ];
136
+ };
137
+ }, [stats.data, range]);
138
+
139
+ const burnSeries = view.burn[metric];
140
+ const burnFormat = metric === "cost" ? (v: number) => formatCost(v) : formatCompact;
141
+ const t = view.totals;
114
142
 
115
143
  return (
116
- <Panel
117
- title="Provider Totals"
118
- subtitle={
119
- unpricedRequests > 0
120
- ? `Token, request, and API-equivalent estimates; excludes ${unpricedRequests.toLocaleString()} unpriced subscription request${unpricedRequests === 1 ? "" : "s"}`
121
- : "Token, request, and API-equivalent estimates over the active range"
122
- }
123
- >
124
- <DataTable
125
- columns={columns}
126
- data={providers}
127
- keyExtractor={p => p.provider}
128
- emptyText="No requests recorded in this range"
144
+ <div className="page">
145
+ <PageHeader
146
+ title="Providers"
147
+ description={`Burn, reliability and subscription headroom per provider over ${meta.windowLabel}.`}
129
148
  />
130
- </Panel>
149
+
150
+ <QueryView query={stats} skeleton={<ChartSkeleton height={112} />}>
151
+ {({ providers }) => (
152
+ <div data-stale={stats.stale}>
153
+ <StatGrid min={190}>
154
+ <Stat
155
+ label="Providers"
156
+ value={formatInteger(providers.length)}
157
+ hint={view.topProvider ? `Most tokens: ${view.topProvider.provider}` : undefined}
158
+ />
159
+ <Stat
160
+ label="Requests"
161
+ value={formatInteger(t.requests)}
162
+ hint={`${formatInteger(t.failed)} failed`}
163
+ spark={view.spark.requests}
164
+ />
165
+ <Stat
166
+ label="Tokens"
167
+ title="Uncached input + cache reads + cache writes + output"
168
+ value={formatCompact(t.tokens)}
169
+ spark={view.spark.tokens}
170
+ />
171
+ <Stat
172
+ label="API-equivalent cost"
173
+ title="What this usage would cost at public API rates"
174
+ value={formatEstimatedCost(t.cost, t.unpriced)}
175
+ hint={t.unpriced > 0 ? `${formatInteger(t.unpriced)} unpriced` : undefined}
176
+ spark={view.spark.cost}
177
+ sparkColor="var(--chart-secondary)"
178
+ />
179
+ <Stat
180
+ label="Error rate"
181
+ value={formatPercent(t.requests > 0 ? t.failed / t.requests : 0)}
182
+ hint={`${formatInteger(t.requests - t.failed)} succeeded`}
183
+ />
184
+ </StatGrid>
185
+ </div>
186
+ )}
187
+ </QueryView>
188
+
189
+ <Card
190
+ index={1}
191
+ title="Provider totals"
192
+ description={
193
+ t.unpriced > 0
194
+ ? `Cost is an API-equivalent estimate and excludes ${formatInteger(t.unpriced)} unpriced subscription request${t.unpriced === 1 ? "" : "s"}. Select a row for its token mix.`
195
+ : "Cost is an API-equivalent estimate. Select a row for its token mix."
196
+ }
197
+ flush
198
+ stale={stats.stale}
199
+ >
200
+ <QueryView query={stats} skeleton={<TableSkeleton rows={6} />}>
201
+ {({ providers }) => <ProviderTotalsTable providers={providers} colors={view.colors} />}
202
+ </QueryView>
203
+ </Card>
204
+
205
+ <div className="grid grid-main-side">
206
+ <Card
207
+ index={2}
208
+ title="Burn by provider"
209
+ description={
210
+ metric === "cost" && t.unpriced > 0
211
+ ? `API-equivalent estimate per ${meta.bucketMs < 86_400_000 ? "hour" : "day"}; excludes unpriced subscription requests`
212
+ : `Per ${meta.bucketMs < 3_600_000 ? "5 minutes" : meta.bucketMs < 86_400_000 ? "hour" : "day"}, top ${TOP_PROVIDERS} providers stacked`
213
+ }
214
+ actions={<Segmented size="sm" options={BURN_OPTIONS} value={metric} onChange={setMetric} />}
215
+ stale={stats.stale}
216
+ >
217
+ <QueryView query={stats} skeleton={<ChartSkeleton height={260} />}>
218
+ {() => (
219
+ <div className="stack" style={{ gap: 12 }}>
220
+ <TimeChart
221
+ buckets={view.buckets}
222
+ bucketMs={meta.bucketMs}
223
+ series={burnSeries}
224
+ hidden={hidden}
225
+ height={260}
226
+ format={burnFormat}
227
+ formatTooltip={metric === "cost" ? v => formatCost(v) : formatInteger}
228
+ emptyLabel="No provider activity in this range"
229
+ />
230
+ <Legend
231
+ items={burnSeries.map(s => ({
232
+ key: s.key,
233
+ label: s.label,
234
+ color: s.color,
235
+ value: burnFormat(s.values.reduce<number>((sum, v) => sum + (v ?? 0), 0)),
236
+ }))}
237
+ hidden={hidden}
238
+ onToggle={toggleHidden}
239
+ />
240
+ </div>
241
+ )}
242
+ </QueryView>
243
+ </Card>
244
+
245
+ <QueryView query={stats} skeleton={<ChartSkeleton height={320} />}>
246
+ {({ hourly, providers }) => <PeakHoursCard hourly={hourly} providers={providers} stale={stats.stale} />}
247
+ </QueryView>
248
+ </div>
249
+
250
+ <Card
251
+ index={4}
252
+ title="Subscription windows"
253
+ description="What one usage window buys you and how many accounts peak demand needs. Select a row to chart it."
254
+ flush
255
+ stale={windows.stale}
256
+ >
257
+ <QueryView query={windows} skeleton={<TableSkeleton rows={6} />}>
258
+ {data => <WindowInsightsTable insights={data.windowInsights} selected={selected} onSelect={setPicked} />}
259
+ </QueryView>
260
+ </Card>
261
+
262
+ <WindowUtilizationCard
263
+ insights={windows.data?.windowInsights ?? null}
264
+ accounts={accounts}
265
+ selected={selected}
266
+ onSelect={setPicked}
267
+ />
268
+ </div>
131
269
  );
132
270
  }
133
271
 
272
+ /**
273
+ * The window charted below the insights table: the user's pick when it still
274
+ * exists, else the first window of the picked provider, else the most-burned
275
+ * window (the insights table's first row).
276
+ */
277
+ function resolveWindow(insights: readonly ProviderWindowInsight[], picked: WindowRef | null): WindowRef | null {
278
+ if (picked && insights.some(i => i.provider === picked.provider && i.windowKey === picked.windowKey)) return picked;
279
+ const fallback =
280
+ (picked && insights.find(i => i.provider === picked.provider)) ||
281
+ insights.reduce<ProviderWindowInsight | undefined>(
282
+ (top, i) => (top === undefined || i.fractionConsumed > top.fractionConsumed ? i : top),
283
+ undefined,
284
+ );
285
+ return fallback ? { provider: fallback.provider, windowKey: fallback.windowKey } : null;
286
+ }
287
+
134
288
  // ---------------------------------------------------------------------------
135
- // Token / cost trend by provider
289
+ // Provider totals
136
290
  // ---------------------------------------------------------------------------
137
291
 
138
- function ProviderTrendPanel({ stats }: { stats: ProviderDashboardStats }) {
139
- const [metric, setMetric] = useState<"tokens" | "cost">("tokens");
140
- const theme = useSystemTheme();
141
- const chartTheme = CHART_THEMES[theme];
142
- const unpricedRequests = useMemo(
143
- () => stats.providers.reduce((sum, provider) => sum + provider.unpricedRequests, 0),
144
- [stats.providers],
145
- );
146
-
147
- // buildTopNByModelSeries keys on `model`; feed it the provider name so we
148
- // get the same top-N + "Other" rollup without a parallel implementation.
149
- const chartData = useMemo(() => {
150
- const points = stats.series.map(p => ({ ...p, model: p.provider }));
151
- return buildTopNByModelSeries<(typeof points)[number], { total: number }>(points, {
152
- topN: 6,
153
- rankWeight: p => (metric === "tokens" ? p.totalTokens : p.cost),
154
- initBucket: () => ({ total: 0 }),
155
- accumulate: (bucket, p) => {
156
- bucket.total += metric === "tokens" ? p.totalTokens : p.cost;
292
+ function ProviderTotalsTable({
293
+ providers,
294
+ colors,
295
+ }: {
296
+ providers: ProviderAggregate[];
297
+ colors: ReadonlyMap<string, string>;
298
+ }) {
299
+ const [open, setOpen] = useState<string | null>(null);
300
+ const columns = useMemo<Column<ProviderAggregate>[]>(() => {
301
+ let maxRequests = 0;
302
+ let maxTokens = 0;
303
+ let grandTokens = 0;
304
+ for (const p of providers) {
305
+ maxRequests = Math.max(maxRequests, p.totalRequests);
306
+ maxTokens = Math.max(maxTokens, p.totalTokens);
307
+ grandTokens += p.totalTokens;
308
+ }
309
+ return [
310
+ {
311
+ key: "provider",
312
+ header: "Provider",
313
+ sort: p => p.provider,
314
+ render: p => (
315
+ <span className="row" style={{ gap: 8 }}>
316
+ <Swatch color={colors.get(p.provider) ?? OTHER_COLOR} />
317
+ <span className="mono cell-primary">{p.provider}</span>
318
+ </span>
319
+ ),
157
320
  },
158
- bucketToValue: bucket => bucket.total,
159
- });
160
- }, [stats.series, metric]);
161
-
162
- const formatValue = metric === "tokens" ? formatCompact : (v: number) => formatCost(v);
163
- const options = useMemo(() => {
164
- const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatValue });
165
- return {
166
- responsive: true,
167
- maintainAspectRatio: false,
168
- interaction: { mode: "index" as const, intersect: false },
169
- plugins: buildSharedPlugins({
170
- chartTheme,
171
- showLegend: true,
172
- defaultLabel: metric === "tokens" ? "Tokens" : "API-equivalent estimate",
173
- formatValue,
174
- footer: items => {
175
- if (items.length < 2) return undefined;
176
- const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
177
- return `Total: ${formatValue(total)}`;
321
+ {
322
+ key: "requests",
323
+ header: "Requests",
324
+ align: "right",
325
+ sort: p => p.totalRequests,
326
+ render: p => (
327
+ <MeterCell value={p.totalRequests} max={maxRequests} display={formatInteger(p.totalRequests)} />
328
+ ),
329
+ },
330
+ {
331
+ key: "errors",
332
+ header: "Error rate",
333
+ align: "right",
334
+ sort: p => (p.totalRequests > 0 ? p.failedRequests / p.totalRequests : 0),
335
+ render: p => {
336
+ const rate = p.totalRequests > 0 ? p.failedRequests / p.totalRequests : 0;
337
+ return (
338
+ <span className="row" style={{ gap: 8, justifyContent: "flex-end" }}>
339
+ <span className="num dim">{formatInteger(p.failedRequests)}</span>
340
+ <Badge tone={p.failedRequests > 0 ? errorRateTone(rate) : "neutral"} mono>
341
+ {formatPercent(rate)}
342
+ </Badge>
343
+ </span>
344
+ );
178
345
  },
179
- }),
180
- scales: {
181
- x: { ...sharedScaleBase, stacked: true },
182
- y: { ...yScale, stacked: true },
183
346
  },
184
- };
185
- }, [chartTheme, metric, formatValue]);
186
-
187
- const data = useMemo(
188
- () => ({
189
- labels: chartData.labels,
190
- datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
191
- }),
192
- [chartData],
347
+ {
348
+ key: "models",
349
+ header: "Models",
350
+ title: "Distinct models used through this provider",
351
+ align: "right",
352
+ sort: p => p.models,
353
+ render: p => <span className="num">{formatInteger(p.models)}</span>,
354
+ },
355
+ {
356
+ key: "tokens",
357
+ header: "Tokens",
358
+ title: "Uncached input + cache reads + cache writes + output",
359
+ align: "right",
360
+ sort: p => p.totalTokens,
361
+ render: p => (
362
+ <MeterCell
363
+ value={p.totalTokens}
364
+ max={maxTokens}
365
+ display={formatCompact(p.totalTokens)}
366
+ color={colors.get(p.provider)}
367
+ />
368
+ ),
369
+ },
370
+ {
371
+ key: "share",
372
+ header: "Share",
373
+ title: "Fraction of all tokens in range",
374
+ align: "right",
375
+ sort: p => p.totalTokens,
376
+ render: p => (
377
+ <span className="num muted">{formatPercent(grandTokens > 0 ? p.totalTokens / grandTokens : 0)}</span>
378
+ ),
379
+ },
380
+ {
381
+ key: "cost",
382
+ header: "Cost",
383
+ title: "API-equivalent estimate",
384
+ align: "right",
385
+ sort: p => p.totalCost,
386
+ render: p => (
387
+ <span
388
+ className="num"
389
+ title={p.unpricedRequests > 0 ? `${formatInteger(p.unpricedRequests)} unpriced` : undefined}
390
+ >
391
+ {formatEstimatedCost(p.totalCost, p.unpricedRequests)}
392
+ </span>
393
+ ),
394
+ },
395
+ {
396
+ key: "tps",
397
+ header: "Tokens/s",
398
+ title: "Average output throughput",
399
+ align: "right",
400
+ sort: p => p.avgTokensPerSecond ?? -1,
401
+ render: p => <span className="num">{formatTokensPerSecond(p.avgTokensPerSecond)}</span>,
402
+ },
403
+ {
404
+ key: "premium",
405
+ header: "Premium",
406
+ title: "Premium requests (subscription multiplier units)",
407
+ align: "right",
408
+ sort: p => p.totalPremiumRequests,
409
+ render: p => (
410
+ <span className={p.totalPremiumRequests > 0 ? "num" : "num dim"}>
411
+ {formatInteger(Math.round(p.totalPremiumRequests * 100) / 100)}
412
+ </span>
413
+ ),
414
+ },
415
+ ];
416
+ }, [providers, colors]);
417
+
418
+ return (
419
+ <Table
420
+ rows={providers}
421
+ rowKey={p => p.provider}
422
+ columns={columns}
423
+ initialSort={{ key: "tokens", dir: "desc" }}
424
+ limit={12}
425
+ selectedKey={open}
426
+ onRowClick={p => setOpen(prev => (prev === p.provider ? null : p.provider))}
427
+ expanded={p => (p.provider === open ? <TokenMix provider={p} /> : null)}
428
+ empty={<EmptyState title="No requests in this range" />}
429
+ />
193
430
  );
431
+ }
194
432
 
433
+ function TokenMix({ provider }: { provider: ProviderAggregate }) {
434
+ const mix = {
435
+ input: provider.totalInputTokens,
436
+ cacheRead: provider.totalCacheReadTokens,
437
+ cacheWrite: provider.totalCacheWriteTokens,
438
+ output: provider.totalOutputTokens,
439
+ };
195
440
  return (
196
- <Panel
197
- title="Burn by Provider"
198
- subtitle={
199
- metric === "cost" && unpricedRequests > 0
200
- ? `API-equivalent estimates over time; excludes ${unpricedRequests.toLocaleString()} unpriced subscription request${unpricedRequests === 1 ? "" : "s"}`
201
- : "Stacked token or API-equivalent estimate burn over time"
202
- }
203
- actions={
204
- <SegmentedControl
205
- options={[
206
- { value: "tokens" as const, label: "Tokens" },
207
- { value: "cost" as const, label: "API-equivalent estimate" },
208
- ]}
209
- value={metric}
210
- onChange={setMetric}
211
- />
212
- }
213
- >
214
- <div className="h-[300px]">
215
- {chartData.labels.length === 0 ? (
216
- <EmptyState message="No provider activity in this range" />
217
- ) : (
218
- <Bar data={data} options={options} />
219
- )}
220
- </div>
221
- </Panel>
441
+ <div className="providers-mix">
442
+ <ShareBar segments={TOKEN_MIX.map(m => ({ key: m.key, label: m.label, value: mix[m.key], color: m.color }))} />
443
+ <Legend
444
+ items={TOKEN_MIX.map(m => ({
445
+ key: m.key,
446
+ label: m.label,
447
+ color: m.color,
448
+ value: `${formatCompact(mix[m.key])} · ${provider.totalTokens > 0 ? formatPercent(mix[m.key] / provider.totalTokens, 0) : "–"}`,
449
+ }))}
450
+ />
451
+ </div>
222
452
  );
223
453
  }
224
454
 
@@ -226,72 +456,66 @@ function ProviderTrendPanel({ stats }: { stats: ProviderDashboardStats }) {
226
456
  // Peak burn hours
227
457
  // ---------------------------------------------------------------------------
228
458
 
229
- const ALL_PROVIDERS = "__all__";
459
+ const ALL_PROVIDERS = "";
230
460
 
231
- function PeakHoursPanel({ hourly, providers }: { hourly: ProviderHourlyPoint[]; providers: ProviderAggregate[] }) {
461
+ function PeakHoursCard({
462
+ hourly,
463
+ providers,
464
+ stale,
465
+ }: {
466
+ hourly: ProviderHourlyPoint[];
467
+ providers: ProviderAggregate[];
468
+ stale: boolean;
469
+ }) {
232
470
  const [provider, setProvider] = useState(ALL_PROVIDERS);
233
- const theme = useSystemTheme();
234
- const chartTheme = CHART_THEMES[theme];
471
+ const current =
472
+ provider === ALL_PROVIDERS || providers.some(p => p.provider === provider) ? provider : ALL_PROVIDERS;
235
473
 
236
- const { tokensByHour, peakHour } = useMemo(() => {
237
- // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
238
- const tokens = new Array<number>(24).fill(0);
474
+ const hours = useMemo(() => {
475
+ const tokens = Array.from({ length: 24 }, () => 0);
476
+ const output = Array.from({ length: 24 }, () => 0);
477
+ const requests = Array.from({ length: 24 }, () => 0);
239
478
  for (const point of hourly) {
240
- if (provider !== ALL_PROVIDERS && point.provider !== provider) continue;
479
+ if (current !== ALL_PROVIDERS && point.provider !== current) continue;
241
480
  tokens[point.hour] += point.totalTokens;
481
+ output[point.hour] += point.outputTokens;
482
+ requests[point.hour] += point.requests;
242
483
  }
243
484
  let peak = 0;
244
- for (let hour = 1; hour < 24; hour++) {
245
- if (tokens[hour] > tokens[peak]) peak = hour;
246
- }
247
- return { tokensByHour: tokens, peakHour: peak };
248
- }, [hourly, provider]);
249
-
250
- const hasData = tokensByHour.some(v => v > 0);
251
-
252
- const data = useMemo(
253
- () => ({
254
- labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, "0")}:00`),
255
- datasets: [
256
- {
257
- label: "Tokens",
258
- data: tokensByHour,
259
- ...barDatasetStyle(MODEL_COLORS[2]),
260
- // Highlight the peak hour in the brand accent color.
261
- backgroundColor: tokensByHour.map((_, hour) => (hour === peakHour ? MODEL_COLORS[0] : MODEL_COLORS[2])),
262
- },
263
- ],
264
- }),
265
- [tokensByHour, peakHour],
266
- );
485
+ for (let hour = 1; hour < 24; hour++) if (tokens[hour] > tokens[peak]) peak = hour;
486
+ const hasData = tokens[peak] > 0;
487
+ const series: ChartSeries[] = [
488
+ {
489
+ key: "tokens",
490
+ label: "Tokens",
491
+ color: "var(--chart-primary)",
492
+ values: tokens.map((v, hour) => (hasData && hour === peak ? null : v)),
493
+ },
494
+ {
495
+ key: "peak",
496
+ label: "Tokens (peak hour)",
497
+ color: "var(--chart-secondary)",
498
+ values: tokens.map((v, hour) => (hasData && hour === peak ? v : null)),
499
+ },
500
+ ];
501
+ return { series, output, requests, peak, hasData };
502
+ }, [hourly, current]);
267
503
 
268
- const options = useMemo(() => {
269
- const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatCompact });
270
- return {
271
- responsive: true,
272
- maintainAspectRatio: false,
273
- plugins: buildSharedPlugins({
274
- chartTheme,
275
- showLegend: false,
276
- defaultLabel: "Tokens",
277
- formatValue: formatCompact,
278
- }),
279
- scales: { x: sharedScaleBase, y: yScale },
280
- };
281
- }, [chartTheme]);
504
+ const hourLabel = (hour: number) => `${String(hour).padStart(2, "0")}:00`;
282
505
 
283
506
  return (
284
- <Panel
285
- title="Peak Burn Hours"
286
- subtitle={
287
- hasData
288
- ? `Token burn by local hour of day — peak at ${String(peakHour).padStart(2, "0")}:00`
289
- : "Token burn by local hour of day"
507
+ <Card
508
+ index={3}
509
+ title="Peak burn hours"
510
+ description={
511
+ hours.hasData
512
+ ? `Tokens by local hour of day; peak at ${hourLabel(hours.peak)}`
513
+ : "Tokens by local hour of day"
290
514
  }
291
515
  actions={
292
516
  <select
293
- className="stats-select"
294
- value={provider}
517
+ className="input providers-select"
518
+ value={current}
295
519
  onChange={e => setProvider(e.target.value)}
296
520
  aria-label="Provider"
297
521
  >
@@ -303,11 +527,31 @@ function PeakHoursPanel({ hourly, providers }: { hourly: ProviderHourlyPoint[];
303
527
  ))}
304
528
  </select>
305
529
  }
530
+ stale={stale}
306
531
  >
307
- <div className="h-[260px]">
308
- {hasData ? <Bar data={data} options={options} /> : <EmptyState message="No activity in this range" />}
309
- </div>
310
- </Panel>
532
+ <Chart
533
+ slots={24}
534
+ tickLabel={hour => String(hour).padStart(2, "0")}
535
+ tooltipTitle={hour => `${hourLabel(hour)}–${hourLabel((hour + 1) % 24)}`}
536
+ series={hours.series}
537
+ height={260}
538
+ showTotal={false}
539
+ formatTooltip={formatInteger}
540
+ emptyLabel="No activity in this range"
541
+ tooltipExtra={hour => (
542
+ <>
543
+ <div className="chart-tooltip-row">
544
+ <span className="chart-tooltip-label">Output</span>
545
+ <span className="chart-tooltip-value">{formatInteger(hours.output[hour])}</span>
546
+ </div>
547
+ <div className="chart-tooltip-row">
548
+ <span className="chart-tooltip-label">Requests</span>
549
+ <span className="chart-tooltip-value">{formatInteger(hours.requests[hour])}</span>
550
+ </div>
551
+ </>
552
+ )}
553
+ />
554
+ </Card>
311
555
  );
312
556
  }
313
557
 
@@ -315,204 +559,492 @@ function PeakHoursPanel({ hourly, providers }: { hourly: ProviderHourlyPoint[];
315
559
  // Subscription window insights
316
560
  // ---------------------------------------------------------------------------
317
561
 
318
- function WindowInsightsPanel({ insights }: { insights: ProviderWindowInsight[] }) {
319
- const columns: DataTableColumn<ProviderWindowInsight>[] = [
320
- { key: "provider", header: "Provider", render: i => <span className="font-medium">{i.provider}</span> },
321
- { key: "window", header: "Window", render: i => i.windowLabel },
322
- { key: "accounts", header: "Accounts", numeric: true, render: i => formatInteger(i.accounts) },
323
- {
324
- key: "consumed",
325
- header: "Windows Burned",
326
- numeric: true,
327
- render: i => (
328
- <span title="Subscription-window equivalents consumed in range (sum of used-fraction increases across accounts)">
329
- {i.fractionConsumed.toFixed(2)}
330
- </span>
331
- ),
332
- },
333
- {
334
- key: "capacity",
335
- header: "Est. Tokens / Window",
336
- numeric: true,
337
- render: i => (
338
- <span title="Provider tokens burned in range ÷ windows burned — what one full window is worth">
339
- {i.estTokensPerWindow !== null ? formatCompact(i.estTokensPerWindow) : "—"}
340
- </span>
341
- ),
342
- },
343
- {
344
- key: "peak",
345
- header: "Peak Utilization",
346
- numeric: true,
347
- render: i => (
348
- <span title="Peak of summed used fraction across accounts at any sampled instant">
349
- {formatPercent(i.peakConcurrentFraction)}
562
+ function WindowInsightsTable({
563
+ insights,
564
+ selected,
565
+ onSelect,
566
+ }: {
567
+ insights: ProviderWindowInsight[];
568
+ selected: WindowRef | null;
569
+ onSelect: (ref: WindowRef) => void;
570
+ }) {
571
+ return (
572
+ <Table
573
+ rows={insights}
574
+ rowKey={i => `${i.provider}::${i.windowKey}`}
575
+ columns={INSIGHT_COLUMNS}
576
+ initialSort={{ key: "consumed", dir: "desc" }}
577
+ limit={12}
578
+ selectedKey={selected ? `${selected.provider}::${selected.windowKey}` : null}
579
+ onRowClick={i => onSelect({ provider: i.provider, windowKey: i.windowKey })}
580
+ empty={<EmptyState title="No usage snapshots in this range" hint={SNAPSHOT_HINT} />}
581
+ />
582
+ );
583
+ }
584
+
585
+ const INSIGHT_COLUMNS: Column<ProviderWindowInsight>[] = [
586
+ {
587
+ key: "window",
588
+ header: "Window",
589
+ sort: i => `${i.provider} ${i.windowLabel}`,
590
+ render: i => <LabelCell primary={i.windowLabel} secondary={<span className="mono">{i.provider}</span>} />,
591
+ },
592
+ {
593
+ key: "accounts",
594
+ header: "Accounts",
595
+ title: "Accounts with at least one snapshot for this window in range",
596
+ align: "right",
597
+ sort: i => i.accounts,
598
+ render: i => <span className="num">{formatInteger(i.accounts)}</span>,
599
+ },
600
+ {
601
+ key: "cycles",
602
+ header: "Resets",
603
+ title: "Window resets observed (drops in used fraction)",
604
+ align: "right",
605
+ sort: i => i.cycles,
606
+ render: i => <span className="num muted">{formatInteger(i.cycles)}</span>,
607
+ },
608
+ {
609
+ key: "consumed",
610
+ header: "Windows burned",
611
+ title: "Subscription-window equivalents consumed in range (sum of used-fraction increases across accounts)",
612
+ align: "right",
613
+ sort: i => i.fractionConsumed,
614
+ render: i => <span className="num">{i.fractionConsumed.toFixed(2)}</span>,
615
+ },
616
+ {
617
+ key: "capacity",
618
+ header: "Tokens / window",
619
+ title: "Provider tokens burned in range ÷ windows burned — what one full window is worth",
620
+ align: "right",
621
+ sort: i => i.estTokensPerWindow ?? -1,
622
+ render: i =>
623
+ i.estTokensPerWindow !== null ? (
624
+ <span className="num">{formatCompact(i.estTokensPerWindow)}</span>
625
+ ) : (
626
+ <span className="dim" title="Too little of the window was consumed to extrapolate">
627
+ –
350
628
  </span>
351
629
  ),
630
+ },
631
+ {
632
+ key: "peak",
633
+ header: "Peak utilization",
634
+ title: "Peak of summed used fraction across accounts at any sampled instant; the bar is relative to fleet capacity",
635
+ align: "right",
636
+ sort: i => i.peakConcurrentFraction,
637
+ render: i => {
638
+ const load = i.accounts > 0 ? i.peakConcurrentFraction / i.accounts : 0;
639
+ return (
640
+ <MeterCell
641
+ value={i.peakConcurrentFraction}
642
+ max={i.accounts}
643
+ display={formatPercent(i.peakConcurrentFraction, 0)}
644
+ color={load >= 0.9 ? "var(--warn)" : undefined}
645
+ />
646
+ );
352
647
  },
353
- {
354
- key: "ideal",
355
- header: "Ideal Accounts",
356
- numeric: true,
357
- render: i => (
358
- <span
359
- title="Accounts needed to keep peak demand under 90% of fleet capacity"
360
- className={i.idealAccounts > i.accounts ? "stats-text-warning font-semibold" : undefined}
361
- >
362
- {formatInteger(i.idealAccounts)}
363
- {i.idealAccounts > i.accounts ? ` (have ${i.accounts})` : ""}
364
- </span>
648
+ },
649
+ {
650
+ key: "ideal",
651
+ header: "Accounts needed",
652
+ title: "Accounts needed to keep peak demand under 90% of fleet capacity",
653
+ align: "right",
654
+ sort: i => i.idealAccounts - i.accounts,
655
+ render: i =>
656
+ i.idealAccounts > i.accounts ? (
657
+ <Badge tone="warn" mono>
658
+ {formatInteger(i.idealAccounts)} · have {formatInteger(i.accounts)}
659
+ </Badge>
660
+ ) : (
661
+ <span className="num">{formatInteger(i.idealAccounts)}</span>
365
662
  ),
366
- },
367
- {
368
- key: "exhausted",
369
- header: "Exhaustions",
370
- numeric: true,
371
- render: i => (
372
- <span className={i.exhaustedEvents > 0 ? "stats-text-warning" : undefined}>
373
- {formatInteger(i.exhaustedEvents)}
374
- </span>
663
+ },
664
+ {
665
+ key: "exhausted",
666
+ header: "Exhaustions",
667
+ title: "Transitions into an exhausted state observed in range",
668
+ align: "right",
669
+ sort: i => i.exhaustedEvents,
670
+ render: i =>
671
+ i.exhaustedEvents > 0 ? (
672
+ <Badge tone="warn" mono>
673
+ {formatInteger(i.exhaustedEvents)} exhausted
674
+ </Badge>
675
+ ) : (
676
+ <span className="num dim">0</span>
375
677
  ),
376
- },
377
- ];
378
-
379
- return (
380
- <Panel
381
- title="Subscription Windows"
382
- subtitle="What each usage window buys you, and how many accounts peak demand needs"
383
- >
384
- <DataTable
385
- columns={columns}
386
- data={insights}
387
- keyExtractor={i => `${i.provider}::${i.windowKey}`}
388
- emptyText="No usage snapshots recorded yet — they accumulate whenever usage is fetched (TUI footer, /usage, omp usage)"
389
- />
390
- </Panel>
391
- );
392
- }
678
+ },
679
+ ];
393
680
 
394
681
  // ---------------------------------------------------------------------------
395
682
  // Window utilization
396
683
  // ---------------------------------------------------------------------------
397
684
 
398
- const UTILIZATION_COLORS = {
399
- ok: "#62d394",
400
- warning: "#f5c14b",
401
- exhausted: "#ff6b7d",
402
- } as const;
403
-
404
- function WindowUtilizationPanel({ usageSeries }: { usageSeries: UsageWindowSeries[] }) {
405
- const providers = useMemo(() => [...new Set(usageSeries.map(s => s.provider))], [usageSeries]);
406
- const [selected, setSelected] = useState<string | null>(null);
407
- const provider = selected !== null && providers.includes(selected) ? selected : (providers[0] ?? null);
408
- const theme = useSystemTheme();
409
- const chartTheme = CHART_THEMES[theme];
410
-
411
- // One row per (window, account): the latest recorded fraction. Snapshot
412
- // history is bursty (rows appear whenever usage is fetched), so a "how full
413
- // is each window right now" bar reads far better than a time axis.
414
- const rows = useMemo(() => {
415
- return usageSeries
416
- .filter(s => s.provider === provider)
417
- .map(s => {
418
- const latest = [...s.points].reverse().find(p => p.usedFraction !== null);
419
- return latest
420
- ? {
421
- label: `${s.windowLabel} · ${s.accountLabel}`,
422
- fraction: latest.usedFraction ?? 0,
423
- exhausted: latest.exhausted,
424
- recordedAt: latest.timestamp,
425
- }
426
- : null;
427
- })
428
- .filter(row => row !== null)
429
- .sort((a, b) => b.fraction - a.fraction);
430
- }, [usageSeries, provider]);
431
-
432
- const data = useMemo(
433
- () => ({
434
- labels: rows.map(r => r.label),
435
- datasets: [
436
- {
437
- label: "Used",
438
- data: rows.map(r => r.fraction * 100),
439
- backgroundColor: rows.map(r =>
440
- r.exhausted
441
- ? UTILIZATION_COLORS.exhausted
442
- : r.fraction >= 0.8
443
- ? UTILIZATION_COLORS.warning
444
- : UTILIZATION_COLORS.ok,
445
- ),
446
- borderWidth: 0,
447
- borderRadius: 4,
448
- barThickness: 18,
449
- },
450
- ],
451
- }),
452
- [rows],
685
+ const MINUTE_MS = 60_000;
686
+ const HOUR_MS = 60 * MINUTE_MS;
687
+ const DAY_MS = 24 * HOUR_MS;
688
+ /** Candidate utilization bucket sizes; the finest one within {@link MAX_UTIL_SLOTS} wins. */
689
+ const UTIL_STEPS = [
690
+ 5 * MINUTE_MS,
691
+ 15 * MINUTE_MS,
692
+ 30 * MINUTE_MS,
693
+ HOUR_MS,
694
+ 2 * HOUR_MS,
695
+ 3 * HOUR_MS,
696
+ 6 * HOUR_MS,
697
+ 12 * HOUR_MS,
698
+ DAY_MS,
699
+ ];
700
+ const MAX_UTIL_SLOTS = 240;
701
+ /**
702
+ * Snapshots are recorded whenever usage is fetched, so an account's samples
703
+ * are irregular. A reading holds until the next one, but only this long —
704
+ * longer silences render as gaps rather than invented plateaus.
705
+ */
706
+ const MAX_HOLD_MS = 6 * HOUR_MS;
707
+
708
+ interface AccountRow {
709
+ series: UsageWindowSeries;
710
+ /** Account label, numbered when another account shares it. */
711
+ name: string;
712
+ color: string | null;
713
+ latest: { fraction: number; exhausted: boolean; timestamp: number } | null;
714
+ peak: number | null;
715
+ samples: number;
716
+ }
717
+
718
+ function WindowUtilizationCard({
719
+ insights,
720
+ accounts,
721
+ selected,
722
+ onSelect,
723
+ }: {
724
+ /** Every provider's window insights; `null` while they load. */
725
+ insights: ProviderWindowInsight[] | null;
726
+ /** Window stats whose `usageSeries` covers only the selected provider. */
727
+ accounts: QueryResult<ProviderWindowStats>;
728
+ selected: WindowRef | null;
729
+ onSelect: (ref: WindowRef) => void;
730
+ }) {
731
+ const [hidden, toggleHidden] = useHiddenSeries();
732
+
733
+ const providers = useMemo(() => [...new Set((insights ?? []).map(i => i.provider))], [insights]);
734
+ const windows = useMemo(
735
+ () =>
736
+ (insights ?? [])
737
+ .filter(i => i.provider === selected?.provider)
738
+ .map(i => ({ key: i.windowKey, label: i.windowLabel })),
739
+ [insights, selected?.provider],
740
+ );
741
+ // While another provider's series are still on screen, show none of them.
742
+ const providerSeries = useMemo(
743
+ () => (accounts.data?.usageSeries ?? []).filter(s => s.provider === selected?.provider),
744
+ [accounts.data, selected?.provider],
453
745
  );
746
+ const loadingSeries = providerSeries.length === 0 && (accounts.loading || accounts.stale);
454
747
 
455
- const options = useMemo(() => {
456
- const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: v => `${Math.round(v)}%` });
457
- const xMax = Math.max(100, ...rows.map(r => r.fraction * 100));
458
- const shared = buildSharedPlugins({
459
- chartTheme,
460
- showLegend: false,
461
- defaultLabel: "Used",
462
- formatValue: v => `${v.toFixed(1)}%`,
463
- });
464
- return {
465
- indexAxis: "y" as const,
466
- responsive: true,
467
- maintainAspectRatio: false,
468
- plugins: {
469
- ...shared,
470
- tooltip: {
471
- ...shared.tooltip,
472
- callbacks: {
473
- label: (ctx: { dataIndex: number; parsed: { x: number | null } }) => {
474
- const row = rows[ctx.dataIndex];
475
- const used = `${(ctx.parsed.x ?? 0).toFixed(1)}% used`;
476
- return row ? `${used} · recorded ${formatRelativeTime(row.recordedAt)}` : used;
477
- },
478
- },
479
- },
480
- },
481
- scales: {
482
- x: { ...yScale, max: xMax },
483
- y: { ...sharedScaleBase, grid: { display: false } },
484
- },
485
- };
486
- }, [chartTheme, rows]);
748
+ // Distinct accounts can share a label (one email in two orgs): number the repeats.
749
+ const names = useMemo(() => {
750
+ const keysByLabel = new Map<string, string[]>();
751
+ for (const s of providerSeries) {
752
+ const keys = keysByLabel.get(s.accountLabel) ?? [];
753
+ if (!keys.includes(s.accountKey)) keys.push(s.accountKey);
754
+ keysByLabel.set(s.accountLabel, keys);
755
+ }
756
+ const out = new Map<string, string>();
757
+ for (const [label, keys] of keysByLabel) {
758
+ keys.sort();
759
+ keys.forEach((key, i) => out.set(key, keys.length > 1 ? `${label} #${i + 1}` : label));
760
+ }
761
+ return out;
762
+ }, [providerSeries]);
763
+
764
+ const chart = useMemo(
765
+ () =>
766
+ buildUtilization(
767
+ providerSeries.filter(s => s.windowKey === selected?.windowKey),
768
+ names,
769
+ ),
770
+ [providerSeries, names, selected?.windowKey],
771
+ );
772
+
773
+ const rows = useMemo<AccountRow[]>(() => {
774
+ const colorOf = new Map(chart.series.map(s => [s.key, s.color]));
775
+ return providerSeries
776
+ .map(series => {
777
+ let latest: AccountRow["latest"] = null;
778
+ let peak: number | null = null;
779
+ for (const p of series.points) {
780
+ if (p.usedFraction === null) continue;
781
+ peak = Math.max(peak ?? 0, p.usedFraction);
782
+ if (!latest || p.timestamp >= latest.timestamp)
783
+ latest = { fraction: p.usedFraction, exhausted: p.exhausted, timestamp: p.timestamp };
784
+ }
785
+ const color = series.windowKey === selected?.windowKey ? (colorOf.get(series.accountKey) ?? null) : null;
786
+ const name = names.get(series.accountKey) ?? series.accountLabel;
787
+ return { series, name, color, latest, peak, samples: series.points.length };
788
+ })
789
+ .sort(
790
+ (a, b) =>
791
+ Number(b.color !== null) - Number(a.color !== null) ||
792
+ a.series.windowLabel.localeCompare(b.series.windowLabel) ||
793
+ (b.latest?.fraction ?? -1) - (a.latest?.fraction ?? -1),
794
+ );
795
+ }, [providerSeries, names, chart.series, selected?.windowKey]);
796
+
797
+ const windowLabel = windows.find(w => w.key === selected?.windowKey)?.label;
487
798
 
488
799
  return (
489
- <Panel
490
- title="Window Utilization"
491
- subtitle="Latest recorded limit utilization per account and window — red bars are exhausted, amber above 80%"
800
+ <Card
801
+ index={5}
802
+ title="Window utilization"
803
+ description={
804
+ chart.buckets.length > 0
805
+ ? `${windowLabel ?? "Window"} used fraction per account, ${format(new Date(chart.first), "MMM d HH:mm")} – ${format(new Date(chart.last), "MMM d HH:mm")}`
806
+ : "Recorded limit utilization per account"
807
+ }
492
808
  actions={
493
- providers.length > 1 ? (
494
- <select
495
- className="stats-select"
496
- value={provider ?? ""}
497
- onChange={e => setSelected(e.target.value)}
498
- aria-label="Provider"
499
- >
500
- {providers.map(p => (
501
- <option key={p} value={p}>
502
- {p}
503
- </option>
504
- ))}
505
- </select>
506
- ) : undefined
809
+ selected && (
810
+ <div className="row" style={{ gap: 6 }}>
811
+ <select
812
+ className="input providers-select"
813
+ value={selected.provider}
814
+ onChange={e => {
815
+ const next = e.target.value;
816
+ const first = insights?.find(i => i.provider === next);
817
+ if (first) onSelect({ provider: next, windowKey: first.windowKey });
818
+ }}
819
+ aria-label="Provider"
820
+ >
821
+ {providers.map(p => (
822
+ <option key={p} value={p}>
823
+ {p}
824
+ </option>
825
+ ))}
826
+ </select>
827
+ <select
828
+ className="input providers-select"
829
+ value={selected.windowKey}
830
+ onChange={e => onSelect({ provider: selected.provider, windowKey: e.target.value })}
831
+ aria-label="Window"
832
+ >
833
+ {windows.map(w => (
834
+ <option key={w.key} value={w.key}>
835
+ {w.label}
836
+ </option>
837
+ ))}
838
+ </select>
839
+ </div>
840
+ )
507
841
  }
842
+ stale={accounts.stale}
508
843
  >
509
- <div style={{ height: Math.max(160, rows.length * 34 + 60) }}>
510
- {rows.length === 0 ? (
511
- <EmptyState message="No usage snapshots recorded yet — they accumulate whenever usage is fetched" />
512
- ) : (
513
- <Bar data={data} options={options} />
514
- )}
515
- </div>
516
- </Panel>
844
+ {insights === null ? (
845
+ <ChartSkeleton height={260} />
846
+ ) : selected === null ? (
847
+ <EmptyState title="No usage snapshots in this range" hint={SNAPSHOT_HINT} />
848
+ ) : (
849
+ <QueryView query={accounts} skeleton={<ChartSkeleton height={260} />}>
850
+ {() =>
851
+ loadingSeries ? (
852
+ <ChartSkeleton height={260} />
853
+ ) : (
854
+ <div className="stack" style={{ gap: 16 }}>
855
+ <div className="stack" style={{ gap: 12 }}>
856
+ <Chart
857
+ slots={chart.buckets.length}
858
+ tickLabel={i =>
859
+ formatTick(
860
+ chart.buckets[i],
861
+ chart.last - chart.first > DAY_MS ? DAY_MS : chart.bucketMs,
862
+ )
863
+ }
864
+ tooltipTitle={i => formatBucket(chart.buckets[i], HOUR_MS)}
865
+ series={chart.series}
866
+ hidden={hidden}
867
+ kind="line"
868
+ stacked={false}
869
+ height={260}
870
+ yMax={Math.max(1, chart.max)}
871
+ format={v => formatPercent(v, 0)}
872
+ formatTooltip={v => formatPercent(v, 1)}
873
+ references={[{ value: 1, label: "100%" }]}
874
+ emptyLabel="No utilization readings for this window"
875
+ tooltipExtra={i => {
876
+ const exhausted = chart.exhausted[i];
877
+ return exhausted.length > 0 ? (
878
+ <div className="chart-tooltip-row">
879
+ <span className="chart-tooltip-label tone-bad">Exhausted</span>
880
+ <span className="chart-tooltip-value">{exhausted.join(", ")}</span>
881
+ </div>
882
+ ) : null;
883
+ }}
884
+ />
885
+ {chart.series.length > 1 && (
886
+ <Legend
887
+ items={chart.series.map(s => ({ key: s.key, label: s.label, color: s.color }))}
888
+ hidden={hidden}
889
+ onToggle={toggleHidden}
890
+ />
891
+ )}
892
+ </div>
893
+ <div className="providers-accounts">
894
+ <Table
895
+ rows={rows}
896
+ rowKey={r => `${r.series.windowKey}::${r.series.accountKey}`}
897
+ columns={ACCOUNT_COLUMNS}
898
+ limit={16}
899
+ onRowClick={r => onSelect({ provider: r.series.provider, windowKey: r.series.windowKey })}
900
+ dense
901
+ />
902
+ </div>
903
+ </div>
904
+ )
905
+ }
906
+ </QueryView>
907
+ )}
908
+ </Card>
517
909
  );
518
910
  }
911
+
912
+ interface UtilizationChart {
913
+ buckets: number[];
914
+ bucketMs: number;
915
+ first: number;
916
+ last: number;
917
+ max: number;
918
+ series: ChartSeries[];
919
+ /** Accounts seen exhausted per bucket. */
920
+ exhausted: string[][];
921
+ }
922
+
923
+ /** One line per account over a shared axis spanning the window's recorded snapshots. */
924
+ function buildUtilization(accounts: UsageWindowSeries[], names: ReadonlyMap<string, string>): UtilizationChart {
925
+ let first = Number.POSITIVE_INFINITY;
926
+ let last = Number.NEGATIVE_INFINITY;
927
+ for (const s of accounts) {
928
+ for (const p of s.points) {
929
+ if (p.timestamp < first) first = p.timestamp;
930
+ if (p.timestamp > last) last = p.timestamp;
931
+ }
932
+ }
933
+ if (!(last >= first))
934
+ return { buckets: [], bucketMs: HOUR_MS, first: 0, last: 0, max: 0, series: [], exhausted: [] };
935
+
936
+ const span = last - first;
937
+ const bucketMs = UTIL_STEPS.find(step => span / step < MAX_UTIL_SLOTS) ?? DAY_MS;
938
+ const buckets = bucketAxis("all", [first], bucketMs, last);
939
+ const index = (ts: number) => Math.floor((ts - buckets[0]) / bucketMs);
940
+ const exhausted = buckets.map((): string[] => []);
941
+ let max = 0;
942
+
943
+ const nameOf = (account: UsageWindowSeries) => names.get(account.accountKey) ?? account.accountLabel;
944
+ const ordered = [...accounts].sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
945
+ const series = ordered.map((account, rank): ChartSeries => {
946
+ // Last reading in each bucket, then hold it forward across short silences.
947
+ const values: (number | null)[] = buckets.map(() => null);
948
+ const readAt: number[] = buckets.map(() => 0);
949
+ for (const p of account.points) {
950
+ const i = index(p.timestamp);
951
+ if (i < 0 || i >= buckets.length) continue;
952
+ if (p.exhausted && !exhausted[i].includes(nameOf(account))) exhausted[i].push(nameOf(account));
953
+ if (p.usedFraction === null || p.timestamp < readAt[i]) continue;
954
+ values[i] = p.usedFraction;
955
+ readAt[i] = p.timestamp;
956
+ max = Math.max(max, p.usedFraction);
957
+ }
958
+ let held: number | null = null;
959
+ let heldAt = 0;
960
+ for (let i = 0; i < buckets.length; i++) {
961
+ if (values[i] !== null) {
962
+ held = values[i];
963
+ heldAt = readAt[i];
964
+ } else if (held !== null && buckets[i] - heldAt <= MAX_HOLD_MS) {
965
+ values[i] = held;
966
+ }
967
+ }
968
+ return {
969
+ key: account.accountKey,
970
+ label: nameOf(account),
971
+ color: SERIES_COLORS[rank % SERIES_COLORS.length],
972
+ values,
973
+ };
974
+ });
975
+
976
+ return { buckets, bucketMs, first, last, max, series, exhausted };
977
+ }
978
+
979
+ function statusBadge(latest: AccountRow["latest"]): ReactNode {
980
+ if (!latest) return <span className="dim">No reading</span>;
981
+ if (latest.exhausted) return <Badge tone="bad">Exhausted</Badge>;
982
+ if (latest.fraction >= 0.8) return <Badge tone="warn">High</Badge>;
983
+ return <Badge tone="ok">OK</Badge>;
984
+ }
985
+
986
+ const ACCOUNT_COLUMNS: Column<AccountRow>[] = [
987
+ {
988
+ key: "account",
989
+ header: "Account",
990
+ sort: r => r.name,
991
+ render: r => (
992
+ <span className="row" style={{ gap: 8 }}>
993
+ <span className="providers-swatch-slot">{r.color && <Swatch color={r.color} />}</span>
994
+ <span className="truncate providers-account" title={r.series.accountKey}>
995
+ {r.name}
996
+ </span>
997
+ </span>
998
+ ),
999
+ },
1000
+ {
1001
+ key: "window",
1002
+ header: "Window",
1003
+ sort: r => r.series.windowLabel,
1004
+ render: r => <span className={r.color ? undefined : "muted"}>{r.series.windowLabel}</span>,
1005
+ },
1006
+ {
1007
+ key: "latest",
1008
+ header: "Latest used",
1009
+ align: "right",
1010
+ sort: r => r.latest?.fraction ?? -1,
1011
+ render: r =>
1012
+ r.latest ? (
1013
+ <MeterCell
1014
+ value={r.latest.fraction}
1015
+ max={1}
1016
+ display={formatPercent(r.latest.fraction, 0)}
1017
+ color={r.latest.exhausted ? "var(--bad)" : r.latest.fraction >= 0.8 ? "var(--warn)" : "var(--ok)"}
1018
+ />
1019
+ ) : (
1020
+ <span className="dim">–</span>
1021
+ ),
1022
+ },
1023
+ {
1024
+ key: "status",
1025
+ header: "Status",
1026
+ sort: r => (r.latest?.exhausted ? 2 : (r.latest?.fraction ?? 0) >= 0.8 ? 1 : 0),
1027
+ render: r => statusBadge(r.latest),
1028
+ },
1029
+ {
1030
+ key: "peak",
1031
+ header: "Peak in range",
1032
+ align: "right",
1033
+ sort: r => r.peak ?? -1,
1034
+ render: r => <span className="num">{r.peak === null ? "–" : formatPercent(r.peak, 0)}</span>,
1035
+ },
1036
+ {
1037
+ key: "samples",
1038
+ header: "Snapshots",
1039
+ align: "right",
1040
+ sort: r => r.samples,
1041
+ render: r => <span className="num muted">{formatInteger(r.samples)}</span>,
1042
+ },
1043
+ {
1044
+ key: "recorded",
1045
+ header: "Recorded",
1046
+ align: "right",
1047
+ sort: r => r.latest?.timestamp ?? 0,
1048
+ render: r => <span className="muted">{r.latest ? formatRelativeTime(r.latest.timestamp) : "–"}</span>,
1049
+ },
1050
+ ];