@oh-my-pi/omp-stats 18.3.5 → 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,14 @@
1
- import { rangeMeta } from "../components/range-meta";
2
1
  import type {
3
2
  AgentType,
4
3
  AgentTypeStats,
5
- BehaviorOverallStats,
6
- BehaviorTimeSeriesPoint,
7
4
  CostTimeSeriesPoint,
8
5
  FolderStats,
6
+ MessageStats,
9
7
  ModelPerformancePoint,
10
- TimeRange,
11
8
  ToolUsageStats,
12
9
  } from "../types";
10
+ import { modelKey } from "./colors";
11
+ import { isUnpricedMessage } from "./formatters";
13
12
 
14
13
  /** Fixed display order for the agent-token-share breakdown. */
15
14
  const AGENT_TYPE_ORDER: AgentType[] = ["main", "subagent", "advisor"];
@@ -72,12 +71,39 @@ export function buildAgentTokenShare(stats: AgentTypeStats[]): AgentTokenShareVi
72
71
  return { totalTokens, totalCost, segments };
73
72
  }
74
73
 
75
- export interface CostSummaryView {
74
+ /** API-equivalent cost split by billing component. */
75
+ export interface CostComponents {
76
+ costInput: number;
77
+ costOutput: number;
78
+ costCacheRead: number;
79
+ costCacheWrite: number;
80
+ }
81
+
82
+ /** One model's (model + provider) totals across the cost series. */
83
+ export interface CostModelRow extends CostComponents {
84
+ /** `model::provider` (see `modelKey`). */
85
+ key: string;
86
+ model: string;
87
+ provider: string;
88
+ cost: number;
89
+ requests: number;
90
+ unpricedRequests: number;
91
+ /** Fraction (0-1) of the total estimate. */
92
+ share: number;
93
+ }
94
+
95
+ export interface CostSummaryView extends CostComponents {
76
96
  totalCost: number;
97
+ requests: number;
77
98
  unpricedRequests: number;
99
+ /** Day buckets that carry any usage. */
100
+ activeDays: number;
101
+ /** Total estimate ÷ active days. */
78
102
  avgDailyCost: number;
79
- topModelName: string;
80
- topModelCost: number;
103
+ /** Ranked by estimate, then requests, then key. */
104
+ models: CostModelRow[];
105
+ /** Highest-estimate model; `null` when nothing is priced. */
106
+ topModel: CostModelRow | null;
81
107
  }
82
108
 
83
109
  export interface ModelPerformanceDataPoint {
@@ -87,176 +113,357 @@ export interface ModelPerformanceDataPoint {
87
113
  requests: number;
88
114
  }
89
115
 
90
- export interface ModelPerformanceSeries {
91
- label: string;
92
- data: ModelPerformanceDataPoint[];
93
- }
94
-
95
- export interface BehaviorSummaryView {
96
- totalMessages: number;
97
- totalYelling: number;
98
- totalProfanity: number;
99
- totalAnguish: number;
100
- totalFrustration: number;
101
- highestFrictionModel: {
102
- model: string;
103
- provider: string;
104
- score: number;
105
- } | null;
106
- }
107
-
108
116
  export interface FolderRowView extends FolderStats {
109
- costPercentage: number;
110
- requestsPercentage: number;
117
+ /** Uncached input + cache reads + cache writes + output. */
118
+ conversationTokens: number;
119
+ /** Share of every folder's requests in range (0-1). */
120
+ requestShare: number;
121
+ /** Share of every folder's API-equivalent cost in range (0-1). */
122
+ costShare: number;
123
+ /** Session directory under a system temp location (`/tmp`, `/var/folders`, …). */
124
+ temporary: boolean;
111
125
  }
112
126
 
113
- export function buildCostSummary(costSeries: CostTimeSeriesPoint[]): CostSummaryView {
114
- const totalCost = costSeries.reduce((sum, p) => sum + p.cost, 0);
115
- const unpricedRequests = costSeries.reduce((sum, point) => sum + point.unpricedRequests, 0);
116
- const dayBuckets = new Set(costSeries.map(p => p.timestamp)).size;
117
- const avgDailyCost = dayBuckets > 0 ? totalCost / dayBuckets : 0;
127
+ export interface FolderTableView {
128
+ rows: FolderRowView[];
129
+ totalRequests: number;
130
+ failedRequests: number;
131
+ totalCost: number;
132
+ unpricedRequests: number;
133
+ conversationTokens: number;
134
+ /** Cache reads ÷ (uncached input + cache reads) across every folder. */
135
+ cacheRate: number;
136
+ /** Largest single-folder request count / cost, for meter scales. */
137
+ maxRequests: number;
138
+ maxCost: number;
139
+ temporaryCount: number;
140
+ }
118
141
 
119
- const modelTotals = new Map<string, number>();
142
+ /** Totals, per-model breakdown and component split for the Costs page. */
143
+ export function buildCostSummary(costSeries: readonly CostTimeSeriesPoint[]): CostSummaryView {
144
+ const summary: CostSummaryView = {
145
+ totalCost: 0,
146
+ requests: 0,
147
+ unpricedRequests: 0,
148
+ activeDays: new Set(costSeries.map(p => p.timestamp)).size,
149
+ avgDailyCost: 0,
150
+ costInput: 0,
151
+ costOutput: 0,
152
+ costCacheRead: 0,
153
+ costCacheWrite: 0,
154
+ models: [],
155
+ topModel: null,
156
+ };
157
+ const byKey = new Map<string, CostModelRow>();
120
158
  for (const point of costSeries) {
121
- modelTotals.set(point.model, (modelTotals.get(point.model) ?? 0) + point.cost);
122
- }
123
-
124
- let topModelName = "";
125
- let topModelCost = 0;
126
- for (const [model, cost] of modelTotals) {
127
- if (cost > topModelCost) {
128
- topModelName = model;
129
- topModelCost = cost;
159
+ const key = modelKey(point.model, point.provider);
160
+ let row = byKey.get(key);
161
+ if (!row) {
162
+ row = {
163
+ key,
164
+ model: point.model,
165
+ provider: point.provider,
166
+ cost: 0,
167
+ requests: 0,
168
+ unpricedRequests: 0,
169
+ costInput: 0,
170
+ costOutput: 0,
171
+ costCacheRead: 0,
172
+ costCacheWrite: 0,
173
+ share: 0,
174
+ };
175
+ byKey.set(key, row);
130
176
  }
177
+ addCostPoint(row, point);
178
+ addCostPoint(summary, point);
179
+ row.cost += point.cost;
180
+ summary.totalCost += point.cost;
131
181
  }
132
182
 
133
- return {
134
- totalCost,
135
- unpricedRequests,
136
- avgDailyCost,
137
- topModelName,
138
- topModelCost,
139
- };
183
+ summary.avgDailyCost = summary.activeDays > 0 ? summary.totalCost / summary.activeDays : 0;
184
+ summary.models = [...byKey.values()].sort(
185
+ (a, b) => b.cost - a.cost || b.requests - a.requests || a.key.localeCompare(b.key),
186
+ );
187
+ for (const row of summary.models) row.share = summary.totalCost > 0 ? row.cost / summary.totalCost : 0;
188
+ const top = summary.models[0];
189
+ summary.topModel = top !== undefined && top.cost > 0 ? top : null;
190
+ return summary;
140
191
  }
141
192
 
142
- export function buildModelPerformanceLookup(
143
- points: ModelPerformancePoint[],
144
- range: TimeRange,
145
- ): Map<string, ModelPerformanceSeries> {
146
- if (points.length === 0) return new Map();
147
-
148
- const meta = rangeMeta(range);
149
- const bucketMs = meta.bucketMs;
150
- const bucketCount = meta.bucketCount;
151
-
152
- const buckets =
153
- bucketCount > 0
154
- ? (() => {
155
- const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0);
156
- const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / bucketMs) * bucketMs;
157
- const start = anchor - (bucketCount - 1) * bucketMs;
158
- return Array.from({ length: bucketCount }, (_, index) => start + index * bucketMs);
159
- })()
160
- : Array.from(new Set(points.map(p => p.timestamp))).sort((a, b) => a - b);
161
- const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index]));
162
- const seriesByKey = new Map<string, ModelPerformanceSeries>();
193
+ function addCostPoint(
194
+ target: CostComponents & { requests: number; unpricedRequests: number },
195
+ point: CostTimeSeriesPoint,
196
+ ): void {
197
+ target.costInput += point.costInput;
198
+ target.costOutput += point.costOutput;
199
+ target.costCacheRead += point.costCacheRead;
200
+ target.costCacheWrite += point.costCacheWrite;
201
+ target.requests += point.requests;
202
+ target.unpricedRequests += point.unpricedRequests;
203
+ }
163
204
 
205
+ /**
206
+ * Per-model (`model::provider`) performance points in ascending time order,
207
+ * with TTFT converted to seconds. Only buckets where the model served
208
+ * requests are present, so sparse history (all time) keeps every point.
209
+ */
210
+ export function buildModelPerformanceLookup(
211
+ points: readonly ModelPerformancePoint[],
212
+ ): Map<string, ModelPerformanceDataPoint[]> {
213
+ const byKey = new Map<string, ModelPerformanceDataPoint[]>();
164
214
  for (const point of points) {
165
- const key = `${point.model}::${point.provider}`;
166
- let series = seriesByKey.get(key);
215
+ const key = modelKey(point.model, point.provider);
216
+ let series = byKey.get(key);
167
217
  if (!series) {
168
- series = {
169
- label: `${point.model} (${point.provider})`,
170
- data: buckets.map(timestamp => ({
171
- timestamp,
172
- avgTtftSeconds: null,
173
- avgTokensPerSecond: null,
174
- requests: 0,
175
- })),
176
- };
177
- seriesByKey.set(key, series);
218
+ series = [];
219
+ byKey.set(key, series);
178
220
  }
179
-
180
- const index = bucketIndex.get(point.timestamp);
181
- if (index === undefined) continue;
182
-
183
- series.data[index] = {
221
+ series.push({
184
222
  timestamp: point.timestamp,
185
223
  avgTtftSeconds: point.avgTtft !== null ? point.avgTtft / 1000 : null,
186
224
  avgTokensPerSecond: point.avgTokensPerSecond,
187
225
  requests: point.requests,
188
- };
226
+ });
189
227
  }
190
-
191
- return seriesByKey;
228
+ for (const series of byKey.values()) series.sort((a, b) => a.timestamp - b.timestamp);
229
+ return byKey;
192
230
  }
193
231
 
194
- export function buildBehaviorSummary(
195
- overall: BehaviorOverallStats,
196
- series: BehaviorTimeSeriesPoint[],
197
- ): BehaviorSummaryView {
198
- const totalFrustration = overall.totalNegation + overall.totalRepetition + overall.totalBlame;
199
-
200
- const totals = new Map<string, { model: string; provider: string; score: number }>();
201
- for (const point of series) {
202
- const key = `${point.model}::${point.provider}`;
203
- const existing = totals.get(key);
204
- const score = point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame;
205
- if (existing) {
206
- existing.score += score;
207
- } else {
208
- totals.set(key, { model: point.model, provider: point.provider, score });
209
- }
232
+ /**
233
+ * Folder names are session directories with path separators flattened to `-`
234
+ * (`/tmp/x` → `/tmp-x/`, `-tmp-x`), so temp locations are recognized by their prefix.
235
+ */
236
+ const TEMP_FOLDER_RE = /^\/?-?(?:private-)?(?:tmp|var-folders)(?:[-/]|$)/;
237
+
238
+ /** Table rows plus range totals for the Projects page; linear in the (possibly tens of thousands of) folders. */
239
+ export function buildFolderRows(folders: readonly FolderStats[]): FolderTableView {
240
+ let totalRequests = 0;
241
+ let failedRequests = 0;
242
+ let totalCost = 0;
243
+ let unpricedRequests = 0;
244
+ let conversationTokens = 0;
245
+ let input = 0;
246
+ let cacheRead = 0;
247
+ let maxRequests = 0;
248
+ let maxCost = 0;
249
+ for (const f of folders) {
250
+ totalRequests += f.totalRequests;
251
+ failedRequests += f.failedRequests;
252
+ totalCost += f.totalCost;
253
+ unpricedRequests += f.unpricedRequests;
254
+ conversationTokens += f.totalInputTokens + f.totalCacheReadTokens + f.totalCacheWriteTokens + f.totalOutputTokens;
255
+ input += f.totalInputTokens;
256
+ cacheRead += f.totalCacheReadTokens;
257
+ if (f.totalRequests > maxRequests) maxRequests = f.totalRequests;
258
+ if (f.totalCost > maxCost) maxCost = f.totalCost;
210
259
  }
211
260
 
212
- let highestFrictionModel: { model: string; provider: string; score: number } | null = null;
213
- for (const entry of totals.values()) {
214
- if (!highestFrictionModel || entry.score > highestFrictionModel.score) {
215
- highestFrictionModel = entry;
216
- }
217
- }
261
+ let temporaryCount = 0;
262
+ const rows = folders.map(f => {
263
+ const temporary = TEMP_FOLDER_RE.test(f.folder);
264
+ if (temporary) temporaryCount++;
265
+ return {
266
+ ...f,
267
+ conversationTokens:
268
+ f.totalInputTokens + f.totalCacheReadTokens + f.totalCacheWriteTokens + f.totalOutputTokens,
269
+ requestShare: totalRequests > 0 ? f.totalRequests / totalRequests : 0,
270
+ costShare: totalCost > 0 ? f.totalCost / totalCost : 0,
271
+ temporary,
272
+ };
273
+ });
218
274
 
219
275
  return {
220
- totalMessages: overall.totalMessages,
221
- totalYelling: overall.totalYelling,
222
- totalProfanity: overall.totalProfanity,
223
- totalAnguish: overall.totalAnguish,
224
- totalFrustration,
225
- highestFrictionModel,
276
+ rows,
277
+ totalRequests,
278
+ failedRequests,
279
+ totalCost,
280
+ unpricedRequests,
281
+ conversationTokens,
282
+ cacheRate: input + cacheRead > 0 ? cacheRead / (input + cacheRead) : 0,
283
+ maxRequests,
284
+ maxCost,
285
+ temporaryCount,
226
286
  };
227
287
  }
228
288
 
229
- export function buildFolderRows(folders: FolderStats[]): FolderRowView[] {
230
- const sorted = [...folders].sort((a, b) => {
231
- if (b.totalCost !== a.totalCost) {
232
- return b.totalCost - a.totalCost;
233
- }
234
- return b.totalRequests - a.totalRequests;
235
- });
236
-
237
- const maxCost = sorted.reduce((max, f) => Math.max(max, f.totalCost), 0);
238
- const maxRequests = sorted.reduce((max, f) => Math.max(max, f.totalRequests), 0);
239
-
240
- return sorted.map(f => ({
241
- ...f,
242
- costPercentage: maxCost > 0 ? (f.totalCost / maxCost) * 100 : 0,
243
- requestsPercentage: maxRequests > 0 ? (f.totalRequests / maxRequests) * 100 : 0,
244
- }));
245
- }
246
-
247
289
  /** Table row for the Tools route: usage stats plus derived rates/shares. */
248
290
  export interface ToolRowView extends ToolUsageStats {
249
291
  /** errors / calls (0 for zero calls). */
250
292
  errorRate: number;
251
- /** Calls relative to the busiest tool, 0-100, for the share bar. */
252
- callsPercentage: number;
293
+ /** This tool's fraction (0..1) of all calls in range. */
294
+ callFraction: number;
295
+ /** This tool's fraction (0..1) of all attributed tokens in range. */
296
+ tokenFraction: number;
297
+ /** This tool's fraction (0..1) of all attributed cost in range. */
298
+ costFraction: number;
299
+ /** Mean result characters fed back into context per call. */
300
+ avgResultChars: number;
253
301
  }
254
302
 
255
303
  export function buildToolRows(tools: ToolUsageStats[]): ToolRowView[] {
256
- const maxCalls = tools.reduce((max, t) => Math.max(max, t.calls), 0);
304
+ let calls = 0;
305
+ let tokens = 0;
306
+ let cost = 0;
307
+ for (const t of tools) {
308
+ calls += t.calls;
309
+ tokens += t.totalTokensShare;
310
+ cost += t.costShare;
311
+ }
257
312
  return tools.map(t => ({
258
313
  ...t,
259
314
  errorRate: t.calls > 0 ? t.errors / t.calls : 0,
260
- callsPercentage: maxCalls > 0 ? (t.calls / maxCalls) * 100 : 0,
315
+ callFraction: calls > 0 ? t.calls / calls : 0,
316
+ tokenFraction: tokens > 0 ? t.totalTokensShare / tokens : 0,
317
+ costFraction: cost > 0 ? t.costShare / cost : 0,
318
+ avgResultChars: t.calls > 0 ? t.resultChars / t.calls : 0,
261
319
  }));
262
320
  }
321
+
322
+ export type RequestStatus = "ok" | "aborted" | "failed";
323
+
324
+ /** Outcome of one request: cancellations abort; provider/transport errors fail. */
325
+ export function requestStatus(row: Pick<MessageStats, "stopReason" | "errorMessage">): RequestStatus {
326
+ if (row.stopReason === "aborted") return "aborted";
327
+ if (row.stopReason === "error" || row.errorMessage) return "failed";
328
+ return "ok";
329
+ }
330
+
331
+ export interface RequestLogSummary {
332
+ requests: number;
333
+ failed: number;
334
+ aborted: number;
335
+ tokens: number;
336
+ cost: number;
337
+ /** Requests with tokens but no price (see `isUnpricedMessage`). */
338
+ unpriced: number;
339
+ medianDuration: number | null;
340
+ p95Duration: number | null;
341
+ medianTtft: number | null;
342
+ oldest: number | null;
343
+ newest: number | null;
344
+ }
345
+
346
+ /** Nearest-rank quantile of an ascending list; `null` when empty. */
347
+ function quantile(sorted: readonly number[], q: number): number | null {
348
+ if (sorted.length === 0) return null;
349
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))];
350
+ }
351
+
352
+ /** Totals and latency quantiles over a list of requests. */
353
+ export function summarizeRequests(rows: readonly MessageStats[]): RequestLogSummary {
354
+ let failed = 0;
355
+ let aborted = 0;
356
+ let tokens = 0;
357
+ let cost = 0;
358
+ let unpriced = 0;
359
+ let oldest: number | null = null;
360
+ let newest: number | null = null;
361
+ const durations: number[] = [];
362
+ const ttfts: number[] = [];
363
+ for (const row of rows) {
364
+ const status = requestStatus(row);
365
+ if (status === "failed") failed++;
366
+ else if (status === "aborted") aborted++;
367
+ tokens += row.usage.totalTokens;
368
+ cost += row.usage.cost.total;
369
+ if (isUnpricedMessage(row)) unpriced++;
370
+ if (row.duration !== null) durations.push(row.duration);
371
+ if (row.ttft !== null) ttfts.push(row.ttft);
372
+ if (oldest === null || row.timestamp < oldest) oldest = row.timestamp;
373
+ if (newest === null || row.timestamp > newest) newest = row.timestamp;
374
+ }
375
+ durations.sort((a, b) => a - b);
376
+ ttfts.sort((a, b) => a - b);
377
+ return {
378
+ requests: rows.length,
379
+ failed,
380
+ aborted,
381
+ tokens,
382
+ cost,
383
+ unpriced,
384
+ medianDuration: quantile(durations, 0.5),
385
+ p95Duration: quantile(durations, 0.95),
386
+ medianTtft: quantile(ttfts, 0.5),
387
+ oldest,
388
+ newest,
389
+ };
390
+ }
391
+
392
+ const SIGNATURE_MAX = 180;
393
+ const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
394
+ const PREFIXED_ID_RE = /\b(?:req|msg|call|toolu|chatcmpl|resp|run|gen)[-_][A-Za-z0-9_-]{6,}/g;
395
+ const HEX_RE = /\b[0-9a-f]{12,}\b/gi;
396
+ /** Free-standing numbers; digits inside identifiers (`gpt-5.2`) are kept. */
397
+ const NUMBER_RE = /(?<![\w.-])\d+(?:\.\d+)?/g;
398
+ const HTTP_STATUS_RE = /^[1-5]\d\d$/;
399
+
400
+ /**
401
+ * Normalized error message used to group failures: whitespace collapsed,
402
+ * request ids / hashes / counters replaced with placeholders (HTTP status
403
+ * codes kept), truncated.
404
+ */
405
+ export function errorSignature(message: string | null): string {
406
+ if (!message?.trim()) return "Unknown error";
407
+ const normalized = message
408
+ .replace(/\s+/g, " ")
409
+ .trim()
410
+ .replace(UUID_RE, "<id>")
411
+ .replace(PREFIXED_ID_RE, "<id>")
412
+ .replace(HEX_RE, "<hex>")
413
+ .replace(NUMBER_RE, n => (HTTP_STATUS_RE.test(n) ? n : "N"));
414
+ return normalized.length > SIGNATURE_MAX ? `${normalized.slice(0, SIGNATURE_MAX - 1)}…` : normalized;
415
+ }
416
+
417
+ export interface ErrorGroupModel {
418
+ model: string;
419
+ provider: string;
420
+ count: number;
421
+ }
422
+
423
+ export interface ErrorGroupView {
424
+ signature: string;
425
+ count: number;
426
+ firstSeen: number;
427
+ lastSeen: number;
428
+ /** Most recent occurrence (full message, drawer target). */
429
+ latest: MessageStats;
430
+ /** Affected models, most failures first. */
431
+ models: ErrorGroupModel[];
432
+ }
433
+
434
+ /** Group failures by `errorSignature`; most frequent first, then most recent. */
435
+ export function groupErrorsBySignature(rows: readonly MessageStats[]): ErrorGroupView[] {
436
+ const groups = new Map<string, ErrorGroupView & { byModel: Map<string, ErrorGroupModel> }>();
437
+ for (const row of rows) {
438
+ const signature = errorSignature(row.errorMessage);
439
+ let group = groups.get(signature);
440
+ if (!group) {
441
+ group = {
442
+ signature,
443
+ count: 0,
444
+ firstSeen: row.timestamp,
445
+ lastSeen: row.timestamp,
446
+ latest: row,
447
+ models: [],
448
+ byModel: new Map(),
449
+ };
450
+ groups.set(signature, group);
451
+ }
452
+ group.count++;
453
+ if (row.timestamp < group.firstSeen) group.firstSeen = row.timestamp;
454
+ if (row.timestamp > group.lastSeen) {
455
+ group.lastSeen = row.timestamp;
456
+ group.latest = row;
457
+ }
458
+ const key = modelKey(row.model, row.provider);
459
+ const model = group.byModel.get(key);
460
+ if (model) model.count++;
461
+ else group.byModel.set(key, { model: row.model, provider: row.provider, count: 1 });
462
+ }
463
+ return [...groups.values()]
464
+ .map(({ byModel, ...group }) => ({
465
+ ...group,
466
+ models: [...byModel.values()].sort((a, b) => b.count - a.count || a.model.localeCompare(b.model)),
467
+ }))
468
+ .sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen);
469
+ }
@@ -1,10 +1,7 @@
1
1
  import "./styles.css";
2
- // Side-effect import: registers all Chart.js scales/elements/plugins exactly
3
- // once before any chart renders. Without it the first chart throws
4
- // "category is not a registered scale" and unmounts the whole app.
5
- import "./data/charts";
6
2
  import { createRoot } from "react-dom/client";
7
3
  import App from "./App";
8
4
 
9
- const root = createRoot(document.getElementById("root")!);
10
- root.render(<App />);
5
+ const root = document.getElementById("root");
6
+ if (!root) throw new Error("missing #root");
7
+ createRoot(root).render(<App />);