@workflow/web-shared 5.0.0-beta.17 → 5.0.0-beta.18
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/dist/components/new-trace-viewer/components/span-markers.d.ts +46 -0
- package/dist/components/new-trace-viewer/components/span-markers.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/span-markers.js +85 -0
- package/dist/components/new-trace-viewer/components/timeline.d.ts +3 -1
- package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/timeline.js +34 -51
- package/dist/components/new-trace-viewer/components/use-row-window.d.ts +14 -0
- package/dist/components/new-trace-viewer/components/use-row-window.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/use-row-window.js +39 -1
- package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.js +87 -23
- package/dist/components/new-trace-viewer/utils.d.ts +15 -0
- package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/utils.js +37 -31
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +3 -25
- package/dist/components/sidebar/attributes-block.d.ts +1 -1
- package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
- package/dist/components/sidebar/attributes-block.js +5 -7
- package/dist/components/trace-viewer/trace-viewer.module.css +4 -4
- package/dist/lib/hydration.d.ts.map +1 -1
- package/dist/lib/hydration.js +6 -1
- package/dist/lib/zstd-browser-decoder.d.ts +7 -0
- package/dist/lib/zstd-browser-decoder.d.ts.map +1 -0
- package/dist/lib/zstd-browser-decoder.js +41 -0
- package/package.json +4 -3
- package/src/components/new-trace-viewer/components/span-markers.tsx +173 -0
- package/src/components/new-trace-viewer/components/timeline.tsx +142 -95
- package/src/components/new-trace-viewer/components/use-row-window.ts +47 -0
- package/src/components/new-trace-viewer/trace-viewer.tsx +108 -23
- package/src/components/new-trace-viewer/utils.test.ts +150 -0
- package/src/components/new-trace-viewer/utils.ts +61 -34
- package/src/components/sidebar/attribute-panel.tsx +5 -43
- package/src/components/sidebar/attributes-block.tsx +9 -18
- package/src/components/trace-viewer/trace-viewer.module.css +4 -4
- package/src/lib/hydration.ts +7 -0
- package/src/lib/zstd-browser-decoder.ts +42 -0
|
@@ -2,23 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
|
4
4
|
import type { CSSProperties, ReactNode } from 'react';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Fragment,
|
|
7
|
+
memo,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from 'react';
|
|
6
14
|
import { cn } from '../../../lib/utils';
|
|
7
15
|
import {
|
|
8
16
|
formatDuration,
|
|
9
17
|
formatDurationPrecise,
|
|
10
18
|
getHighResInMs,
|
|
11
19
|
} from '../../trace-viewer/util/timing';
|
|
12
|
-
import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip';
|
|
13
20
|
import { isSpanDimmedBySearch, type SpanSearchResult } from '../search';
|
|
14
21
|
import type { Span } from '../types';
|
|
15
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
OffscreenMarkers,
|
|
24
|
+
Segment,
|
|
25
|
+
SegmentStatus,
|
|
26
|
+
TimeMarker,
|
|
27
|
+
} from '../utils';
|
|
16
28
|
import {
|
|
29
|
+
computeOffscreenMarkers,
|
|
17
30
|
computeSpanGaps,
|
|
31
|
+
computeSpanMarkers,
|
|
18
32
|
computeSpanSegments,
|
|
19
33
|
getResourceColor,
|
|
20
34
|
getSpanDurationMs,
|
|
21
35
|
} from '../utils';
|
|
36
|
+
import {
|
|
37
|
+
cullCollidingMarkers,
|
|
38
|
+
MarkerLayer,
|
|
39
|
+
OffscreenMarkerIndicator,
|
|
40
|
+
projectMarkers,
|
|
41
|
+
} from './span-markers';
|
|
22
42
|
import styles from './timeline.module.css';
|
|
23
43
|
import { ROW_HEIGHT_PX, useRowWindow } from './use-row-window';
|
|
24
44
|
|
|
@@ -33,7 +53,7 @@ export const TIMELINE_PADDING_PX = 16;
|
|
|
33
53
|
const SEGMENT_CLASSES: Record<SegmentStatus, string> = {
|
|
34
54
|
queued: 'bg-gray-400 border border-gray-500',
|
|
35
55
|
retrying: 'bg-gray-400 border border-gray-500',
|
|
36
|
-
waiting: 'bg-gray-
|
|
56
|
+
waiting: 'bg-gray-200 border border-gray-500',
|
|
37
57
|
running: 'bg-blue-200 border border-blue-500',
|
|
38
58
|
completed: 'bg-blue-200 border border-blue-500',
|
|
39
59
|
failed: 'bg-red-200 border border-red-500',
|
|
@@ -194,9 +214,20 @@ function projectSegments(
|
|
|
194
214
|
// Small render helpers
|
|
195
215
|
// ---------------------------------------------------------------------------
|
|
196
216
|
|
|
197
|
-
function DurationLabel({
|
|
217
|
+
function DurationLabel({
|
|
218
|
+
label,
|
|
219
|
+
className,
|
|
220
|
+
}: {
|
|
221
|
+
label: string;
|
|
222
|
+
className?: string;
|
|
223
|
+
}): ReactNode {
|
|
198
224
|
return (
|
|
199
|
-
<span
|
|
225
|
+
<span
|
|
226
|
+
className={cn(
|
|
227
|
+
'pointer-events-none absolute inset-0 flex items-center justify-start overflow-hidden px-1 text-[10px] font-mono font-medium leading-none whitespace-nowrap text-left text-gray-1000 tabular-nums opacity-0 group-hover/timeline-row:opacity-100',
|
|
228
|
+
className
|
|
229
|
+
)}
|
|
230
|
+
>
|
|
200
231
|
{label}
|
|
201
232
|
</span>
|
|
202
233
|
);
|
|
@@ -258,27 +289,50 @@ function LeadInConnector({
|
|
|
258
289
|
);
|
|
259
290
|
}
|
|
260
291
|
|
|
261
|
-
function SegmentBar({
|
|
292
|
+
function SegmentBar({
|
|
293
|
+
segments,
|
|
294
|
+
showLabels = true,
|
|
295
|
+
}: {
|
|
296
|
+
segments: VisibleSegment[];
|
|
297
|
+
/**
|
|
298
|
+
* When false, segment duration labels are suppressed. Used when a separate
|
|
299
|
+
* top layer (e.g. the resumption-marker duration overlay) renders the label
|
|
300
|
+
* above the markers instead, so we don't draw it twice.
|
|
301
|
+
*/
|
|
302
|
+
showLabels?: boolean;
|
|
303
|
+
}): ReactNode {
|
|
262
304
|
return (
|
|
263
305
|
<div className="relative h-6 w-full">
|
|
264
306
|
{segments.map((seg, i) => {
|
|
265
307
|
if (seg.status === 'queued') {
|
|
266
308
|
const leadInLabel = formatDurationPrecise(seg.fullDurationMs);
|
|
267
309
|
const showLeadInLabel =
|
|
310
|
+
showLabels &&
|
|
268
311
|
seg.pixelWidth >= Math.max(40, leadInLabel.length * 6 + 12);
|
|
312
|
+
const isFullWidthQueued = segments.length === 1;
|
|
269
313
|
return (
|
|
270
|
-
<
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
314
|
+
<Fragment key={i}>
|
|
315
|
+
<LeadInConnector
|
|
316
|
+
leftPct={seg.leftPct}
|
|
317
|
+
widthPct={seg.widthPct}
|
|
318
|
+
label={showLeadInLabel ? leadInLabel : null}
|
|
319
|
+
/>
|
|
320
|
+
{isFullWidthQueued ? (
|
|
321
|
+
<div
|
|
322
|
+
className="absolute top-1/2 h-4 w-px -translate-y-1/2 bg-gray-500"
|
|
323
|
+
style={{
|
|
324
|
+
left: `calc(${seg.leftPct + seg.widthPct}% - 1.5px)`,
|
|
325
|
+
}}
|
|
326
|
+
/>
|
|
327
|
+
) : null}
|
|
328
|
+
</Fragment>
|
|
276
329
|
);
|
|
277
330
|
}
|
|
278
331
|
|
|
279
332
|
const label = formatDurationPrecise(seg.fullDurationMs);
|
|
280
333
|
// Only render the label when there's enough room for it without clipping.
|
|
281
|
-
const showLabel =
|
|
334
|
+
const showLabel =
|
|
335
|
+
showLabels && seg.pixelWidth >= Math.max(40, label.length * 6 + 12);
|
|
282
336
|
|
|
283
337
|
return (
|
|
284
338
|
<div
|
|
@@ -305,80 +359,6 @@ function SegmentBar({ segments }: { segments: VisibleSegment[] }): ReactNode {
|
|
|
305
359
|
);
|
|
306
360
|
}
|
|
307
361
|
|
|
308
|
-
// ---------------------------------------------------------------------------
|
|
309
|
-
// Event markers (attr_set)
|
|
310
|
-
// ---------------------------------------------------------------------------
|
|
311
|
-
|
|
312
|
-
function formatMarkerTime(ms: number): string {
|
|
313
|
-
const date = new Date(ms);
|
|
314
|
-
return (
|
|
315
|
-
date.toLocaleTimeString('en-US', {
|
|
316
|
-
hour: '2-digit',
|
|
317
|
-
minute: '2-digit',
|
|
318
|
-
second: '2-digit',
|
|
319
|
-
hour12: false,
|
|
320
|
-
}) +
|
|
321
|
-
'.' +
|
|
322
|
-
date.getMilliseconds().toString().padStart(3, '0')
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Diamond markers for `attr_set` events on a span's timeline row. Attribute
|
|
328
|
-
* changes are point-in-time events with no duration, so they don't get a
|
|
329
|
-
* status segment — instead they render as small teal diamonds at the moment
|
|
330
|
-
* the attributes were written.
|
|
331
|
-
*/
|
|
332
|
-
function AttrSetMarkers({
|
|
333
|
-
span,
|
|
334
|
-
viewStart,
|
|
335
|
-
viewDuration,
|
|
336
|
-
}: {
|
|
337
|
-
span: Span;
|
|
338
|
-
viewStart: number;
|
|
339
|
-
viewDuration: number;
|
|
340
|
-
}): ReactNode {
|
|
341
|
-
const markers = useMemo(
|
|
342
|
-
() => span.events.filter((e) => e.name === 'attr_set'),
|
|
343
|
-
[span.events]
|
|
344
|
-
);
|
|
345
|
-
|
|
346
|
-
if (markers.length === 0 || viewDuration <= 0) return null;
|
|
347
|
-
|
|
348
|
-
return (
|
|
349
|
-
<>
|
|
350
|
-
{markers.map((event, index) => {
|
|
351
|
-
const timeMs = getHighResInMs(event.timestamp);
|
|
352
|
-
const frac = (timeMs - viewStart) / viewDuration;
|
|
353
|
-
if (frac < 0 || frac > 1) return null;
|
|
354
|
-
|
|
355
|
-
return (
|
|
356
|
-
<Tooltip key={`attr-set-${index}`}>
|
|
357
|
-
<TooltipTrigger asChild>
|
|
358
|
-
<div
|
|
359
|
-
aria-label="Attributes set"
|
|
360
|
-
className="absolute top-1/2 z-10 flex h-4 w-4 -translate-x-1/2 -translate-y-1/2 cursor-pointer items-center justify-center"
|
|
361
|
-
style={{ left: `${frac * 100}%` }}
|
|
362
|
-
>
|
|
363
|
-
<div
|
|
364
|
-
className="h-2 w-2 rotate-45 rounded-[1px] border"
|
|
365
|
-
style={{
|
|
366
|
-
backgroundColor: 'var(--ds-teal-400)',
|
|
367
|
-
borderColor: 'var(--ds-teal-900)',
|
|
368
|
-
}}
|
|
369
|
-
/>
|
|
370
|
-
</div>
|
|
371
|
-
</TooltipTrigger>
|
|
372
|
-
<TooltipContent>
|
|
373
|
-
Attributes set · {formatMarkerTime(timeMs)}
|
|
374
|
-
</TooltipContent>
|
|
375
|
-
</Tooltip>
|
|
376
|
-
);
|
|
377
|
-
})}
|
|
378
|
-
</>
|
|
379
|
-
);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
362
|
// ---------------------------------------------------------------------------
|
|
383
363
|
// TimelineBar
|
|
384
364
|
// ---------------------------------------------------------------------------
|
|
@@ -391,6 +371,7 @@ const TimelineBar = memo(function TimelineBar({
|
|
|
391
371
|
isSelected,
|
|
392
372
|
isDimmed,
|
|
393
373
|
onSelect,
|
|
374
|
+
onRevealTime,
|
|
394
375
|
}: {
|
|
395
376
|
span: Span;
|
|
396
377
|
viewStart: number;
|
|
@@ -399,6 +380,7 @@ const TimelineBar = memo(function TimelineBar({
|
|
|
399
380
|
isSelected: boolean;
|
|
400
381
|
isDimmed?: boolean;
|
|
401
382
|
onSelect: (spanId: string) => void;
|
|
383
|
+
onRevealTime?: (timeMs: number) => void;
|
|
402
384
|
}): ReactNode {
|
|
403
385
|
const startMs = getHighResInMs(span.startTime);
|
|
404
386
|
const endMs = getHighResInMs(span.endTime);
|
|
@@ -425,6 +407,41 @@ const TimelineBar = memo(function TimelineBar({
|
|
|
425
407
|
[geometry, baseSegments, startMs, totalDurationMs]
|
|
426
408
|
);
|
|
427
409
|
|
|
410
|
+
const baseMarkers = useMemo(() => computeSpanMarkers(span), [span]);
|
|
411
|
+
const markers = useMemo(
|
|
412
|
+
() =>
|
|
413
|
+
geometry.mode.kind === 'full'
|
|
414
|
+
? cullCollidingMarkers(
|
|
415
|
+
projectMarkers(
|
|
416
|
+
baseMarkers,
|
|
417
|
+
geometry.visibleStartMs,
|
|
418
|
+
geometry.visibleEndMs
|
|
419
|
+
),
|
|
420
|
+
geometry.visiblePixelWidth
|
|
421
|
+
)
|
|
422
|
+
: [],
|
|
423
|
+
[geometry, baseMarkers]
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
// Markers that fall outside the visible window (scrolled off while zoomed in)
|
|
427
|
+
// — surfaced as edge indicators so they aren't silently lost.
|
|
428
|
+
const offscreen = useMemo<OffscreenMarkers>(
|
|
429
|
+
() =>
|
|
430
|
+
geometry.mode.kind === 'full'
|
|
431
|
+
? computeOffscreenMarkers(
|
|
432
|
+
baseMarkers,
|
|
433
|
+
geometry.visibleStartMs,
|
|
434
|
+
geometry.visibleEndMs
|
|
435
|
+
)
|
|
436
|
+
: { left: null, right: null },
|
|
437
|
+
[geometry, baseMarkers]
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// Markers (visible or off-screen) move the duration label into the overlay,
|
|
441
|
+
// so the in-bar segment label is suppressed.
|
|
442
|
+
const hasMarkers =
|
|
443
|
+
markers.length > 0 || offscreen.left !== null || offscreen.right !== null;
|
|
444
|
+
|
|
428
445
|
const workflowStatus = (span.attributes.data as Record<string, unknown>)
|
|
429
446
|
?.status as string | undefined;
|
|
430
447
|
const isErrored = span.status.code === 2 || workflowStatus === 'failed';
|
|
@@ -458,7 +475,7 @@ const TimelineBar = memo(function TimelineBar({
|
|
|
458
475
|
>
|
|
459
476
|
<div className="absolute inset-y-0" style={TIMELINE_INSET_STYLE}>
|
|
460
477
|
<div
|
|
461
|
-
className="absolute top-1/2 h-6 -translate-y-1/2 rounded-[0.25rem]"
|
|
478
|
+
className="absolute top-1/2 h-6 -translate-y-1/2 overflow-hidden rounded-[0.25rem]"
|
|
462
479
|
style={getBarPositionStyle(geometry)}
|
|
463
480
|
>
|
|
464
481
|
{geometry.mode.kind === 'arrow' ? (
|
|
@@ -469,7 +486,7 @@ const TimelineBar = memo(function TimelineBar({
|
|
|
469
486
|
style={{ background: fallbackBg, borderColor: fallbackBorder }}
|
|
470
487
|
/>
|
|
471
488
|
) : segments.length > 0 ? (
|
|
472
|
-
<SegmentBar segments={segments} />
|
|
489
|
+
<SegmentBar segments={segments} showLabels={!hasMarkers} />
|
|
473
490
|
) : (
|
|
474
491
|
<PlainBar
|
|
475
492
|
bg={fallbackBg}
|
|
@@ -478,11 +495,38 @@ const TimelineBar = memo(function TimelineBar({
|
|
|
478
495
|
/>
|
|
479
496
|
)}
|
|
480
497
|
</div>
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
498
|
+
{/* Overlay (not clipped): ticks, off-screen indicators, then the duration label on top. */}
|
|
499
|
+
{hasMarkers ? (
|
|
500
|
+
<div
|
|
501
|
+
className="pointer-events-none absolute top-1/2 h-6 -translate-y-1/2"
|
|
502
|
+
style={getBarPositionStyle(geometry)}
|
|
503
|
+
>
|
|
504
|
+
{markers.length > 0 ? <MarkerLayer markers={markers} /> : null}
|
|
505
|
+
{offscreen.left ? (
|
|
506
|
+
<OffscreenMarkerIndicator
|
|
507
|
+
direction="left"
|
|
508
|
+
count={offscreen.left.count}
|
|
509
|
+
targetMs={offscreen.left.nearestMs}
|
|
510
|
+
onReveal={onRevealTime}
|
|
511
|
+
/>
|
|
512
|
+
) : null}
|
|
513
|
+
{offscreen.right ? (
|
|
514
|
+
<OffscreenMarkerIndicator
|
|
515
|
+
direction="right"
|
|
516
|
+
count={offscreen.right.count}
|
|
517
|
+
targetMs={offscreen.right.nearestMs}
|
|
518
|
+
onReveal={onRevealTime}
|
|
519
|
+
/>
|
|
520
|
+
) : null}
|
|
521
|
+
{showTotalLabel ? (
|
|
522
|
+
<DurationLabel
|
|
523
|
+
label={totalLabel}
|
|
524
|
+
// Shift clear of the left edge indicator so it isn't covered.
|
|
525
|
+
className={offscreen.left ? 'pl-10' : undefined}
|
|
526
|
+
/>
|
|
527
|
+
) : null}
|
|
528
|
+
</div>
|
|
529
|
+
) : null}
|
|
486
530
|
</div>
|
|
487
531
|
</div>
|
|
488
532
|
);
|
|
@@ -581,6 +625,7 @@ export function Timeline({
|
|
|
581
625
|
selectedId,
|
|
582
626
|
searchResult,
|
|
583
627
|
onSelect,
|
|
628
|
+
onRevealTime,
|
|
584
629
|
hoverFraction,
|
|
585
630
|
altHeld = false,
|
|
586
631
|
}: {
|
|
@@ -591,6 +636,7 @@ export function Timeline({
|
|
|
591
636
|
selectedId: string | null;
|
|
592
637
|
searchResult: SpanSearchResult;
|
|
593
638
|
onSelect: (spanId: string) => void;
|
|
639
|
+
onRevealTime?: (timeMs: number) => void;
|
|
594
640
|
hoverFraction?: number | null;
|
|
595
641
|
altHeld?: boolean;
|
|
596
642
|
}): ReactNode {
|
|
@@ -663,6 +709,7 @@ export function Timeline({
|
|
|
663
709
|
isSelected={selectedId === span.spanId}
|
|
664
710
|
isDimmed={isSpanDimmedBySearch(span.spanId, searchResult)}
|
|
665
711
|
onSelect={onSelect}
|
|
712
|
+
onRevealTime={onRevealTime}
|
|
666
713
|
/>
|
|
667
714
|
))}
|
|
668
715
|
</div>
|
|
@@ -91,3 +91,50 @@ export function useRowWindow(
|
|
|
91
91
|
|
|
92
92
|
/** Row height (px) shared by the events list and timeline rows (`h-10`). */
|
|
93
93
|
export const ROW_HEIGHT_PX = 40;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Scroll a windowed row into view by index.
|
|
97
|
+
*
|
|
98
|
+
* The list is virtualized (fixed `rowHeight` rows), so an off-screen row has no
|
|
99
|
+
* DOM node to `scrollIntoView` — its target offset is computed from its index
|
|
100
|
+
* instead. Walks up from `listEl` to the shared scrollable ancestor (the same
|
|
101
|
+
* one `useRowWindow` measures against), only scrolls when the row sits outside
|
|
102
|
+
* the visible area, leaves a one-row `margin` of breathing room past it, and
|
|
103
|
+
* clamps the result to `[0, scrollHeight - clientHeight]`.
|
|
104
|
+
*/
|
|
105
|
+
export function scrollRowIntoView(
|
|
106
|
+
listEl: HTMLElement | null,
|
|
107
|
+
index: number,
|
|
108
|
+
rowHeight: number,
|
|
109
|
+
opts?: { margin?: number; behavior?: ScrollBehavior }
|
|
110
|
+
): void {
|
|
111
|
+
const scroller = listEl ? getScrollParent(listEl) : null;
|
|
112
|
+
if (!listEl || !scroller) return;
|
|
113
|
+
|
|
114
|
+
const margin = opts?.margin ?? rowHeight;
|
|
115
|
+
|
|
116
|
+
// Offset of the list's top within the scroll container's content.
|
|
117
|
+
const listOffset =
|
|
118
|
+
listEl.getBoundingClientRect().top -
|
|
119
|
+
scroller.getBoundingClientRect().top +
|
|
120
|
+
scroller.scrollTop;
|
|
121
|
+
|
|
122
|
+
const rowTop = listOffset + index * rowHeight;
|
|
123
|
+
const rowBottom = rowTop + rowHeight;
|
|
124
|
+
const viewTop = scroller.scrollTop;
|
|
125
|
+
const viewBottom = viewTop + scroller.clientHeight;
|
|
126
|
+
|
|
127
|
+
let top: number | null = null;
|
|
128
|
+
if (rowTop < viewTop) {
|
|
129
|
+
top = rowTop - margin;
|
|
130
|
+
} else if (rowBottom > viewBottom) {
|
|
131
|
+
top = rowBottom + margin - scroller.clientHeight;
|
|
132
|
+
}
|
|
133
|
+
if (top === null) return;
|
|
134
|
+
|
|
135
|
+
const max = scroller.scrollHeight - scroller.clientHeight;
|
|
136
|
+
scroller.scrollTo({
|
|
137
|
+
top: Math.max(0, Math.min(top, max)),
|
|
138
|
+
behavior: opts?.behavior,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
TooltipTrigger,
|
|
40
40
|
} from '../ui/tooltip';
|
|
41
41
|
import EventList from './components/event-list';
|
|
42
|
+
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
|
|
42
43
|
import { SplitPane } from './components/split-pane';
|
|
43
44
|
import {
|
|
44
45
|
TIMELINE_PADDING_PX,
|
|
@@ -59,6 +60,8 @@ interface NewTraceViewerProps {
|
|
|
59
60
|
|
|
60
61
|
const MIN_VIEWPORT_MS = 0.001;
|
|
61
62
|
|
|
63
|
+
const ZOOM_DEBOUNCE_MS = 150;
|
|
64
|
+
|
|
62
65
|
interface Viewport {
|
|
63
66
|
start: number;
|
|
64
67
|
end: number;
|
|
@@ -197,6 +200,7 @@ function NewTraceViewerContent({
|
|
|
197
200
|
|
|
198
201
|
const sidebar = useSidebarData();
|
|
199
202
|
const selectedSpan = useSelectedSpanInfo();
|
|
203
|
+
const reducedMotion = useReducedMotion();
|
|
200
204
|
|
|
201
205
|
const [searchQuery, setSearchQuery] = useState('');
|
|
202
206
|
const deferredSearchQuery = useDeferredValue(searchQuery);
|
|
@@ -249,6 +253,12 @@ function NewTraceViewerContent({
|
|
|
249
253
|
|
|
250
254
|
const viewDuration = viewport.end - viewport.start;
|
|
251
255
|
|
|
256
|
+
// Keep a ref to the live viewport so the reveal callback can read the current
|
|
257
|
+
// zoom without being recreated on every pan (which would bust TimelineBar's
|
|
258
|
+
// memo and re-render every row each animation frame).
|
|
259
|
+
const viewportRef = useRef(viewport);
|
|
260
|
+
viewportRef.current = viewport;
|
|
261
|
+
|
|
252
262
|
const timeMarkers = useMemo(
|
|
253
263
|
() => computeTimeMarkers(viewDuration, viewport.start - root.startTime),
|
|
254
264
|
[viewDuration, viewport.start, root.startTime]
|
|
@@ -258,6 +268,29 @@ function NewTraceViewerContent({
|
|
|
258
268
|
animateTo({ start: root.startTime, end: root.startTime + root.duration });
|
|
259
269
|
}, [animateTo, root.startTime, root.duration]);
|
|
260
270
|
|
|
271
|
+
// Pan (keeping the current zoom) so `timeMs` is centered in view — used by the
|
|
272
|
+
// off-screen marker indicators to scroll their marker into view.
|
|
273
|
+
const handleRevealTime = useCallback(
|
|
274
|
+
(timeMs: number) => {
|
|
275
|
+
const rootS = root.startTime;
|
|
276
|
+
const rootE = root.startTime + root.duration;
|
|
277
|
+
const { start, end } = viewportRef.current;
|
|
278
|
+
const duration = end - start;
|
|
279
|
+
let newStart = timeMs - duration / 2;
|
|
280
|
+
let newEnd = timeMs + duration / 2;
|
|
281
|
+
if (newStart < rootS) {
|
|
282
|
+
newStart = rootS;
|
|
283
|
+
newEnd = rootS + duration;
|
|
284
|
+
}
|
|
285
|
+
if (newEnd > rootE) {
|
|
286
|
+
newEnd = rootE;
|
|
287
|
+
newStart = Math.max(rootS, rootE - duration);
|
|
288
|
+
}
|
|
289
|
+
animateTo({ start: newStart, end: newEnd });
|
|
290
|
+
},
|
|
291
|
+
[animateTo, root.startTime, root.duration]
|
|
292
|
+
);
|
|
293
|
+
|
|
261
294
|
const ZOOM_FACTOR = 0.5;
|
|
262
295
|
|
|
263
296
|
const zoomBy = useCallback(
|
|
@@ -294,14 +327,8 @@ function NewTraceViewerContent({
|
|
|
294
327
|
const zoomIn = useCallback(() => zoomBy(ZOOM_FACTOR), [zoomBy]);
|
|
295
328
|
const zoomOut = useCallback(() => zoomBy(1 / ZOOM_FACTOR), [zoomBy]);
|
|
296
329
|
|
|
297
|
-
const
|
|
330
|
+
const focusViewportOnSpan = useCallback(
|
|
298
331
|
(spanId: string) => {
|
|
299
|
-
if (spanId === activeSpanId) {
|
|
300
|
-
clearActiveSpan();
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
setActiveSpan(spanId);
|
|
304
|
-
|
|
305
332
|
const span = trace.spans.find((s) => s.spanId === spanId);
|
|
306
333
|
if (!span) return;
|
|
307
334
|
|
|
@@ -341,17 +368,74 @@ function NewTraceViewerContent({
|
|
|
341
368
|
|
|
342
369
|
animateTo({ start: newStart, end: newEnd });
|
|
343
370
|
},
|
|
371
|
+
[animateTo, trace.spans, root.startTime, root.duration]
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
// Bring a row into view when keyboard/button navigation lands on a span that
|
|
375
|
+
// sits outside the shared scroll container's visible area. The list is
|
|
376
|
+
// windowed, so an off-screen row has no DOM node to `scrollIntoView` —
|
|
377
|
+
// `scrollRowIntoView` computes the target offset from the span's index.
|
|
378
|
+
const scrollSpanIntoView = useCallback(
|
|
379
|
+
(spanId: string) => {
|
|
380
|
+
const index = trace.spans.findIndex((s) => s.spanId === spanId);
|
|
381
|
+
if (index === -1) return;
|
|
382
|
+
|
|
383
|
+
const list =
|
|
384
|
+
scrollContainerRef.current?.querySelector<HTMLElement>('#event-list') ??
|
|
385
|
+
null;
|
|
386
|
+
scrollRowIntoView(list, index, ROW_HEIGHT_PX, {
|
|
387
|
+
behavior: reducedMotion ? 'auto' : 'smooth',
|
|
388
|
+
});
|
|
389
|
+
},
|
|
390
|
+
[trace.spans, reducedMotion]
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const zoomTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
394
|
+
const cancelPendingZoom = useCallback(() => {
|
|
395
|
+
if (zoomTimerRef.current !== null) {
|
|
396
|
+
clearTimeout(zoomTimerRef.current);
|
|
397
|
+
zoomTimerRef.current = null;
|
|
398
|
+
}
|
|
399
|
+
}, []);
|
|
400
|
+
useEffect(() => cancelPendingZoom, [cancelPendingZoom]);
|
|
401
|
+
|
|
402
|
+
const handleClearActiveSpan = useCallback(() => {
|
|
403
|
+
cancelPendingZoom();
|
|
404
|
+
clearActiveSpan();
|
|
405
|
+
}, [cancelPendingZoom, clearActiveSpan]);
|
|
406
|
+
|
|
407
|
+
const handleSelectSpan = useCallback(
|
|
408
|
+
(spanId: string) => {
|
|
409
|
+
cancelPendingZoom();
|
|
410
|
+
if (spanId === activeSpanId) {
|
|
411
|
+
clearActiveSpan();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
setActiveSpan(spanId);
|
|
415
|
+
focusViewportOnSpan(spanId);
|
|
416
|
+
},
|
|
344
417
|
[
|
|
345
|
-
|
|
346
|
-
setActiveSpan,
|
|
347
|
-
clearActiveSpan,
|
|
418
|
+
cancelPendingZoom,
|
|
348
419
|
activeSpanId,
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
420
|
+
clearActiveSpan,
|
|
421
|
+
setActiveSpan,
|
|
422
|
+
focusViewportOnSpan,
|
|
352
423
|
]
|
|
353
424
|
);
|
|
354
425
|
|
|
426
|
+
const navigateToSpan = useCallback(
|
|
427
|
+
(spanId: string) => {
|
|
428
|
+
setActiveSpan(spanId);
|
|
429
|
+
scrollSpanIntoView(spanId);
|
|
430
|
+
cancelPendingZoom();
|
|
431
|
+
zoomTimerRef.current = setTimeout(() => {
|
|
432
|
+
zoomTimerRef.current = null;
|
|
433
|
+
focusViewportOnSpan(spanId);
|
|
434
|
+
}, ZOOM_DEBOUNCE_MS);
|
|
435
|
+
},
|
|
436
|
+
[setActiveSpan, scrollSpanIntoView, cancelPendingZoom, focusViewportOnSpan]
|
|
437
|
+
);
|
|
438
|
+
|
|
355
439
|
const [altHeld, setAltHeld] = useState(false);
|
|
356
440
|
|
|
357
441
|
useEffect(() => {
|
|
@@ -368,13 +452,13 @@ function NewTraceViewerContent({
|
|
|
368
452
|
e.key === 'k' ? prevSpanIdRef.current : nextSpanIdRef.current;
|
|
369
453
|
if (targetId) {
|
|
370
454
|
e.preventDefault();
|
|
371
|
-
|
|
455
|
+
navigateToSpanRef.current(targetId);
|
|
372
456
|
}
|
|
373
457
|
};
|
|
374
458
|
|
|
375
459
|
const onKeyDown = (e: KeyboardEvent): void => {
|
|
376
460
|
if (e.key === 'Escape') {
|
|
377
|
-
|
|
461
|
+
handleClearActiveSpan();
|
|
378
462
|
} else if (e.key === 'Alt') {
|
|
379
463
|
e.preventDefault();
|
|
380
464
|
setAltHeld(true);
|
|
@@ -395,7 +479,7 @@ function NewTraceViewerContent({
|
|
|
395
479
|
window.removeEventListener('keyup', onKeyUp);
|
|
396
480
|
window.removeEventListener('blur', onBlur);
|
|
397
481
|
};
|
|
398
|
-
}, [
|
|
482
|
+
}, [handleClearActiveSpan]);
|
|
399
483
|
|
|
400
484
|
const timelineRef = useRef<HTMLDivElement>(null);
|
|
401
485
|
const [hoverFraction, setHoverFraction] = useState<number | null>(null);
|
|
@@ -546,19 +630,19 @@ function NewTraceViewerContent({
|
|
|
546
630
|
}, [activeSpanId, trace.spans]);
|
|
547
631
|
|
|
548
632
|
const handleSelectPrevSpan = useCallback(() => {
|
|
549
|
-
if (prevSpanId)
|
|
550
|
-
}, [prevSpanId,
|
|
633
|
+
if (prevSpanId) navigateToSpan(prevSpanId);
|
|
634
|
+
}, [prevSpanId, navigateToSpan]);
|
|
551
635
|
|
|
552
636
|
const handleSelectNextSpan = useCallback(() => {
|
|
553
|
-
if (nextSpanId)
|
|
554
|
-
}, [nextSpanId,
|
|
637
|
+
if (nextSpanId) navigateToSpan(nextSpanId);
|
|
638
|
+
}, [nextSpanId, navigateToSpan]);
|
|
555
639
|
|
|
556
640
|
const prevSpanIdRef = useRef(prevSpanId);
|
|
557
641
|
const nextSpanIdRef = useRef(nextSpanId);
|
|
558
|
-
const
|
|
642
|
+
const navigateToSpanRef = useRef(navigateToSpan);
|
|
559
643
|
prevSpanIdRef.current = prevSpanId;
|
|
560
644
|
nextSpanIdRef.current = nextSpanId;
|
|
561
|
-
|
|
645
|
+
navigateToSpanRef.current = navigateToSpan;
|
|
562
646
|
|
|
563
647
|
return (
|
|
564
648
|
<div
|
|
@@ -642,6 +726,7 @@ function NewTraceViewerContent({
|
|
|
642
726
|
selectedId={activeSpanId}
|
|
643
727
|
searchResult={searchResult}
|
|
644
728
|
onSelect={handleSelectSpan}
|
|
729
|
+
onRevealTime={handleRevealTime}
|
|
645
730
|
hoverFraction={hoverFraction}
|
|
646
731
|
altHeld={altHeld}
|
|
647
732
|
/>
|
|
@@ -724,7 +809,7 @@ function NewTraceViewerContent({
|
|
|
724
809
|
<IconButton
|
|
725
810
|
aria-label="Close span details"
|
|
726
811
|
aria-keyshortcuts="Escape"
|
|
727
|
-
onClick={
|
|
812
|
+
onClick={handleClearActiveSpan}
|
|
728
813
|
>
|
|
729
814
|
<X className="w-4 h-4" />
|
|
730
815
|
</IconButton>
|