@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,118 +1,421 @@
1
- import { useMemo } from "react";
1
+ import { ArrowUpRight, X } from "lucide-react";
2
+ import { useMemo, useState } from "react";
2
3
  import { getRecentErrors } from "../api";
3
- import { formatInteger, formatMessageCost, formatRelativeTime } from "../data/formatters";
4
- import { useResource } from "../data/useResource";
4
+ import { BarList } from "../charts";
5
+ import { modelKey } from "../data/colors";
6
+ import {
7
+ formatCompact,
8
+ formatFolder,
9
+ formatInteger,
10
+ formatMessageCost,
11
+ formatRelativeTime,
12
+ formatTimestamp,
13
+ } from "../data/formatters";
14
+ import { useQuery } from "../data/query";
15
+ import { rangeMeta } from "../data/range";
16
+ import { type ErrorGroupView, errorSignature, groupErrorsBySignature } from "../data/view-models";
5
17
  import type { MessageStats, TimeRange } from "../types";
6
- import { AsyncBoundary, DataTable, Panel, StatusPill } from "../ui";
18
+ import {
19
+ Card,
20
+ type Column,
21
+ EmptyState,
22
+ LabelCell,
23
+ MeterCell,
24
+ PageHeader,
25
+ QueryView,
26
+ SearchInput,
27
+ Skeleton,
28
+ Stat,
29
+ StatGrid,
30
+ Table,
31
+ TableSkeleton,
32
+ } from "../ui";
33
+ import "./errors.css";
7
34
 
8
35
  export interface ErrorsRouteProps {
9
36
  active: boolean;
10
37
  range: TimeRange;
11
- refreshTrigger: number;
12
38
  onRequestClick: (id: number) => void;
13
39
  }
14
40
 
15
- export function ErrorsRoute({ active, range, refreshTrigger, onRequestClick }: ErrorsRouteProps) {
16
- const {
17
- data: recentErrors,
18
- error,
19
- loading,
20
- } = useResource(["recent-errors-dense", range, refreshTrigger], signal => getRecentErrors(range, 50, signal), {
21
- pollMs: 30000,
22
- enabled: active,
23
- });
24
-
25
- const columns = useMemo(
26
- () => [
27
- {
28
- key: "model",
29
- header: "Model",
30
- render: (item: MessageStats) => (
31
- <div>
32
- <div className="stats-font-medium stats-text-primary">{item.model}</div>
33
- <div className="stats-text-xs stats-text-muted">{item.provider}</div>
34
- </div>
35
- ),
36
- },
37
- {
38
- key: "timestamp",
39
- header: "Time",
40
- render: (item: MessageStats) => formatRelativeTime(item.timestamp),
41
- },
42
- {
43
- key: "errorMessage",
44
- header: "Error Message",
45
- render: (item: MessageStats) => (
46
- <div
47
- className="stats-text-xs stats-text-danger stats-truncate stats-max-w-md stats-font-mono"
48
- title={item.errorMessage || ""}
41
+ /** How many of the newest failures to load; "Load more" steps through these. */
42
+ const LOAD_STEPS = [50, 200, 1_000] as const;
43
+
44
+ export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps) {
45
+ const [step, setStep] = useState(0);
46
+ const limit = LOAD_STEPS[step];
47
+ const errors = useQuery(["errors", range, limit], () => getRecentErrors(range, limit), { enabled: active });
48
+ const [selectedSignature, setSelectedSignature] = useState<string | null>(null);
49
+ const [selectedModel, setSelectedModel] = useState<string | null>(null);
50
+ const [search, setSearch] = useState("");
51
+ const meta = rangeMeta(range);
52
+
53
+ const view = useMemo(() => {
54
+ const rows = (errors.data ?? []).map(row => ({ row, signature: errorSignature(row.errorMessage) }));
55
+ const groups = groupErrorsBySignature(errors.data ?? []);
56
+ const byModel = new Map<string, { model: string; provider: string; count: number }>();
57
+ for (const { row } of rows) {
58
+ const key = modelKey(row.model, row.provider);
59
+ const entry = byModel.get(key);
60
+ if (entry) entry.count++;
61
+ else byModel.set(key, { model: row.model, provider: row.provider, count: 1 });
62
+ }
63
+ const models = [...byModel.entries()]
64
+ .map(([key, entry]) => ({ key, ...entry }))
65
+ .sort((a, b) => b.count - a.count || a.model.localeCompare(b.model));
66
+ const newest = rows.reduce<MessageStats | null>(
67
+ (best, { row }) => (!best || row.timestamp > best.timestamp ? row : best),
68
+ null,
69
+ );
70
+ return { rows, groups, models, newest };
71
+ }, [errors.data]);
72
+
73
+ // Selections that no longer exist in this range's data are ignored.
74
+ const signature = view.groups.some(g => g.signature === selectedSignature) ? selectedSignature : null;
75
+ const model = view.models.some(m => m.key === selectedModel) ? selectedModel : null;
76
+
77
+ const filtered = useMemo(() => {
78
+ const needle = search.trim().toLowerCase();
79
+ return view.rows
80
+ .filter(
81
+ entry =>
82
+ (signature === null || entry.signature === signature) &&
83
+ (model === null || modelKey(entry.row.model, entry.row.provider) === model) &&
84
+ (needle === "" ||
85
+ entry.row.model.toLowerCase().includes(needle) ||
86
+ entry.row.provider.toLowerCase().includes(needle) ||
87
+ entry.row.folder.toLowerCase().includes(needle) ||
88
+ (entry.row.errorMessage ?? "").toLowerCase().includes(needle)),
89
+ )
90
+ .map(entry => entry.row);
91
+ }, [view.rows, signature, model, search]);
92
+
93
+ const loaded = errors.data?.length ?? 0;
94
+ // The server applies the range; fewer rows than the limit means we have them all.
95
+ const complete = !errors.stale && loaded < limit;
96
+ const nextStep = step + 1 < LOAD_STEPS.length ? LOAD_STEPS[step + 1] : null;
97
+ const footer =
98
+ errors.data !== null && !complete ? (
99
+ <>
100
+ <span>
101
+ Showing the latest {formatInteger(loaded)} failures in {meta.windowLabel}; older ones are not loaded.
102
+ </span>
103
+ {nextStep !== null && (
104
+ <button
105
+ type="button"
106
+ className="btn"
107
+ data-size="sm"
108
+ disabled={errors.refreshing}
109
+ onClick={() => setStep(step + 1)}
49
110
  >
50
- {item.errorMessage || "Unknown error"}
111
+ {errors.refreshing && errors.stale ? "Loading…" : `Load latest ${formatInteger(nextStep)}`}
112
+ </button>
113
+ )}
114
+ </>
115
+ ) : undefined;
116
+
117
+ const maxGroup = view.groups[0]?.count ?? 0;
118
+ const groupColumns = useMemo(() => buildGroupColumns(maxGroup), [maxGroup]);
119
+ const selectedModelInfo = view.models.find(m => m.key === model);
120
+
121
+ return (
122
+ <div className="page">
123
+ <PageHeader
124
+ title="Errors"
125
+ description={`Failed model requests in ${meta.windowLabel}, grouped by error signature. Open a failure for its full payload.`}
126
+ />
127
+
128
+ <QueryView query={errors} skeleton={<Skeleton height={96} style={{ borderRadius: 12 }} />}>
129
+ {() => (
130
+ <div data-stale={errors.stale}>
131
+ <StatGrid min={170}>
132
+ <Stat
133
+ label="Failures"
134
+ value={formatInteger(loaded)}
135
+ hint={complete ? `in ${meta.windowLabel}` : `latest ${formatInteger(loaded)} loaded`}
136
+ />
137
+ <Stat
138
+ label="Signatures"
139
+ title="Distinct error messages after normalizing ids and numbers"
140
+ value={formatInteger(view.groups.length)}
141
+ hint={view.groups[0] ? `top: ${formatInteger(view.groups[0].count)} failures` : undefined}
142
+ />
143
+ <Stat
144
+ label="Affected models"
145
+ value={formatInteger(view.models.length)}
146
+ hint={view.models[0]?.model}
147
+ />
148
+ <Stat
149
+ label="Last failure"
150
+ value={view.newest ? formatRelativeTime(view.newest.timestamp) : "–"}
151
+ hint={view.newest ? formatTimestamp(view.newest.timestamp) : `none in ${meta.windowLabel}`}
152
+ />
153
+ </StatGrid>
51
154
  </div>
52
- ),
53
- },
54
- {
55
- key: "tokens",
56
- header: "Tokens",
57
- numeric: true,
58
- render: (item: MessageStats) => formatInteger(item.usage.totalTokens),
59
- },
60
- {
61
- key: "cost",
62
- header: "API-equivalent estimate",
63
- numeric: true,
64
- render: (item: MessageStats) => formatMessageCost(item, 4),
65
- },
66
- ],
67
- [],
68
- );
155
+ )}
156
+ </QueryView>
69
157
 
70
- const renderMobileCard = (item: MessageStats, onClick?: () => void) => (
71
- <div className="stats-mobile-card stats-border-danger" onClick={onClick}>
72
- <div className="stats-mobile-card-header">
73
- <div>
74
- <div className="stats-font-semibold stats-text-primary">{item.model}</div>
75
- <div className="stats-text-xs stats-text-muted">{item.provider}</div>
76
- </div>
77
- <StatusPill variant="danger">Failed</StatusPill>
78
- </div>
79
- <div className="stats-mobile-card-grid">
80
- <div>
81
- <div className="stats-mobile-card-label">Time</div>
82
- <div className="stats-mobile-card-value">{formatRelativeTime(item.timestamp)}</div>
83
- </div>
84
- <div>
85
- <div className="stats-mobile-card-label">API-equivalent estimate</div>
86
- <div className="stats-mobile-card-value">{formatMessageCost(item, 4)}</div>
87
- </div>
88
- <div>
89
- <div className="stats-mobile-card-label">Tokens</div>
90
- <div className="stats-mobile-card-value">{formatInteger(item.usage.totalTokens)}</div>
91
- </div>
158
+ <div className="grid grid-main-side">
159
+ <Card
160
+ index={1}
161
+ title="Error signatures"
162
+ description="Same message with ids and counters normalized. Select one to filter the failures below."
163
+ flush
164
+ stale={errors.stale}
165
+ >
166
+ <QueryView
167
+ query={errors}
168
+ skeleton={<TableSkeleton rows={8} />}
169
+ isEmpty={rows => rows.length === 0}
170
+ empty={
171
+ <EmptyState
172
+ title={`No failures in ${meta.windowLabel}`}
173
+ hint="Every request succeeded or was aborted."
174
+ />
175
+ }
176
+ >
177
+ {() => (
178
+ <Table
179
+ rows={view.groups}
180
+ rowKey={group => group.signature}
181
+ columns={groupColumns}
182
+ onRowClick={group =>
183
+ setSelectedSignature(signature === group.signature ? null : group.signature)
184
+ }
185
+ selectedKey={signature}
186
+ expanded={group =>
187
+ group.signature === signature ? (
188
+ <SignatureDetail group={group} onRequestClick={onRequestClick} />
189
+ ) : null
190
+ }
191
+ limit={12}
192
+ />
193
+ )}
194
+ </QueryView>
195
+ </Card>
196
+
197
+ <Card
198
+ index={2}
199
+ title="By model"
200
+ description="Failures per model. Select one to filter."
201
+ stale={errors.stale}
202
+ >
203
+ <QueryView
204
+ query={errors}
205
+ skeleton={<Skeleton height={180} />}
206
+ isEmpty={rows => rows.length === 0}
207
+ empty={<EmptyState title="No affected models" />}
208
+ >
209
+ {() => (
210
+ <BarList
211
+ items={view.models.slice(0, 12).map(m => ({
212
+ key: m.key,
213
+ label: (
214
+ <span className="row" style={{ gap: 6, minWidth: 0 }}>
215
+ <span className="mono truncate">{m.model}</span>
216
+ <span className="dim truncate">{m.provider}</span>
217
+ </span>
218
+ ),
219
+ value: m.count,
220
+ color: m.key === model ? "var(--bad)" : "color-mix(in srgb, var(--bad) 45%, transparent)",
221
+ }))}
222
+ onSelect={key => setSelectedModel(model === key ? null : key)}
223
+ />
224
+ )}
225
+ </QueryView>
226
+ </Card>
92
227
  </div>
93
- {item.errorMessage && <div className="stats-mobile-card-error mt-2 stats-font-mono">{item.errorMessage}</div>}
228
+
229
+ <Card
230
+ index={3}
231
+ title="Failures"
232
+ description={
233
+ errors.data === null
234
+ ? "Loading failures…"
235
+ : `${formatInteger(filtered.length)} of ${formatInteger(loaded)} failures, newest first`
236
+ }
237
+ actions={
238
+ <>
239
+ {signature !== null && (
240
+ <button
241
+ type="button"
242
+ className="btn errors-filter"
243
+ data-size="sm"
244
+ onClick={() => setSelectedSignature(null)}
245
+ title={signature}
246
+ >
247
+ <span className="mono truncate">{signature}</span>
248
+ <X size={12} />
249
+ </button>
250
+ )}
251
+ {selectedModelInfo && (
252
+ <button
253
+ type="button"
254
+ className="btn errors-filter"
255
+ data-size="sm"
256
+ onClick={() => setSelectedModel(null)}
257
+ >
258
+ <span className="mono truncate">{selectedModelInfo.model}</span>
259
+ <X size={12} />
260
+ </button>
261
+ )}
262
+ <SearchInput value={search} onChange={setSearch} placeholder="Message, model or project" />
263
+ </>
264
+ }
265
+ flush
266
+ stale={errors.stale}
267
+ footer={footer}
268
+ >
269
+ <QueryView
270
+ query={errors}
271
+ skeleton={<TableSkeleton rows={10} />}
272
+ isEmpty={rows => rows.length === 0}
273
+ empty={<EmptyState title={`No failures in ${meta.windowLabel}`} />}
274
+ >
275
+ {() => (
276
+ <Table
277
+ rows={filtered}
278
+ rowKey={row => row.id ?? `${row.sessionFile}:${row.entryId}`}
279
+ onRowClick={row => row.id !== undefined && onRequestClick(row.id)}
280
+ columns={FAILURE_COLUMNS}
281
+ initialSort={{ key: "time", dir: "desc" }}
282
+ limit={50}
283
+ dense
284
+ empty={<EmptyState title="No failures match" hint="Clear the search or the selected filters." />}
285
+ />
286
+ )}
287
+ </QueryView>
288
+ </Card>
94
289
  </div>
95
290
  );
291
+ }
96
292
 
293
+ function buildGroupColumns(maxCount: number): Column<ErrorGroupView>[] {
294
+ return [
295
+ {
296
+ key: "signature",
297
+ header: "Signature",
298
+ wrap: true,
299
+ sort: group => group.signature,
300
+ render: group => (
301
+ <span className="errors-signature" title={group.latest.errorMessage ?? undefined}>
302
+ {group.signature}
303
+ </span>
304
+ ),
305
+ },
306
+ {
307
+ key: "models",
308
+ header: "Models",
309
+ sort: group => group.models.length,
310
+ render: group => (
311
+ <LabelCell
312
+ primary={<span className="mono">{group.models[0]?.model}</span>}
313
+ secondary={group.models.length > 1 ? `+${group.models.length - 1} more` : group.models[0]?.provider}
314
+ />
315
+ ),
316
+ },
317
+ {
318
+ key: "last",
319
+ header: "Last seen",
320
+ sort: group => group.lastSeen,
321
+ render: group => (
322
+ <span className="muted" title={formatTimestamp(group.lastSeen)}>
323
+ {formatRelativeTime(group.lastSeen)}
324
+ </span>
325
+ ),
326
+ },
327
+ {
328
+ key: "count",
329
+ header: "Failures",
330
+ align: "right",
331
+ width: 120,
332
+ sort: group => group.count,
333
+ render: group => (
334
+ <MeterCell value={group.count} max={maxCount} display={formatInteger(group.count)} color="var(--bad)" />
335
+ ),
336
+ },
337
+ ];
338
+ }
339
+
340
+ function SignatureDetail({ group, onRequestClick }: { group: ErrorGroupView; onRequestClick: (id: number) => void }) {
341
+ const latestId = group.latest.id;
97
342
  return (
98
- <div className="stats-route-container">
99
- <Panel title="Recent Errors" subtitle="Up to 50 most recent failed requests in the stats database">
100
- <AsyncBoundary
101
- loading={loading}
102
- error={error}
103
- data={recentErrors}
104
- emptyText="No recent failures in the local stats database"
105
- >
106
- <DataTable
107
- columns={columns}
108
- data={recentErrors || []}
109
- keyExtractor={item => item.id || `${item.sessionFile}-${item.entryId}`}
110
- onRowClick={item => item.id && onRequestClick(item.id)}
111
- renderMobileCard={renderMobileCard}
112
- emptyText="No recent failures in the local stats database"
113
- />
114
- </AsyncBoundary>
115
- </Panel>
343
+ <div className="errors-detail">
344
+ <pre className="code-block errors-detail-message">{group.latest.errorMessage ?? "Unknown error"}</pre>
345
+ <div className="errors-detail-meta">
346
+ <span className="muted">
347
+ {formatInteger(group.count)} failures · first {formatTimestamp(group.firstSeen)} · last{" "}
348
+ {formatTimestamp(group.lastSeen)}
349
+ </span>
350
+ {latestId !== undefined && (
351
+ <button type="button" className="btn" data-size="sm" onClick={() => onRequestClick(latestId)}>
352
+ Open latest <ArrowUpRight size={13} />
353
+ </button>
354
+ )}
355
+ </div>
356
+ <div className="errors-detail-models">
357
+ {group.models.map(m => (
358
+ <span key={modelKey(m.model, m.provider)} className="badge" data-mono="true">
359
+ {m.model} <span className="dim">{m.provider}</span>{" "}
360
+ <span className="num">{formatCompact(m.count)}</span>
361
+ </span>
362
+ ))}
363
+ </div>
116
364
  </div>
117
365
  );
118
366
  }
367
+
368
+ const FAILURE_COLUMNS: readonly Column<MessageStats>[] = [
369
+ {
370
+ key: "time",
371
+ header: "When",
372
+ width: 120,
373
+ sort: row => row.timestamp,
374
+ render: row => (
375
+ <span className="muted" title={formatTimestamp(row.timestamp)}>
376
+ {formatRelativeTime(row.timestamp)}
377
+ </span>
378
+ ),
379
+ },
380
+ {
381
+ key: "model",
382
+ header: "Model",
383
+ sort: row => row.model,
384
+ render: row => <LabelCell primary={<span className="mono">{row.model}</span>} secondary={row.provider} />,
385
+ },
386
+ {
387
+ key: "error",
388
+ header: "Error",
389
+ sort: row => row.errorMessage ?? "",
390
+ render: row => (
391
+ <span className="errors-message" title={row.errorMessage ?? undefined}>
392
+ {row.errorMessage ?? "Unknown error"}
393
+ </span>
394
+ ),
395
+ },
396
+ {
397
+ key: "project",
398
+ header: "Project",
399
+ sort: row => row.folder,
400
+ render: row => (
401
+ <span className="mono muted" title={row.folder}>
402
+ {formatFolder(row.folder)}
403
+ </span>
404
+ ),
405
+ },
406
+ {
407
+ key: "tokens",
408
+ header: "Tokens",
409
+ align: "right",
410
+ sort: row => row.usage.totalTokens,
411
+ render: row => <span className="num">{formatInteger(row.usage.totalTokens)}</span>,
412
+ },
413
+ {
414
+ key: "cost",
415
+ header: "Cost",
416
+ title: "API-equivalent estimate",
417
+ align: "right",
418
+ sort: row => row.usage.cost.total,
419
+ render: row => <span className="num">{formatMessageCost(row, 4)}</span>,
420
+ },
421
+ ];