@oh-my-pi/omp-stats 18.7.0 → 18.8.1
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 +7 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +96 -96
- package/dist/types/aggregator.d.ts +3 -0
- package/dist/types/client/api.d.ts +5 -1
- package/dist/types/client/data/live.d.ts +16 -11
- package/dist/types/client/data/query-store.d.ts +39 -0
- package/dist/types/client/data/query.d.ts +10 -5
- package/dist/types/client/traces/Minimap.d.ts +2 -0
- package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
- package/dist/types/client/traces/TranscriptList.d.ts +3 -1
- package/dist/types/db.d.ts +14 -2
- package/dist/types/embedded-client.d.ts +5 -0
- package/dist/types/live.d.ts +5 -3
- package/dist/types/rollup.d.ts +9 -1
- package/dist/types/trace.d.ts +18 -6
- package/package.json +4 -4
- package/src/aggregator.ts +26 -9
- package/src/client/api.ts +40 -5
- package/src/client/app/LiveChip.tsx +2 -2
- package/src/client/app/Shell.tsx +2 -2
- package/src/client/data/live.tsx +85 -45
- package/src/client/data/query-store.ts +108 -0
- package/src/client/data/query.ts +47 -56
- package/src/client/routes/CostsRoute.tsx +4 -2
- package/src/client/routes/ErrorsRoute.tsx +3 -1
- package/src/client/routes/FrustrationRoute.tsx +1 -1
- package/src/client/routes/GainRoute.tsx +3 -1
- package/src/client/routes/ModelsRoute.tsx +9 -2
- package/src/client/routes/OverviewRoute.tsx +2 -2
- package/src/client/routes/ProjectsRoute.tsx +1 -1
- package/src/client/routes/ProvidersRoute.tsx +5 -3
- package/src/client/routes/RequestsRoute.tsx +1 -1
- package/src/client/routes/ToolsRoute.tsx +1 -1
- package/src/client/routes/TracesRoute.tsx +5 -4
- package/src/client/traces/Minimap.tsx +63 -26
- package/src/client/traces/TimelineCanvas.tsx +224 -105
- package/src/client/traces/TraceView.tsx +29 -10
- package/src/client/traces/TranscriptList.tsx +17 -9
- package/src/client/traces/traces.css +26 -0
- package/src/client/ui/RequestDrawer.tsx +3 -1
- package/src/db.ts +106 -64
- package/src/embedded-client.ts +9 -0
- package/src/live.ts +36 -11
- package/src/parser.ts +1 -8
- package/src/rollup.ts +41 -20
- package/src/server.ts +38 -24
- package/src/trace.ts +54 -40
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The trace flamegraph:
|
|
3
|
-
* labels, collapse chevrons) and a
|
|
4
|
-
*
|
|
5
|
-
* fit/focus keys, hover tooltips,
|
|
2
|
+
* The trace flamegraph: a DPR-scaled scene canvas with DOM hover/selection
|
|
3
|
+
* boxes on top, a DOM gutter (track/lane labels, collapse chevrons) and a
|
|
4
|
+
* DOM tooltip. Implements the Chrome DevTools interaction contract:
|
|
5
|
+
* cursor-anchored wheel zoom, drag pan, WASD, fit/focus keys, hover tooltips,
|
|
6
|
+
* click-to-select, double-click-to-zoom.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
9
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
10
11
|
import { createPortal } from "react-dom";
|
|
11
12
|
import { formatDurationMs, formatInteger } from "../data/formatters";
|
|
12
13
|
import type { TraceMarker, TraceSpan, TraceSpanKind, TraceTrack } from "../types";
|
|
@@ -23,9 +24,11 @@ export interface TimelineCanvasProps {
|
|
|
23
24
|
scale: TraceScale;
|
|
24
25
|
viewport: TimelineViewport;
|
|
25
26
|
onViewportChange: (viewport: TimelineViewport) => void;
|
|
26
|
-
|
|
27
|
+
/** The selected span with its track (resolved by the trace view). */
|
|
28
|
+
selected: { span: TraceSpan; track: TraceTrack } | null;
|
|
27
29
|
onSelect: (spanId: string | null) => void;
|
|
28
|
-
search
|
|
30
|
+
/** Spans matching the search; `null` when not searching (everything at full opacity). */
|
|
31
|
+
matchIds: ReadonlySet<string> | null;
|
|
29
32
|
collapsed: ReadonlySet<string>;
|
|
30
33
|
onToggleCollapse: (trackId: string) => void;
|
|
31
34
|
traceStart: number;
|
|
@@ -42,6 +45,8 @@ const MIN_SPAN_PX = 2;
|
|
|
42
45
|
const HIT_SLOP_PX = 4;
|
|
43
46
|
const LABEL_MIN_PX = 56;
|
|
44
47
|
const SPAN_RADIUS = 3;
|
|
48
|
+
/** Opacity of spans that don't match the search. */
|
|
49
|
+
const DIM_ALPHA = 0.3;
|
|
45
50
|
|
|
46
51
|
const LANE_ORDER: Array<{ kind: TraceSpanKind; label: string }> = [
|
|
47
52
|
{ kind: "turn", label: "Input" },
|
|
@@ -65,6 +70,8 @@ interface TrackBlock {
|
|
|
65
70
|
hasChildren: boolean;
|
|
66
71
|
headerY: number;
|
|
67
72
|
lanes: LaneRow[];
|
|
73
|
+
/** Turn spans of the track (start order), for the alternating turn bands. */
|
|
74
|
+
turnSpans: TraceSpan[];
|
|
68
75
|
/** Full vertical extent of the block (header top → last lane bottom). */
|
|
69
76
|
y0: number;
|
|
70
77
|
y1: number;
|
|
@@ -99,18 +106,27 @@ function buildLayout(tracks: TraceTrack[], collapsed: ReadonlySet<string>): Time
|
|
|
99
106
|
for (const track of tracks) {
|
|
100
107
|
if (isHidden(track)) continue;
|
|
101
108
|
const depth = track.id === "main" ? 0 : track.id.split("/").length;
|
|
109
|
+
const byKind: Record<TraceSpanKind, TraceSpan[]> = {
|
|
110
|
+
turn: [],
|
|
111
|
+
model: [],
|
|
112
|
+
tool: [],
|
|
113
|
+
subagent: [],
|
|
114
|
+
background: [],
|
|
115
|
+
};
|
|
116
|
+
for (const span of track.spans) byKind[span.kind].push(span);
|
|
102
117
|
const block: TrackBlock = {
|
|
103
118
|
track,
|
|
104
119
|
depth,
|
|
105
120
|
hasChildren: hasChildren.has(track.id),
|
|
106
121
|
headerY: y,
|
|
107
122
|
lanes: [],
|
|
123
|
+
turnSpans: byKind.turn,
|
|
108
124
|
y0: y,
|
|
109
125
|
y1: y,
|
|
110
126
|
};
|
|
111
127
|
y += HEADER_H;
|
|
112
128
|
for (const { kind, label } of LANE_ORDER) {
|
|
113
|
-
const spans =
|
|
129
|
+
const spans = byKind[kind];
|
|
114
130
|
// Main always shows the core lanes; optional lanes appear when populated.
|
|
115
131
|
const isCore = kind === "turn" || kind === "model" || kind === "tool";
|
|
116
132
|
if (spans.length === 0 && !(track.id === "main" && isCore)) continue;
|
|
@@ -126,6 +142,47 @@ function buildLayout(tracks: TraceTrack[], collapsed: ReadonlySet<string>): Time
|
|
|
126
142
|
return { blocks, lanes, totalHeight: Math.max(y, RULER_H + 48) };
|
|
127
143
|
}
|
|
128
144
|
|
|
145
|
+
/** Per-lane span positions on the virtual axis, parallel to `LaneRow.spans`. */
|
|
146
|
+
interface LaneAxis {
|
|
147
|
+
startU: Float64Array;
|
|
148
|
+
endU: Float64Array;
|
|
149
|
+
/** Running max of `endU`; monotonic, so the first visible span is a binary search away. */
|
|
150
|
+
maxEndU: Float64Array;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function buildLaneAxes(
|
|
154
|
+
layout: { lanes: ReadonlyArray<{ spans: ReadonlyArray<Pick<TraceSpan, "start" | "end">> }> },
|
|
155
|
+
scale: Pick<TraceScale, "toU">,
|
|
156
|
+
): LaneAxis[] {
|
|
157
|
+
return layout.lanes.map(lane => {
|
|
158
|
+
const n = lane.spans.length;
|
|
159
|
+
const startU = new Float64Array(n);
|
|
160
|
+
const endU = new Float64Array(n);
|
|
161
|
+
const maxEndU = new Float64Array(n);
|
|
162
|
+
let maxEnd = Number.NEGATIVE_INFINITY;
|
|
163
|
+
for (let i = 0; i < n; i++) {
|
|
164
|
+
const span = lane.spans[i];
|
|
165
|
+
startU[i] = scale.toU(span.start);
|
|
166
|
+
endU[i] = scale.toU(span.end);
|
|
167
|
+
maxEnd = Math.max(maxEnd, endU[i]);
|
|
168
|
+
maxEndU[i] = maxEnd;
|
|
169
|
+
}
|
|
170
|
+
return { startU, endU, maxEndU };
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** First index whose running-max end reaches `u0`: every earlier span ends before the viewport. */
|
|
175
|
+
export function firstVisible(maxEndU: Float64Array, u0: number): number {
|
|
176
|
+
let lo = 0;
|
|
177
|
+
let hi = maxEndU.length;
|
|
178
|
+
while (lo < hi) {
|
|
179
|
+
const mid = (lo + hi) >>> 1;
|
|
180
|
+
if (maxEndU[mid] < u0) lo = mid + 1;
|
|
181
|
+
else hi = mid;
|
|
182
|
+
}
|
|
183
|
+
return lo;
|
|
184
|
+
}
|
|
185
|
+
|
|
129
186
|
interface SpanHit {
|
|
130
187
|
kind: "span";
|
|
131
188
|
x: number;
|
|
@@ -148,10 +205,10 @@ interface MarkerHit {
|
|
|
148
205
|
|
|
149
206
|
type Hit = SpanHit | MarkerHit;
|
|
150
207
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
208
|
+
function sameTarget(a: Hit | null, b: Hit | null): boolean {
|
|
209
|
+
if (a === null || b === null) return a === b;
|
|
210
|
+
if (a.kind === "span") return b.kind === "span" && a.span === b.span;
|
|
211
|
+
return b.kind === "marker" && a.marker === b.marker;
|
|
155
212
|
}
|
|
156
213
|
|
|
157
214
|
export function TimelineCanvas({
|
|
@@ -159,9 +216,9 @@ export function TimelineCanvas({
|
|
|
159
216
|
scale,
|
|
160
217
|
viewport,
|
|
161
218
|
onViewportChange,
|
|
162
|
-
|
|
219
|
+
selected,
|
|
163
220
|
onSelect,
|
|
164
|
-
|
|
221
|
+
matchIds,
|
|
165
222
|
collapsed,
|
|
166
223
|
onToggleCollapse,
|
|
167
224
|
traceStart,
|
|
@@ -169,8 +226,14 @@ export function TimelineCanvas({
|
|
|
169
226
|
const colors = useTraceTheme();
|
|
170
227
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
171
228
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
229
|
+
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
172
230
|
const [canvasWidth, setCanvasWidth] = useState(800);
|
|
173
|
-
|
|
231
|
+
// Hovered target only; the pointer position lives in a ref and moves the
|
|
232
|
+
// tooltip directly, so moving within one span doesn't re-render.
|
|
233
|
+
const [hover, setHover] = useState<Hit | null>(null);
|
|
234
|
+
const hoverRef = useRef<Hit | null>(null);
|
|
235
|
+
hoverRef.current = hover;
|
|
236
|
+
const pointerRef = useRef({ clientX: 0, clientY: 0 });
|
|
174
237
|
const hitsRef = useRef<Hit[]>([]);
|
|
175
238
|
const dragRef = useRef<{ pointerId: number; lastX: number; moved: boolean } | null>(null);
|
|
176
239
|
const hoverXRef = useRef<number | null>(null);
|
|
@@ -181,6 +244,7 @@ export function TimelineCanvas({
|
|
|
181
244
|
viewportRef.current = viewport;
|
|
182
245
|
|
|
183
246
|
const layout = useMemo(() => buildLayout(tracks, collapsed), [tracks, collapsed]);
|
|
247
|
+
const laneAxes = useMemo(() => buildLaneAxes(layout, scale), [layout, scale]);
|
|
184
248
|
|
|
185
249
|
// Observe container width (gutter excluded).
|
|
186
250
|
useEffect(() => {
|
|
@@ -287,6 +351,16 @@ export function TimelineCanvas({
|
|
|
287
351
|
return null;
|
|
288
352
|
}, []);
|
|
289
353
|
|
|
354
|
+
// Fixed positioning escapes the scroll frame's clipping; clamp to the window.
|
|
355
|
+
const positionTooltip = useCallback(() => {
|
|
356
|
+
const tooltip = tooltipRef.current;
|
|
357
|
+
if (!tooltip) return;
|
|
358
|
+
const { clientX, clientY } = pointerRef.current;
|
|
359
|
+
const left = Math.min(clientX + 14, window.innerWidth - 320);
|
|
360
|
+
const top = clientY + 16 > window.innerHeight - 140 ? clientY - 120 : clientY + 16;
|
|
361
|
+
tooltip.style.transform = `translate(${left}px, ${top}px)`;
|
|
362
|
+
}, []);
|
|
363
|
+
|
|
290
364
|
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
|
291
365
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
292
366
|
dragRef.current = { pointerId: event.pointerId, lastX: event.clientX, moved: false };
|
|
@@ -308,8 +382,10 @@ export function TimelineCanvas({
|
|
|
308
382
|
setHover(null);
|
|
309
383
|
return;
|
|
310
384
|
}
|
|
385
|
+
pointerRef.current = { clientX: event.clientX, clientY: event.clientY };
|
|
311
386
|
const hit = hitAt(event.clientX, event.clientY);
|
|
312
|
-
|
|
387
|
+
if (sameTarget(hit, hoverRef.current)) positionTooltip();
|
|
388
|
+
else setHover(hit);
|
|
313
389
|
};
|
|
314
390
|
|
|
315
391
|
const handlePointerUp = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
|
@@ -328,9 +404,10 @@ export function TimelineCanvas({
|
|
|
328
404
|
|
|
329
405
|
const selectSibling = useCallback(
|
|
330
406
|
(direction: 1 | -1) => {
|
|
331
|
-
|
|
407
|
+
const selectedId = selected?.span.id;
|
|
408
|
+
if (!selectedId) return;
|
|
332
409
|
for (const lane of layout.lanes) {
|
|
333
|
-
const index = lane.spans.findIndex(span => span.id ===
|
|
410
|
+
const index = lane.spans.findIndex(span => span.id === selectedId);
|
|
334
411
|
if (index === -1) continue;
|
|
335
412
|
const next = lane.spans[index + direction];
|
|
336
413
|
if (!next) return;
|
|
@@ -344,7 +421,7 @@ export function TimelineCanvas({
|
|
|
344
421
|
return;
|
|
345
422
|
}
|
|
346
423
|
},
|
|
347
|
-
[
|
|
424
|
+
[selected, layout, onSelect, scale, viewport, onViewportChange, clampViewport],
|
|
348
425
|
);
|
|
349
426
|
|
|
350
427
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLCanvasElement>) => {
|
|
@@ -370,12 +447,9 @@ export function TimelineCanvas({
|
|
|
370
447
|
onViewportChange({ u0: scale.domain[0], u1: scale.domain[1] });
|
|
371
448
|
break;
|
|
372
449
|
case "f":
|
|
373
|
-
case "F":
|
|
374
|
-
if (
|
|
375
|
-
const selected = tracks.flatMap(track => track.spans).find(s => s.id === selection);
|
|
376
|
-
if (selected) zoomToSpan(selected);
|
|
450
|
+
case "F":
|
|
451
|
+
if (selected) zoomToSpan(selected.span);
|
|
377
452
|
break;
|
|
378
|
-
}
|
|
379
453
|
case "Escape":
|
|
380
454
|
onSelect(null);
|
|
381
455
|
break;
|
|
@@ -396,23 +470,30 @@ export function TimelineCanvas({
|
|
|
396
470
|
canvasRef.current?.focus({ preventScroll: true });
|
|
397
471
|
}, []);
|
|
398
472
|
|
|
399
|
-
//
|
|
473
|
+
// Scene: everything except hover/selection, so pointer moves never repaint it.
|
|
400
474
|
useEffect(() => {
|
|
401
475
|
const canvas = canvasRef.current;
|
|
402
476
|
if (!canvas) return;
|
|
403
477
|
const raf = requestAnimationFrame(() => {
|
|
404
|
-
|
|
405
|
-
width: canvasWidth,
|
|
406
|
-
selection,
|
|
407
|
-
hoverId: hover?.hit.kind === "span" ? hover.hit.span.id : null,
|
|
408
|
-
search,
|
|
409
|
-
hits: hitsRef,
|
|
410
|
-
});
|
|
478
|
+
hitsRef.current = drawScene(canvas, layout, laneAxes, scale, viewport, colors, canvasWidth, matchIds);
|
|
411
479
|
});
|
|
412
480
|
return () => cancelAnimationFrame(raf);
|
|
413
|
-
}, [layout, scale, viewport, colors, canvasWidth,
|
|
481
|
+
}, [layout, laneAxes, scale, viewport, colors, canvasWidth, matchIds]);
|
|
482
|
+
|
|
483
|
+
// Hover tint + selection outline are DOM boxes over the scene, so pointer
|
|
484
|
+
// moves never repaint it and need no second full-size bitmap.
|
|
485
|
+
const hoverBox = hover?.kind === "span" ? placeSpan(hover, layout, scale, viewport, canvasWidth) : null;
|
|
486
|
+
const selectedBox = selected ? placeSpan(selected, layout, scale, viewport, canvasWidth) : null;
|
|
487
|
+
|
|
488
|
+
// A new tooltip target mounts at the latest pointer position.
|
|
489
|
+
useLayoutEffect(() => {
|
|
490
|
+
if (hover) positionTooltip();
|
|
491
|
+
}, [hover, positionTooltip]);
|
|
414
492
|
|
|
415
|
-
const tooltip = hover ? renderTooltip(hover, traceStart) : null;
|
|
493
|
+
const tooltip = hover ? renderTooltip(hover, traceStart, tooltipRef) : null;
|
|
494
|
+
const pixelWidth = Math.floor(canvasWidth * devicePixelRatio);
|
|
495
|
+
const pixelHeight = Math.floor(layout.totalHeight * devicePixelRatio);
|
|
496
|
+
const cssSize = { width: canvasWidth, height: layout.totalHeight };
|
|
416
497
|
|
|
417
498
|
return (
|
|
418
499
|
<div ref={containerRef} className="traces-timeline">
|
|
@@ -471,39 +552,62 @@ export function TimelineCanvas({
|
|
|
471
552
|
))}
|
|
472
553
|
</div>
|
|
473
554
|
|
|
474
|
-
<canvas
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
555
|
+
<div className="traces-canvas-stack" style={cssSize}>
|
|
556
|
+
<canvas
|
|
557
|
+
ref={canvasRef}
|
|
558
|
+
className="traces-canvas"
|
|
559
|
+
width={pixelWidth}
|
|
560
|
+
height={pixelHeight}
|
|
561
|
+
style={cssSize}
|
|
562
|
+
tabIndex={0}
|
|
563
|
+
role="application"
|
|
564
|
+
aria-label="Trace timeline. W and S zoom, A and D pan, 0 fits all, F focuses the selection."
|
|
565
|
+
onPointerDown={handlePointerDown}
|
|
566
|
+
onPointerMove={handlePointerMove}
|
|
567
|
+
onPointerUp={handlePointerUp}
|
|
568
|
+
onPointerLeave={() => {
|
|
569
|
+
hoverXRef.current = null;
|
|
570
|
+
setHover(null);
|
|
571
|
+
}}
|
|
572
|
+
onDoubleClick={handleDoubleClick}
|
|
573
|
+
onKeyDown={handleKeyDown}
|
|
574
|
+
/>
|
|
575
|
+
{hoverBox && hover?.kind === "span" && (
|
|
576
|
+
<div
|
|
577
|
+
className="traces-canvas-hover"
|
|
578
|
+
aria-hidden="true"
|
|
579
|
+
style={{
|
|
580
|
+
left: hoverBox.x,
|
|
581
|
+
top: hoverBox.y,
|
|
582
|
+
width: hoverBox.w,
|
|
583
|
+
height: LANE_H,
|
|
584
|
+
borderRadius: SPAN_RADIUS,
|
|
585
|
+
opacity: matchIds === null || matchIds.has(hover.span.id) ? 1 : DIM_ALPHA,
|
|
586
|
+
}}
|
|
587
|
+
/>
|
|
588
|
+
)}
|
|
589
|
+
{selectedBox && selected && (
|
|
590
|
+
<div
|
|
591
|
+
className="traces-canvas-selection"
|
|
592
|
+
aria-hidden="true"
|
|
593
|
+
style={{
|
|
594
|
+
left: selectedBox.x - 1,
|
|
595
|
+
top: selectedBox.y - 1,
|
|
596
|
+
width: selectedBox.w + 2,
|
|
597
|
+
height: LANE_H + 2,
|
|
598
|
+
borderRadius: SPAN_RADIUS,
|
|
599
|
+
borderColor: colors.selection,
|
|
600
|
+
opacity: matchIds === null || matchIds.has(selected.span.id) ? 1 : DIM_ALPHA,
|
|
601
|
+
}}
|
|
602
|
+
/>
|
|
603
|
+
)}
|
|
604
|
+
</div>
|
|
493
605
|
|
|
494
606
|
{tooltip && createPortal(tooltip, document.body)}
|
|
495
607
|
</div>
|
|
496
608
|
);
|
|
497
609
|
}
|
|
498
610
|
|
|
499
|
-
interface DrawOptions {
|
|
500
|
-
width: number;
|
|
501
|
-
selection: string | null;
|
|
502
|
-
hoverId: string | null;
|
|
503
|
-
search: string;
|
|
504
|
-
hits: React.MutableRefObject<Hit[]>;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
611
|
/** Rounded span rect, degrading to a plain rect for slivers. */
|
|
508
612
|
function spanPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): void {
|
|
509
613
|
ctx.beginPath();
|
|
@@ -511,17 +615,20 @@ function spanPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number
|
|
|
511
615
|
else ctx.rect(x, y, w, h);
|
|
512
616
|
}
|
|
513
617
|
|
|
514
|
-
|
|
618
|
+
/** Paint the scene and return the hit-test boxes, topmost last. */
|
|
619
|
+
function drawScene(
|
|
515
620
|
canvas: HTMLCanvasElement,
|
|
516
621
|
layout: TimelineLayout,
|
|
622
|
+
laneAxes: LaneAxis[],
|
|
517
623
|
scale: TraceScale,
|
|
518
624
|
viewport: TimelineViewport,
|
|
519
625
|
colors: TraceTheme,
|
|
520
|
-
|
|
521
|
-
|
|
626
|
+
width: number,
|
|
627
|
+
matchIds: ReadonlySet<string> | null,
|
|
628
|
+
): Hit[] {
|
|
629
|
+
const nextHits: Hit[] = [];
|
|
522
630
|
const ctx = canvas.getContext("2d");
|
|
523
|
-
if (!ctx) return;
|
|
524
|
-
const { width, selection, hoverId, search, hits } = options;
|
|
631
|
+
if (!ctx) return nextHits;
|
|
525
632
|
const height = layout.totalHeight;
|
|
526
633
|
const dpr = devicePixelRatio;
|
|
527
634
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
@@ -530,12 +637,10 @@ function draw(
|
|
|
530
637
|
const { u0, u1 } = viewport;
|
|
531
638
|
const uSpan = Math.max(u1 - u0, 1e-9);
|
|
532
639
|
const toX = (u: number) => ((u - u0) / uSpan) * width;
|
|
533
|
-
const needle = search.trim().toLowerCase();
|
|
534
|
-
const nextHits: Hit[] = [];
|
|
535
640
|
|
|
536
641
|
// Turn bands: alternating fill between turn boundaries per track.
|
|
537
642
|
for (const block of layout.blocks) {
|
|
538
|
-
const turnSpans = block.
|
|
643
|
+
const turnSpans = block.turnSpans;
|
|
539
644
|
for (let i = 0; i < turnSpans.length; i++) {
|
|
540
645
|
const bandStart = toX(scale.toU(turnSpans[i].start));
|
|
541
646
|
const bandEnd = i + 1 < turnSpans.length ? toX(scale.toU(turnSpans[i + 1].start)) : width;
|
|
@@ -559,7 +664,8 @@ function draw(
|
|
|
559
664
|
}
|
|
560
665
|
}
|
|
561
666
|
|
|
562
|
-
// Idle-gap bridges.
|
|
667
|
+
// Idle-gap bridges and the ruler share the mono label font.
|
|
668
|
+
ctx.font = `9px ${colors.fontMono}`;
|
|
563
669
|
for (const gap of scale.gaps) {
|
|
564
670
|
const x = toX(gap.uMid);
|
|
565
671
|
if (x < -24 || x > width + 24) continue;
|
|
@@ -572,14 +678,12 @@ function draw(
|
|
|
572
678
|
ctx.stroke();
|
|
573
679
|
ctx.restore();
|
|
574
680
|
ctx.fillStyle = colors.tick;
|
|
575
|
-
ctx.font = `9px ${colors.fontMono}`;
|
|
576
681
|
ctx.textAlign = "center";
|
|
577
682
|
ctx.fillText(`⋯ ${formatDurationMs(gap.t1 - gap.t0)}`, x, RULER_H - 4);
|
|
578
683
|
ctx.textAlign = "left";
|
|
579
684
|
}
|
|
580
685
|
|
|
581
686
|
// Ruler: major ticks get a full-height gridline, minors stay in the ruler.
|
|
582
|
-
ctx.font = `9px ${colors.fontMono}`;
|
|
583
687
|
for (const tick of buildTicks(scale, u0, u1, width)) {
|
|
584
688
|
const x = Math.round(toX(tick.u)) + 0.5;
|
|
585
689
|
ctx.strokeStyle = colors.grid;
|
|
@@ -607,28 +711,32 @@ function draw(
|
|
|
607
711
|
ctx.lineTo(width, RULER_H + 0.5);
|
|
608
712
|
ctx.stroke();
|
|
609
713
|
|
|
610
|
-
// Spans per lane.
|
|
611
|
-
|
|
714
|
+
// Spans per lane. Labels are the only text from here on.
|
|
715
|
+
ctx.font = `10px ${colors.fontSans}`;
|
|
716
|
+
for (let laneIndex = 0; laneIndex < layout.lanes.length; laneIndex++) {
|
|
717
|
+
const lane = layout.lanes[laneIndex];
|
|
718
|
+
const { startU, endU, maxEndU } = laneAxes[laneIndex];
|
|
612
719
|
const isTurnLane = lane.kind === "turn";
|
|
613
|
-
for (
|
|
614
|
-
const uStart =
|
|
720
|
+
for (let i = firstVisible(maxEndU, u0); i < lane.spans.length; i++) {
|
|
721
|
+
const uStart = startU[i];
|
|
615
722
|
if (uStart > u1) break; // spans sorted by start
|
|
616
|
-
const uEnd =
|
|
723
|
+
const uEnd = endU[i];
|
|
617
724
|
if (uEnd < u0) continue;
|
|
725
|
+
const span = lane.spans[i];
|
|
618
726
|
const x = toX(uStart);
|
|
619
727
|
const w = Math.max(MIN_SPAN_PX, toX(uEnd) - x);
|
|
620
728
|
const y = lane.y;
|
|
621
|
-
const
|
|
729
|
+
const alpha = matchIds === null || matchIds.has(span.id) ? 1 : DIM_ALPHA;
|
|
622
730
|
const color = colors.category[span.kind];
|
|
623
731
|
|
|
624
|
-
ctx.globalAlpha =
|
|
732
|
+
ctx.globalAlpha = alpha;
|
|
625
733
|
if (isTurnLane) {
|
|
626
734
|
// Turn ranges read as context, not work: soft fill + solid start cap.
|
|
627
735
|
ctx.fillStyle = color;
|
|
628
|
-
ctx.globalAlpha =
|
|
736
|
+
ctx.globalAlpha = alpha * 0.23;
|
|
629
737
|
spanPath(ctx, x, y, w, LANE_H);
|
|
630
738
|
ctx.fill();
|
|
631
|
-
ctx.globalAlpha =
|
|
739
|
+
ctx.globalAlpha = alpha;
|
|
632
740
|
ctx.fillStyle = color;
|
|
633
741
|
ctx.fillRect(x, y, 2, LANE_H);
|
|
634
742
|
} else {
|
|
@@ -660,25 +768,12 @@ function draw(
|
|
|
660
768
|
ctx.stroke();
|
|
661
769
|
ctx.restore();
|
|
662
770
|
}
|
|
663
|
-
if (hoverId === span.id) {
|
|
664
|
-
ctx.fillStyle = "rgba(255,255,255,0.16)";
|
|
665
|
-
spanPath(ctx, x, y, w, LANE_H);
|
|
666
|
-
ctx.fill();
|
|
667
|
-
}
|
|
668
|
-
if (selection === span.id) {
|
|
669
|
-
ctx.strokeStyle = colors.selection;
|
|
670
|
-
ctx.lineWidth = 1.5;
|
|
671
|
-
spanPath(ctx, x - 1, y - 1, w + 2, LANE_H + 2);
|
|
672
|
-
ctx.stroke();
|
|
673
|
-
ctx.lineWidth = 1;
|
|
674
|
-
}
|
|
675
771
|
if (w > LABEL_MIN_PX) {
|
|
676
772
|
ctx.save();
|
|
677
773
|
ctx.beginPath();
|
|
678
774
|
ctx.rect(x + 3, y, w - 6, LANE_H);
|
|
679
775
|
ctx.clip();
|
|
680
776
|
ctx.fillStyle = isTurnLane ? colors.tick : colors.spanText;
|
|
681
|
-
ctx.font = `10px ${colors.fontSans}`;
|
|
682
777
|
ctx.fillText(span.label, x + 5, y + LANE_H - 5);
|
|
683
778
|
ctx.restore();
|
|
684
779
|
}
|
|
@@ -707,19 +802,43 @@ function draw(
|
|
|
707
802
|
}
|
|
708
803
|
}
|
|
709
804
|
|
|
710
|
-
|
|
805
|
+
return nextHits;
|
|
711
806
|
}
|
|
712
807
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
808
|
+
interface SpanBox {
|
|
809
|
+
x: number;
|
|
810
|
+
y: number;
|
|
811
|
+
w: number;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Scene geometry of a drawn span, or null when it isn't on screen. */
|
|
815
|
+
function placeSpan(
|
|
816
|
+
{ span, track }: { span: TraceSpan; track: TraceTrack },
|
|
817
|
+
layout: TimelineLayout,
|
|
818
|
+
scale: TraceScale,
|
|
819
|
+
viewport: TimelineViewport,
|
|
820
|
+
width: number,
|
|
821
|
+
): SpanBox | null {
|
|
822
|
+
const { u0, u1 } = viewport;
|
|
823
|
+
const uStart = scale.toU(span.start);
|
|
824
|
+
const uEnd = scale.toU(span.end);
|
|
825
|
+
if (uStart > u1 || uEnd < u0) return null;
|
|
826
|
+
const lane = layout.lanes.find(candidate => candidate.track === track && candidate.kind === span.kind);
|
|
827
|
+
if (!lane) return null;
|
|
828
|
+
const uSpan = Math.max(u1 - u0, 1e-9);
|
|
829
|
+
const x = ((uStart - u0) / uSpan) * width;
|
|
830
|
+
const xEnd = ((uEnd - u0) / uSpan) * width;
|
|
831
|
+
return { x, y: lane.y, w: Math.max(MIN_SPAN_PX, xEnd - x) };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function renderTooltip(hover: Hit, traceStart: number, ref: React.RefObject<HTMLDivElement | null>) {
|
|
835
|
+
// Positioned by `transform` from the pointer handler (see positionTooltip).
|
|
836
|
+
const style: React.CSSProperties = { left: 0, top: 0 };
|
|
718
837
|
|
|
719
|
-
if (hover.
|
|
720
|
-
const { marker } = hover
|
|
838
|
+
if (hover.kind === "marker") {
|
|
839
|
+
const { marker } = hover;
|
|
721
840
|
return (
|
|
722
|
-
<div className="traces-tooltip" style={style}>
|
|
841
|
+
<div ref={ref} className="traces-tooltip" style={style}>
|
|
723
842
|
<div className="traces-tooltip-title">{marker.label}</div>
|
|
724
843
|
<div className="muted num">
|
|
725
844
|
{new Date(marker.time).toLocaleTimeString()} ({formatOffset(marker.time - traceStart)})
|
|
@@ -728,9 +847,9 @@ function renderTooltip(hover: HoverState, traceStart: number) {
|
|
|
728
847
|
);
|
|
729
848
|
}
|
|
730
849
|
|
|
731
|
-
const { span } = hover
|
|
850
|
+
const { span } = hover;
|
|
732
851
|
return (
|
|
733
|
-
<div className="traces-tooltip" style={style}>
|
|
852
|
+
<div ref={ref} className="traces-tooltip" style={style}>
|
|
734
853
|
<div className="traces-tooltip-title truncate">{span.label}</div>
|
|
735
854
|
<div className="muted num">
|
|
736
855
|
{formatDurationMs(span.end - span.start)} · {new Date(span.start).toLocaleTimeString()} (
|
|
@@ -8,7 +8,7 @@ import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
|
|
|
8
8
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
9
9
|
import { getSessionTrace } from "../api";
|
|
10
10
|
import { useQuery } from "../data/query";
|
|
11
|
-
import type { TraceSpan, TraceSpanKind, TraceTrack } from "../types";
|
|
11
|
+
import type { SessionTrace, TraceSpan, TraceSpanKind, TraceTrack } from "../types";
|
|
12
12
|
import { Card, ChartSkeleton, PageHeader, QueryView, SearchInput, Segmented } from "../ui";
|
|
13
13
|
import { AggregatesPanel } from "./AggregatesPanel";
|
|
14
14
|
import { Minimap } from "./Minimap";
|
|
@@ -40,7 +40,11 @@ const LEGEND: Array<{ kind: TraceSpanKind; label: string }> = [
|
|
|
40
40
|
];
|
|
41
41
|
|
|
42
42
|
export function TraceView({ file, active, onBack }: TraceViewProps) {
|
|
43
|
-
const query = useQuery(
|
|
43
|
+
const query = useQuery<SessionTrace>(
|
|
44
|
+
["trace", file],
|
|
45
|
+
({ signal }, previous) => getSessionTrace(file, signal, previous),
|
|
46
|
+
{ pollMs: 15000, enabled: active },
|
|
47
|
+
);
|
|
44
48
|
// A previous session's trace must never render under this file's header.
|
|
45
49
|
const trace = query.stale ? null : query.data;
|
|
46
50
|
|
|
@@ -89,7 +93,10 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
|
|
|
89
93
|
});
|
|
90
94
|
}, [scale]);
|
|
91
95
|
|
|
92
|
-
const effectiveViewport
|
|
96
|
+
const effectiveViewport = useMemo<TimelineViewport>(
|
|
97
|
+
() => viewport ?? { u0: scale.domain[0], u1: scale.domain[1] },
|
|
98
|
+
[viewport, scale],
|
|
99
|
+
);
|
|
93
100
|
|
|
94
101
|
const allSpans = useMemo(() => {
|
|
95
102
|
const spans: Array<{ span: TraceSpan; track: TraceTrack }> = [];
|
|
@@ -105,6 +112,11 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
|
|
|
105
112
|
if (!needle) return [];
|
|
106
113
|
return allSpans.filter(({ span }) => `${span.label} ${span.detail ?? ""}`.toLowerCase().includes(needle));
|
|
107
114
|
}, [allSpans, search]);
|
|
115
|
+
// Shared with the canvas and transcript so the match test runs once per search.
|
|
116
|
+
const matchIds = useMemo(
|
|
117
|
+
() => (search.trim() ? new Set(matches.map(({ span }) => span.id)) : null),
|
|
118
|
+
[matches, search],
|
|
119
|
+
);
|
|
108
120
|
|
|
109
121
|
const centerOnSpan = useCallback(
|
|
110
122
|
(span: TraceSpan) => {
|
|
@@ -198,6 +210,15 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
|
|
|
198
210
|
[tracks],
|
|
199
211
|
);
|
|
200
212
|
|
|
213
|
+
const openFromTranscript = useCallback(
|
|
214
|
+
(spanId: string) => {
|
|
215
|
+
selectAndReveal(spanId);
|
|
216
|
+
setDrawerOpen(true);
|
|
217
|
+
},
|
|
218
|
+
[selectAndReveal],
|
|
219
|
+
);
|
|
220
|
+
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
|
221
|
+
|
|
201
222
|
const traceStart = trace?.startedAt ?? 0;
|
|
202
223
|
|
|
203
224
|
const traceQuery = { ...query, data: trace };
|
|
@@ -346,9 +367,9 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
|
|
|
346
367
|
scale={scale}
|
|
347
368
|
viewport={effectiveViewport}
|
|
348
369
|
onViewportChange={setViewport}
|
|
349
|
-
|
|
370
|
+
selected={selected}
|
|
350
371
|
onSelect={selectAndOpen}
|
|
351
|
-
|
|
372
|
+
matchIds={matchIds}
|
|
352
373
|
collapsed={collapsed}
|
|
353
374
|
onToggleCollapse={toggleCollapse}
|
|
354
375
|
traceStart={traceStart}
|
|
@@ -365,11 +386,9 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
|
|
|
365
386
|
<TranscriptList
|
|
366
387
|
tracks={tracks}
|
|
367
388
|
selection={selection}
|
|
368
|
-
onSelect={
|
|
369
|
-
selectAndReveal(spanId);
|
|
370
|
-
setDrawerOpen(true);
|
|
371
|
-
}}
|
|
389
|
+
onSelect={openFromTranscript}
|
|
372
390
|
search={search}
|
|
391
|
+
matchIds={matchIds}
|
|
373
392
|
traceStart={traceStart}
|
|
374
393
|
/>
|
|
375
394
|
</Card>
|
|
@@ -381,7 +400,7 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
|
|
|
381
400
|
<SpanDrawer
|
|
382
401
|
span={drawerOpen ? (selected?.span ?? null) : null}
|
|
383
402
|
track={selected?.track ?? null}
|
|
384
|
-
onClose={
|
|
403
|
+
onClose={closeDrawer}
|
|
385
404
|
onOpenChildTrack={openChildTrack}
|
|
386
405
|
/>
|
|
387
406
|
</div>
|