@oh-my-pi/omp-stats 18.0.11 → 18.1.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.
- package/CHANGELOG.md +11 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +101 -101
- package/dist/client/styles.css +265 -0
- package/dist/types/client/api.d.ts +7 -1
- package/dist/types/client/app/routes.d.ts +1 -1
- package/dist/types/client/data/useHashRoute.d.ts +2 -0
- package/dist/types/client/routes/TracesRoute.d.ts +11 -0
- package/dist/types/client/routes/index.d.ts +1 -0
- package/dist/types/client/traces/AggregatesPanel.d.ts +9 -0
- package/dist/types/client/traces/Minimap.d.ts +14 -0
- package/dist/types/client/traces/SpanDrawer.d.ts +14 -0
- package/dist/types/client/traces/SummaryStrip.d.ts +9 -0
- package/dist/types/client/traces/TimelineCanvas.d.ts +25 -0
- package/dist/types/client/traces/TraceView.d.ts +11 -0
- package/dist/types/client/traces/TranscriptList.d.ts +24 -0
- package/dist/types/client/traces/time-scale.d.ts +40 -0
- package/dist/types/client/traces/trace-colors.d.ts +31 -0
- package/dist/types/db.d.ts +24 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/parser.d.ts +8 -0
- package/dist/types/server.d.ts +3 -2
- package/dist/types/shared-types.d.ts +130 -0
- package/dist/types/trace.d.ts +33 -0
- package/dist/types/types.d.ts +35 -1
- package/package.json +8 -8
- package/src/client/App.tsx +11 -1
- package/src/client/api.ts +23 -0
- package/src/client/app/routes.ts +7 -0
- package/src/client/data/useHashRoute.ts +28 -10
- package/src/client/routes/ProvidersRoute.tsx +1 -0
- package/src/client/routes/TracesRoute.tsx +182 -0
- package/src/client/routes/index.ts +1 -0
- package/src/client/styles.css +316 -0
- package/src/client/traces/AggregatesPanel.tsx +88 -0
- package/src/client/traces/Minimap.tsx +177 -0
- package/src/client/traces/SpanDrawer.tsx +309 -0
- package/src/client/traces/SummaryStrip.tsx +37 -0
- package/src/client/traces/TimelineCanvas.tsx +768 -0
- package/src/client/traces/TraceView.tsx +344 -0
- package/src/client/traces/TranscriptList.tsx +166 -0
- package/src/client/traces/time-scale.ts +250 -0
- package/src/client/traces/trace-colors.ts +77 -0
- package/src/db.ts +77 -0
- package/src/index.ts +1 -6
- package/src/parser.ts +55 -2
- package/src/server.ts +69 -17
- package/src/shared-types.ts +138 -0
- package/src/trace.ts +1002 -0
- package/src/types.ts +45 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-session trace viewer: summary strip, axis-mode toolbar, minimap,
|
|
3
|
+
* flamegraph canvas, transcript list, tool aggregates, and the span drawer.
|
|
4
|
+
* Owns the shared viewport/selection state so all pieces stay in sync.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ArrowLeft, RefreshCw } from "lucide-react";
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
import { getSessionTrace } from "../api";
|
|
10
|
+
import { useResource } from "../data/useResource";
|
|
11
|
+
import type { TraceSpan, TraceTrack } from "../types";
|
|
12
|
+
import { AsyncBoundary } from "../ui/AsyncBoundary";
|
|
13
|
+
import { SegmentedControl } from "../ui/SegmentedControl";
|
|
14
|
+
import { AggregatesPanel } from "./AggregatesPanel";
|
|
15
|
+
import { Minimap } from "./Minimap";
|
|
16
|
+
import { SpanDrawer } from "./SpanDrawer";
|
|
17
|
+
import { SummaryStrip } from "./SummaryStrip";
|
|
18
|
+
import { TimelineCanvas, type TimelineViewport } from "./TimelineCanvas";
|
|
19
|
+
import { TranscriptList } from "./TranscriptList";
|
|
20
|
+
import { type AxisMode, buildScale } from "./time-scale";
|
|
21
|
+
|
|
22
|
+
export interface TraceViewProps {
|
|
23
|
+
file: string;
|
|
24
|
+
active: boolean;
|
|
25
|
+
onBack: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MODE_OPTIONS: Array<{ value: AxisMode; label: string; title?: string }> = [
|
|
29
|
+
{ value: "time", label: "Duration", title: "Real wall-clock time" },
|
|
30
|
+
{ value: "turns", label: "Turns", title: "Equal width per user turn" },
|
|
31
|
+
{ value: "calls", label: "Calls", title: "Equal width per model/tool call boundary" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function TraceView({ file, active, onBack }: TraceViewProps) {
|
|
35
|
+
const {
|
|
36
|
+
data: trace,
|
|
37
|
+
error,
|
|
38
|
+
loading,
|
|
39
|
+
refetch,
|
|
40
|
+
refreshing,
|
|
41
|
+
} = useResource(["trace", file], signal => getSessionTrace(file, signal), {
|
|
42
|
+
pollMs: 15000,
|
|
43
|
+
enabled: active,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const [mode, setMode] = useState<AxisMode>("time");
|
|
47
|
+
const [compressIdle, setCompressIdle] = useState(true);
|
|
48
|
+
const [selection, setSelection] = useState<string | null>(null);
|
|
49
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
50
|
+
const [search, setSearch] = useState("");
|
|
51
|
+
const [matchIndex, setMatchIndex] = useState(0);
|
|
52
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
|
53
|
+
const [viewport, setViewport] = useState<TimelineViewport | null>(null);
|
|
54
|
+
|
|
55
|
+
const tracks = useMemo(() => trace?.tracks ?? [], [trace]);
|
|
56
|
+
const scale = useMemo(() => buildScale(tracks, mode, compressIdle), [tracks, mode, compressIdle]);
|
|
57
|
+
const scaleRef = useRef(scale);
|
|
58
|
+
|
|
59
|
+
// File change: reset viewport/selection and default-collapse deep tracks.
|
|
60
|
+
const lastFileRef = useRef<string | null>(null);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!trace) return;
|
|
63
|
+
if (lastFileRef.current === trace.file) return;
|
|
64
|
+
lastFileRef.current = trace.file;
|
|
65
|
+
setSelection(null);
|
|
66
|
+
setSearch("");
|
|
67
|
+
setViewport(null);
|
|
68
|
+
const deep = new Set<string>();
|
|
69
|
+
for (const track of trace.tracks) {
|
|
70
|
+
const depth = track.id === "main" ? 0 : track.id.split("/").length;
|
|
71
|
+
if (depth >= 1) deep.add(track.id);
|
|
72
|
+
}
|
|
73
|
+
setCollapsed(deep);
|
|
74
|
+
}, [trace]);
|
|
75
|
+
|
|
76
|
+
// Axis-mode / compression switches remap the viewport through wall time so
|
|
77
|
+
// the visible window stays put; selection survives untouched.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const prev = scaleRef.current;
|
|
80
|
+
scaleRef.current = scale;
|
|
81
|
+
setViewport(current => {
|
|
82
|
+
if (!current) return current;
|
|
83
|
+
const t0 = prev.toT(current.u0);
|
|
84
|
+
const t1 = prev.toT(current.u1);
|
|
85
|
+
const u0 = scale.toU(t0);
|
|
86
|
+
const u1 = scale.toU(t1);
|
|
87
|
+
return u1 - u0 > 0 ? { u0, u1 } : null;
|
|
88
|
+
});
|
|
89
|
+
}, [scale]);
|
|
90
|
+
|
|
91
|
+
const effectiveViewport: TimelineViewport = viewport ?? { u0: scale.domain[0], u1: scale.domain[1] };
|
|
92
|
+
|
|
93
|
+
const allSpans = useMemo(() => {
|
|
94
|
+
const spans: Array<{ span: TraceSpan; track: TraceTrack }> = [];
|
|
95
|
+
for (const track of tracks) {
|
|
96
|
+
for (const span of track.spans) spans.push({ span, track });
|
|
97
|
+
}
|
|
98
|
+
spans.sort((a, b) => a.span.start - b.span.start);
|
|
99
|
+
return spans;
|
|
100
|
+
}, [tracks]);
|
|
101
|
+
|
|
102
|
+
const matches = useMemo(() => {
|
|
103
|
+
const needle = search.trim().toLowerCase();
|
|
104
|
+
if (!needle) return [];
|
|
105
|
+
return allSpans.filter(({ span }) => `${span.label} ${span.detail ?? ""}`.toLowerCase().includes(needle));
|
|
106
|
+
}, [allSpans, search]);
|
|
107
|
+
|
|
108
|
+
const centerOnSpan = useCallback(
|
|
109
|
+
(span: TraceSpan) => {
|
|
110
|
+
const u = scale.toU(span.start);
|
|
111
|
+
const uEnd = scale.toU(span.end);
|
|
112
|
+
setViewport(current => {
|
|
113
|
+
const vp = current ?? { u0: scale.domain[0], u1: scale.domain[1] };
|
|
114
|
+
if (u >= vp.u0 && uEnd <= vp.u1) return current;
|
|
115
|
+
const window = vp.u1 - vp.u0;
|
|
116
|
+
const mid = (u + uEnd) / 2;
|
|
117
|
+
const [d0, d1] = scale.domain;
|
|
118
|
+
let u0 = mid - window / 2;
|
|
119
|
+
if (u0 < d0) u0 = d0;
|
|
120
|
+
if (u0 + window > d1) u0 = Math.max(d0, d1 - window);
|
|
121
|
+
return { u0, u1: u0 + window };
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
[scale],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const selectAndReveal = useCallback(
|
|
128
|
+
(spanId: string) => {
|
|
129
|
+
setSelection(spanId);
|
|
130
|
+
const found = allSpans.find(({ span }) => span.id === spanId);
|
|
131
|
+
if (found) centerOnSpan(found.span);
|
|
132
|
+
},
|
|
133
|
+
[allSpans, centerOnSpan],
|
|
134
|
+
);
|
|
135
|
+
// Canvas click / transcript click: select AND open the detail drawer.
|
|
136
|
+
// Search cycling only selects, so the timeline stays visible.
|
|
137
|
+
const selectAndOpen = useCallback((spanId: string | null) => {
|
|
138
|
+
setSelection(spanId);
|
|
139
|
+
setDrawerOpen(spanId !== null);
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
const cycleMatch = useCallback(
|
|
143
|
+
(direction: 1 | -1) => {
|
|
144
|
+
if (matches.length === 0) return;
|
|
145
|
+
const next = (((matchIndex + direction) % matches.length) + matches.length) % matches.length;
|
|
146
|
+
setMatchIndex(next);
|
|
147
|
+
selectAndReveal(matches[next].span.id);
|
|
148
|
+
},
|
|
149
|
+
[matches, matchIndex, selectAndReveal],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const toggleCollapse = useCallback((trackId: string) => {
|
|
153
|
+
setCollapsed(current => {
|
|
154
|
+
const next = new Set(current);
|
|
155
|
+
if (next.has(trackId)) next.delete(trackId);
|
|
156
|
+
else next.add(trackId);
|
|
157
|
+
return next;
|
|
158
|
+
});
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const selected = useMemo(() => {
|
|
162
|
+
if (!selection) return null;
|
|
163
|
+
return allSpans.find(({ span }) => span.id === selection) ?? null;
|
|
164
|
+
}, [selection, allSpans]);
|
|
165
|
+
|
|
166
|
+
const openChildTrack = useCallback(
|
|
167
|
+
(childTrackId: string) => {
|
|
168
|
+
const child = tracks.find(track => track.id === childTrackId);
|
|
169
|
+
const first = child?.spans[0];
|
|
170
|
+
// Expand ancestors so the track is visible.
|
|
171
|
+
setCollapsed(current => {
|
|
172
|
+
const next = new Set(current);
|
|
173
|
+
let cursor = child;
|
|
174
|
+
while (cursor?.parentId) {
|
|
175
|
+
next.delete(cursor.parentId);
|
|
176
|
+
cursor = tracks.find(track => track.id === cursor?.parentId);
|
|
177
|
+
}
|
|
178
|
+
return next;
|
|
179
|
+
});
|
|
180
|
+
if (first) selectAndReveal(first.id);
|
|
181
|
+
},
|
|
182
|
+
[tracks, selectAndReveal],
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const collapseAll = useCallback(
|
|
186
|
+
(collapse: boolean) => {
|
|
187
|
+
if (!collapse) {
|
|
188
|
+
setCollapsed(new Set());
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const all = new Set<string>();
|
|
192
|
+
for (const track of tracks) {
|
|
193
|
+
if (track.id !== "main") all.add(track.id);
|
|
194
|
+
}
|
|
195
|
+
setCollapsed(all);
|
|
196
|
+
},
|
|
197
|
+
[tracks],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const traceStart = trace?.startedAt ?? 0;
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div className="stats-trace-view">
|
|
204
|
+
<div className="stats-trace-header">
|
|
205
|
+
<button type="button" onClick={onBack} className="stats-trace-back">
|
|
206
|
+
<ArrowLeft size={13} aria-hidden="true" />
|
|
207
|
+
Sessions
|
|
208
|
+
</button>
|
|
209
|
+
<h2 className="stats-trace-title" style={{ maxWidth: 480, margin: 0 }}>
|
|
210
|
+
{trace?.title ?? file.split("/").pop()}
|
|
211
|
+
</h2>
|
|
212
|
+
{trace?.cwd && <span className="stats-trace-cwd">{trace.cwd}</span>}
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
onClick={() => void refetch()}
|
|
216
|
+
className="stats-trace-icon-btn"
|
|
217
|
+
aria-label="Refresh trace"
|
|
218
|
+
title="Refresh trace"
|
|
219
|
+
style={{ marginLeft: "auto" }}
|
|
220
|
+
>
|
|
221
|
+
<RefreshCw size={14} className={refreshing ? "stats-spin" : undefined} />
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<AsyncBoundary loading={loading} error={error} data={trace}>
|
|
226
|
+
{trace && (
|
|
227
|
+
<>
|
|
228
|
+
<SummaryStrip summary={trace.summary} />
|
|
229
|
+
|
|
230
|
+
<div className="stats-trace-toolbar">
|
|
231
|
+
<SegmentedControl options={MODE_OPTIONS} value={mode} onChange={setMode} />
|
|
232
|
+
{mode === "time" && (
|
|
233
|
+
<label className="stats-trace-check">
|
|
234
|
+
<input
|
|
235
|
+
type="checkbox"
|
|
236
|
+
checked={compressIdle}
|
|
237
|
+
onChange={event => setCompressIdle(event.target.checked)}
|
|
238
|
+
/>
|
|
239
|
+
Compress idle
|
|
240
|
+
</label>
|
|
241
|
+
)}
|
|
242
|
+
<div style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
|
243
|
+
<input
|
|
244
|
+
type="search"
|
|
245
|
+
value={search}
|
|
246
|
+
onChange={event => {
|
|
247
|
+
setSearch(event.target.value);
|
|
248
|
+
setMatchIndex(0);
|
|
249
|
+
}}
|
|
250
|
+
placeholder="Search spans…"
|
|
251
|
+
aria-label="Search spans"
|
|
252
|
+
spellCheck={false}
|
|
253
|
+
className="stats-trace-input"
|
|
254
|
+
style={{ width: 190 }}
|
|
255
|
+
/>
|
|
256
|
+
{search.trim() && (
|
|
257
|
+
<>
|
|
258
|
+
<span
|
|
259
|
+
className="stats-text-muted"
|
|
260
|
+
style={{ fontSize: 11, fontVariantNumeric: "tabular-nums" }}
|
|
261
|
+
>
|
|
262
|
+
{matches.length === 0
|
|
263
|
+
? "0"
|
|
264
|
+
: `${(matchIndex % Math.max(matches.length, 1)) + 1}/${matches.length}`}
|
|
265
|
+
</span>
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
onClick={() => cycleMatch(-1)}
|
|
269
|
+
className="stats-trace-icon-btn"
|
|
270
|
+
aria-label="Previous match"
|
|
271
|
+
disabled={matches.length === 0}
|
|
272
|
+
>
|
|
273
|
+
‹
|
|
274
|
+
</button>
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
onClick={() => cycleMatch(1)}
|
|
278
|
+
className="stats-trace-icon-btn"
|
|
279
|
+
aria-label="Next match"
|
|
280
|
+
disabled={matches.length === 0}
|
|
281
|
+
>
|
|
282
|
+
›
|
|
283
|
+
</button>
|
|
284
|
+
</>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
<div style={{ marginLeft: "auto", display: "inline-flex", gap: 6 }}>
|
|
288
|
+
<button type="button" onClick={() => collapseAll(false)} className="stats-trace-back">
|
|
289
|
+
Expand all
|
|
290
|
+
</button>
|
|
291
|
+
<button type="button" onClick={() => collapseAll(true)} className="stats-trace-back">
|
|
292
|
+
Collapse all
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div className="stats-trace-timeline-card">
|
|
298
|
+
<Minimap
|
|
299
|
+
tracks={tracks}
|
|
300
|
+
scale={scale}
|
|
301
|
+
viewport={effectiveViewport}
|
|
302
|
+
onViewportChange={setViewport}
|
|
303
|
+
/>
|
|
304
|
+
<TimelineCanvas
|
|
305
|
+
tracks={tracks}
|
|
306
|
+
scale={scale}
|
|
307
|
+
viewport={effectiveViewport}
|
|
308
|
+
onViewportChange={setViewport}
|
|
309
|
+
selection={selection}
|
|
310
|
+
onSelect={selectAndOpen}
|
|
311
|
+
search={search}
|
|
312
|
+
collapsed={collapsed}
|
|
313
|
+
onToggleCollapse={toggleCollapse}
|
|
314
|
+
traceStart={traceStart}
|
|
315
|
+
/>
|
|
316
|
+
<div className="stats-trace-toolbar-hint" style={{ marginLeft: 0 }}>
|
|
317
|
+
W/S zoom · A/D pan · drag pan · wheel zoom · 0 fit · F focus selection · dbl-click focus · Esc
|
|
318
|
+
deselect
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
<TranscriptList
|
|
322
|
+
tracks={tracks}
|
|
323
|
+
selection={selection}
|
|
324
|
+
onSelect={spanId => {
|
|
325
|
+
selectAndReveal(spanId);
|
|
326
|
+
setDrawerOpen(true);
|
|
327
|
+
}}
|
|
328
|
+
search={search}
|
|
329
|
+
traceStart={traceStart}
|
|
330
|
+
/>
|
|
331
|
+
<AggregatesPanel toolStats={trace.summary.toolStats} />
|
|
332
|
+
</>
|
|
333
|
+
)}
|
|
334
|
+
</AsyncBoundary>
|
|
335
|
+
|
|
336
|
+
<SpanDrawer
|
|
337
|
+
span={drawerOpen ? (selected?.span ?? null) : null}
|
|
338
|
+
track={selected?.track ?? null}
|
|
339
|
+
onClose={() => setDrawerOpen(false)}
|
|
340
|
+
onOpenChildTrack={openChildTrack}
|
|
341
|
+
/>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windowed transcript list under the timeline: all spans + markers flattened
|
|
3
|
+
* chronologically across tracks, bidirectionally synced with the canvas
|
|
4
|
+
* selection. Hand-rolled fixed-row virtualization (32px rows, spacer divs).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
8
|
+
import { formatDurationMs } from "../data/formatters";
|
|
9
|
+
import type { TraceMarker, TraceSpan, TraceTrack } from "../types";
|
|
10
|
+
import { useSystemTheme } from "../useSystemTheme";
|
|
11
|
+
import { formatOffset } from "./time-scale";
|
|
12
|
+
import { TRACE_THEMES } from "./trace-colors";
|
|
13
|
+
|
|
14
|
+
export interface TranscriptRow {
|
|
15
|
+
key: string;
|
|
16
|
+
time: number;
|
|
17
|
+
span?: TraceSpan;
|
|
18
|
+
marker?: TraceMarker;
|
|
19
|
+
track: TraceTrack;
|
|
20
|
+
depth: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TranscriptListProps {
|
|
24
|
+
tracks: TraceTrack[];
|
|
25
|
+
selection: string | null;
|
|
26
|
+
onSelect: (spanId: string) => void;
|
|
27
|
+
search: string;
|
|
28
|
+
traceStart: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ROW_H = 28;
|
|
32
|
+
const OVERSCAN = 20;
|
|
33
|
+
const LIST_H = 340;
|
|
34
|
+
|
|
35
|
+
/** Flatten all spans and markers across tracks into chronological rows. */
|
|
36
|
+
export function buildTranscriptRows(tracks: TraceTrack[]): TranscriptRow[] {
|
|
37
|
+
const rows: TranscriptRow[] = [];
|
|
38
|
+
for (const track of tracks) {
|
|
39
|
+
const depth = track.id === "main" ? 0 : track.id.split("/").length;
|
|
40
|
+
for (const span of track.spans) {
|
|
41
|
+
rows.push({ key: span.id, time: span.start, span, track, depth });
|
|
42
|
+
}
|
|
43
|
+
for (let i = 0; i < track.markers.length; i++) {
|
|
44
|
+
const marker = track.markers[i];
|
|
45
|
+
rows.push({ key: `${track.id}:marker:${i}`, time: marker.time, marker, track, depth });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
rows.sort((a, b) => a.time - b.time);
|
|
49
|
+
return rows;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function TranscriptList({ tracks, selection, onSelect, search, traceStart }: TranscriptListProps) {
|
|
53
|
+
const theme = useSystemTheme();
|
|
54
|
+
const colors = TRACE_THEMES[theme];
|
|
55
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
56
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
57
|
+
|
|
58
|
+
const allRows = useMemo(() => buildTranscriptRows(tracks), [tracks]);
|
|
59
|
+
const rows = useMemo(() => {
|
|
60
|
+
const needle = search.trim().toLowerCase();
|
|
61
|
+
if (!needle) return allRows;
|
|
62
|
+
return allRows.filter(row => {
|
|
63
|
+
const text = row.span ? `${row.span.label} ${row.span.detail ?? ""}` : (row.marker?.label ?? "");
|
|
64
|
+
return text.toLowerCase().includes(needle);
|
|
65
|
+
});
|
|
66
|
+
}, [allRows, search]);
|
|
67
|
+
|
|
68
|
+
// Scroll the selected row into view when selection changes externally.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!selection) return;
|
|
71
|
+
const container = containerRef.current;
|
|
72
|
+
if (!container) return;
|
|
73
|
+
const index = rows.findIndex(row => row.span?.id === selection);
|
|
74
|
+
if (index === -1) return;
|
|
75
|
+
const rowTop = index * ROW_H;
|
|
76
|
+
if (rowTop < container.scrollTop || rowTop + ROW_H > container.scrollTop + container.clientHeight) {
|
|
77
|
+
container.scrollTop = Math.max(0, rowTop - container.clientHeight / 2);
|
|
78
|
+
}
|
|
79
|
+
}, [selection, rows]);
|
|
80
|
+
|
|
81
|
+
const firstVisible = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN);
|
|
82
|
+
const lastVisible = Math.min(rows.length, Math.ceil((scrollTop + LIST_H) / ROW_H) + OVERSCAN);
|
|
83
|
+
const visible = rows.slice(firstVisible, lastVisible);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
ref={containerRef}
|
|
88
|
+
onScroll={event => setScrollTop(event.currentTarget.scrollTop)}
|
|
89
|
+
className="stats-trace-list"
|
|
90
|
+
>
|
|
91
|
+
<div style={{ height: rows.length * ROW_H, position: "relative" }}>
|
|
92
|
+
<div style={{ position: "absolute", top: firstVisible * ROW_H, left: 0, right: 0 }}>
|
|
93
|
+
{visible.map(row => {
|
|
94
|
+
const span = row.span;
|
|
95
|
+
const isSelected = span !== undefined && span.id === selection;
|
|
96
|
+
const color = row.span ? colors.category[row.span.kind] : colors.marker;
|
|
97
|
+
const label = row.span?.label ?? row.marker?.label ?? "";
|
|
98
|
+
const detail = row.span?.detail;
|
|
99
|
+
const duration = row.span ? formatDurationMs(row.span.end - row.span.start) : "";
|
|
100
|
+
const chipText = row.span?.kind ?? row.marker?.kind ?? "";
|
|
101
|
+
const rowContent = (
|
|
102
|
+
<>
|
|
103
|
+
<span
|
|
104
|
+
className="stats-trace-kind"
|
|
105
|
+
style={{
|
|
106
|
+
color,
|
|
107
|
+
background: `${color}1c`,
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{chipText}
|
|
111
|
+
</span>
|
|
112
|
+
{row.depth > 0 && (
|
|
113
|
+
<span
|
|
114
|
+
className="stats-text-muted truncate"
|
|
115
|
+
style={{ flexShrink: 0, maxWidth: 90, fontSize: 9 }}
|
|
116
|
+
title={row.track.label}
|
|
117
|
+
>
|
|
118
|
+
{row.track.id}
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
<span
|
|
122
|
+
className="stats-text-primary truncate"
|
|
123
|
+
style={{ fontSize: 11, minWidth: 0, flexShrink: 1 }}
|
|
124
|
+
>
|
|
125
|
+
{label}
|
|
126
|
+
</span>
|
|
127
|
+
{detail && (
|
|
128
|
+
<span className="stats-text-muted truncate" style={{ fontSize: 10, minWidth: 0, flex: 1 }}>
|
|
129
|
+
{detail}
|
|
130
|
+
</span>
|
|
131
|
+
)}
|
|
132
|
+
<span className="stats-trace-row-meta">
|
|
133
|
+
{row.span?.isError && <span style={{ color: colors.error }}>error</span>}
|
|
134
|
+
<span>{duration}</span>
|
|
135
|
+
<span>{formatOffset(row.time - traceStart)}</span>
|
|
136
|
+
</span>
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
const indent = { paddingLeft: 12 + row.depth * 12 };
|
|
140
|
+
return span ? (
|
|
141
|
+
<button
|
|
142
|
+
key={row.key}
|
|
143
|
+
type="button"
|
|
144
|
+
onClick={() => onSelect(span.id)}
|
|
145
|
+
className="stats-trace-row"
|
|
146
|
+
data-selected={isSelected ? "true" : "false"}
|
|
147
|
+
style={indent}
|
|
148
|
+
>
|
|
149
|
+
{rowContent}
|
|
150
|
+
</button>
|
|
151
|
+
) : (
|
|
152
|
+
<div key={row.key} className="stats-trace-row stats-trace-row-static" style={indent}>
|
|
153
|
+
{rowContent}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
{rows.length === 0 && (
|
|
160
|
+
<div className="stats-text-muted" style={{ padding: 16, fontSize: 12 }}>
|
|
161
|
+
No matching events
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|