@workflow/web-shared 5.0.0-beta.34 → 5.0.0-beta.36
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/event-list.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/event-list.js +10 -7
- package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/split-pane.js +5 -5
- package/dist/components/new-trace-viewer/components/timeline.d.ts +8 -2
- package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/timeline.js +95 -26
- package/dist/components/new-trace-viewer/components/trace-shortcut-helper.js +2 -2
- package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.js +14 -11
- package/dist/components/new-trace-viewer/utils.d.ts +29 -0
- package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/utils.js +49 -5
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +2 -1
- package/dist/components/sidebar/attributes-block.js +2 -2
- package/package.json +5 -5
- package/src/components/new-trace-viewer/components/event-list.tsx +21 -11
- package/src/components/new-trace-viewer/components/split-pane.tsx +14 -4
- package/src/components/new-trace-viewer/components/timeline.tsx +158 -47
- package/src/components/new-trace-viewer/components/trace-shortcut-helper.tsx +1 -1
- package/src/components/new-trace-viewer/trace-viewer.tsx +44 -38
- package/src/components/new-trace-viewer/utils.test.ts +211 -0
- package/src/components/new-trace-viewer/utils.ts +82 -4
- package/src/components/sidebar/attribute-panel.tsx +1 -0
- package/src/components/sidebar/attributes-block.tsx +1 -1
|
@@ -22,10 +22,12 @@ import type {
|
|
|
22
22
|
OffscreenMarkers,
|
|
23
23
|
Segment,
|
|
24
24
|
SegmentStatus,
|
|
25
|
+
SpanDelta,
|
|
25
26
|
TimeMarker,
|
|
26
27
|
} from '../utils';
|
|
27
28
|
import {
|
|
28
29
|
computeOffscreenMarkers,
|
|
30
|
+
computeSpanDelta,
|
|
29
31
|
computeSpanGaps,
|
|
30
32
|
computeSpanMarkers,
|
|
31
33
|
computeSpanSegments,
|
|
@@ -223,6 +225,11 @@ function projectSegments(
|
|
|
223
225
|
// Small render helpers
|
|
224
226
|
// ---------------------------------------------------------------------------
|
|
225
227
|
|
|
228
|
+
/** Estimated rendered width of a 10px-mono duration label (6px/glyph + padding). */
|
|
229
|
+
function estimateLabelWidthPx(label: string): number {
|
|
230
|
+
return label.length * 6 + 12;
|
|
231
|
+
}
|
|
232
|
+
|
|
226
233
|
function DurationLabel({
|
|
227
234
|
label,
|
|
228
235
|
className,
|
|
@@ -317,7 +324,7 @@ function SegmentBar({
|
|
|
317
324
|
const leadInLabel = formatDurationPrecise(seg.fullDurationMs);
|
|
318
325
|
const showLeadInLabel =
|
|
319
326
|
showLabels &&
|
|
320
|
-
seg.pixelWidth >= Math.max(40, leadInLabel
|
|
327
|
+
seg.pixelWidth >= Math.max(40, estimateLabelWidthPx(leadInLabel));
|
|
321
328
|
const isFullWidthQueued = segments.length === 1;
|
|
322
329
|
return (
|
|
323
330
|
<Fragment key={i}>
|
|
@@ -341,7 +348,8 @@ function SegmentBar({
|
|
|
341
348
|
const label = formatDurationPrecise(seg.fullDurationMs);
|
|
342
349
|
// Only render the label when there's enough room for it without clipping.
|
|
343
350
|
const showLabel =
|
|
344
|
-
showLabels &&
|
|
351
|
+
showLabels &&
|
|
352
|
+
seg.pixelWidth >= Math.max(40, estimateLabelWidthPx(label));
|
|
345
353
|
|
|
346
354
|
return (
|
|
347
355
|
<div
|
|
@@ -462,7 +470,8 @@ const TimelineBar = memo(function TimelineBar({
|
|
|
462
470
|
|
|
463
471
|
const totalLabel = formatDurationPrecise(totalDurationMs);
|
|
464
472
|
const showTotalLabel =
|
|
465
|
-
geometry.visiblePixelWidth >=
|
|
473
|
+
geometry.visiblePixelWidth >=
|
|
474
|
+
Math.max(40, estimateLabelWidthPx(totalLabel));
|
|
466
475
|
|
|
467
476
|
const handleClick = useCallback(() => {
|
|
468
477
|
onSelect(span.spanId);
|
|
@@ -542,45 +551,99 @@ const TimelineBar = memo(function TimelineBar({
|
|
|
542
551
|
export { TimelineBar };
|
|
543
552
|
|
|
544
553
|
// ---------------------------------------------------------------------------
|
|
545
|
-
//
|
|
554
|
+
// DeltaMeasureLine (Alt-key measurement overlays: the selected ↔ hovered
|
|
555
|
+
// measurement, and the ambient consecutive-gap indicators shown without a
|
|
556
|
+
// selection)
|
|
546
557
|
// ---------------------------------------------------------------------------
|
|
547
558
|
|
|
548
|
-
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
// inside 40px, so bars start ~8px from the top of the row).
|
|
552
|
-
const DELTA_ROW_OFFSET_PX = 8;
|
|
559
|
+
// Horizontal distance between the anchor bar's measured edge and the vertical
|
|
560
|
+
// guide — also the width of the connector stub bridging the two.
|
|
561
|
+
const MEASURE_GUIDE_OUTSET_PX = 4;
|
|
553
562
|
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
563
|
+
const DeltaMeasureLine = memo(function DeltaMeasureLine({
|
|
564
|
+
delta,
|
|
565
|
+
anchorRowIndex,
|
|
566
|
+
hoveredRowIndex,
|
|
567
|
+
timelineWidth,
|
|
559
568
|
}: {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
569
|
+
delta: SpanDelta;
|
|
570
|
+
anchorRowIndex: number;
|
|
571
|
+
hoveredRowIndex: number;
|
|
572
|
+
timelineWidth: number;
|
|
564
573
|
}) {
|
|
565
|
-
|
|
574
|
+
// Both ends of the measurement align with the vertical middle of the bars
|
|
575
|
+
// (bars are centered in their rows, so bar center == row center).
|
|
576
|
+
const anchorCenterY = anchorRowIndex * ROW_HEIGHT_PX + ROW_HEIGHT_PX / 2;
|
|
577
|
+
const lineY = hoveredRowIndex * ROW_HEIGHT_PX + ROW_HEIGHT_PX / 2;
|
|
578
|
+
|
|
579
|
+
// Guide connecting the middle of the anchor bar down/up to the line, so
|
|
580
|
+
// the measurement's origin stays legible when the rows are far apart.
|
|
581
|
+
// It sits just outside the anchor bar's measured edge (so it doesn't blend
|
|
582
|
+
// into the bar's border), joined to the bar by a short horizontal stub. The
|
|
583
|
+
// line runs from the elbow corner (the guide's x) to the hovered span's
|
|
584
|
+
// measured edge — pulled short of the edge arrow when the hovered span is
|
|
585
|
+
// fully off-screen.
|
|
586
|
+
const guideTop = Math.min(anchorCenterY, lineY);
|
|
587
|
+
const guideBottom = Math.max(anchorCenterY, lineY);
|
|
588
|
+
const anchorX = delta.anchorFrac * timelineWidth;
|
|
589
|
+
const guideX =
|
|
590
|
+
anchorX +
|
|
591
|
+
(delta.anchorEdge === 'end'
|
|
592
|
+
? MEASURE_GUIDE_OUTSET_PX
|
|
593
|
+
: -MEASURE_GUIDE_OUTSET_PX);
|
|
594
|
+
const arrowClearance =
|
|
595
|
+
delta.hoveredOffscreen === 'right'
|
|
596
|
+
? -(TINY_BAR_BOX_SIZE_PX + 4)
|
|
597
|
+
: delta.hoveredOffscreen === 'left'
|
|
598
|
+
? TINY_BAR_BOX_SIZE_PX + 4
|
|
599
|
+
: 0;
|
|
600
|
+
const hoveredX = delta.hoveredFrac * timelineWidth + arrowClearance;
|
|
601
|
+
const startX = Math.min(guideX, hoveredX);
|
|
602
|
+
const endX = Math.max(guideX, hoveredX);
|
|
603
|
+
|
|
604
|
+
const label = formatDurationPrecise(delta.deltaMs);
|
|
605
|
+
const labelWidthPx = estimateLabelWidthPx(label);
|
|
606
|
+
// Center the label on the line; when the line is too short, place it beside
|
|
607
|
+
// the right endpoint, flipping left near the viewport's right edge.
|
|
608
|
+
const labelPlacement =
|
|
609
|
+
endX - startX >= labelWidthPx
|
|
610
|
+
? { left: (startX + endX) / 2, translate: '-translate-x-1/2' }
|
|
611
|
+
: endX + 4 + labelWidthPx <= timelineWidth
|
|
612
|
+
? { left: endX + 4, translate: '' }
|
|
613
|
+
: { left: startX - 4, translate: '-translate-x-full' };
|
|
566
614
|
|
|
567
615
|
return (
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
<div
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
616
|
+
<>
|
|
617
|
+
<div
|
|
618
|
+
className="absolute h-px bg-amber-800"
|
|
619
|
+
style={{
|
|
620
|
+
left: Math.min(anchorX, guideX),
|
|
621
|
+
width: MEASURE_GUIDE_OUTSET_PX,
|
|
622
|
+
top: anchorCenterY,
|
|
623
|
+
}}
|
|
624
|
+
/>
|
|
625
|
+
<div
|
|
626
|
+
className="absolute w-px bg-amber-800"
|
|
627
|
+
style={{
|
|
628
|
+
left: guideX,
|
|
629
|
+
top: guideTop,
|
|
630
|
+
height: guideBottom - guideTop,
|
|
631
|
+
}}
|
|
632
|
+
/>
|
|
633
|
+
<div
|
|
634
|
+
className="absolute h-px bg-amber-800"
|
|
635
|
+
style={{ left: startX, width: Math.max(endX - startX, 1), top: lineY }}
|
|
636
|
+
/>
|
|
637
|
+
<span
|
|
638
|
+
className={cn(
|
|
639
|
+
'absolute -translate-y-1/2 font-mono text-[10px] font-medium leading-none tabular-nums whitespace-nowrap rounded-xs bg-background-100 px-1 py-0.5 text-amber-800',
|
|
640
|
+
labelPlacement.translate
|
|
641
|
+
)}
|
|
642
|
+
style={{ left: labelPlacement.left, top: lineY }}
|
|
643
|
+
>
|
|
581
644
|
{label}
|
|
582
645
|
</span>
|
|
583
|
-
|
|
646
|
+
</>
|
|
584
647
|
);
|
|
585
648
|
});
|
|
586
649
|
|
|
@@ -624,6 +687,13 @@ export function TimelineHeader({
|
|
|
624
687
|
// Timeline
|
|
625
688
|
// ---------------------------------------------------------------------------
|
|
626
689
|
|
|
690
|
+
export interface TimelineHover {
|
|
691
|
+
/** Pointer x as a fraction of the timeline's content width, in [0, 1]. */
|
|
692
|
+
fraction: number;
|
|
693
|
+
/** Row index under the pointer; may be past the last row — not validated. */
|
|
694
|
+
rowIndex: number;
|
|
695
|
+
}
|
|
696
|
+
|
|
627
697
|
export function Timeline({
|
|
628
698
|
spans,
|
|
629
699
|
viewStart,
|
|
@@ -633,7 +703,7 @@ export function Timeline({
|
|
|
633
703
|
searchResult,
|
|
634
704
|
onSelect,
|
|
635
705
|
onRevealTime,
|
|
636
|
-
|
|
706
|
+
hover,
|
|
637
707
|
altHeld = false,
|
|
638
708
|
}: {
|
|
639
709
|
spans: Span[];
|
|
@@ -644,7 +714,7 @@ export function Timeline({
|
|
|
644
714
|
searchResult: SpanSearchResult;
|
|
645
715
|
onSelect: (spanId: string) => void;
|
|
646
716
|
onRevealTime?: (timeMs: number) => void;
|
|
647
|
-
|
|
717
|
+
hover?: TimelineHover | null;
|
|
648
718
|
altHeld?: boolean;
|
|
649
719
|
}): ReactNode {
|
|
650
720
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -667,11 +737,45 @@ export function Timeline({
|
|
|
667
737
|
return () => ro.disconnect();
|
|
668
738
|
}, []);
|
|
669
739
|
|
|
670
|
-
|
|
671
|
-
|
|
740
|
+
// Each consecutive gap renders as a measurement from the earlier span's end
|
|
741
|
+
// edge to the next span's start, in the same visual language as the
|
|
742
|
+
// selected ↔ hovered measurement.
|
|
743
|
+
const gapMeasurements = useMemo(
|
|
744
|
+
() =>
|
|
745
|
+
computeSpanGaps(spans, viewStart, viewEnd).map((gap) => ({
|
|
746
|
+
delta: {
|
|
747
|
+
deltaMs: gap.gapMs,
|
|
748
|
+
anchorFrac: gap.leftFrac,
|
|
749
|
+
hoveredFrac: gap.rightFrac,
|
|
750
|
+
anchorEdge: 'end',
|
|
751
|
+
hoveredOffscreen: null,
|
|
752
|
+
} satisfies SpanDelta,
|
|
753
|
+
anchorRowIndex: gap.rowIndex,
|
|
754
|
+
hoveredRowIndex: gap.rowIndex + 1,
|
|
755
|
+
})),
|
|
672
756
|
[spans, viewStart, viewEnd]
|
|
673
757
|
);
|
|
674
758
|
|
|
759
|
+
// With a span selected, Alt+hover measures selected ↔ hovered instead of
|
|
760
|
+
// showing the all-sibling-gaps overlay.
|
|
761
|
+
const anchorIndex = useMemo(
|
|
762
|
+
() => (selectedId ? spans.findIndex((s) => s.spanId === selectedId) : -1),
|
|
763
|
+
[spans, selectedId]
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
const measurement = useMemo(() => {
|
|
767
|
+
if (!altHeld || hover == null || hover.rowIndex === anchorIndex) {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
const anchorSpan = spans[anchorIndex];
|
|
771
|
+
const hoveredSpan = spans[hover.rowIndex];
|
|
772
|
+
if (!anchorSpan || !hoveredSpan) return null;
|
|
773
|
+
const delta = computeSpanDelta(anchorSpan, hoveredSpan, viewStart, viewEnd);
|
|
774
|
+
return delta
|
|
775
|
+
? { delta, anchorRowIndex: anchorIndex, hoveredRowIndex: hover.rowIndex }
|
|
776
|
+
: null;
|
|
777
|
+
}, [altHeld, anchorIndex, hover, spans, viewStart, viewEnd]);
|
|
778
|
+
|
|
675
779
|
return (
|
|
676
780
|
<div
|
|
677
781
|
ref={containerRef}
|
|
@@ -694,14 +798,14 @@ export function Timeline({
|
|
|
694
798
|
) : null
|
|
695
799
|
)}
|
|
696
800
|
</div>
|
|
697
|
-
{
|
|
801
|
+
{hover != null && (
|
|
698
802
|
<div
|
|
699
803
|
className="absolute inset-y-0 pointer-events-none z-10"
|
|
700
804
|
style={TIMELINE_INSET_STYLE}
|
|
701
805
|
>
|
|
702
806
|
<div
|
|
703
807
|
className="absolute top-0 bottom-0 w-px bg-gray-alpha-500"
|
|
704
|
-
style={{ left: `${
|
|
808
|
+
style={{ left: `${hover.fraction * 100}%` }}
|
|
705
809
|
/>
|
|
706
810
|
</div>
|
|
707
811
|
)}
|
|
@@ -720,23 +824,30 @@ export function Timeline({
|
|
|
720
824
|
/>
|
|
721
825
|
))}
|
|
722
826
|
</div>
|
|
723
|
-
{altHeld && (
|
|
827
|
+
{altHeld && anchorIndex < 0 && (
|
|
724
828
|
<div
|
|
725
829
|
aria-hidden
|
|
726
830
|
className="absolute inset-y-0 pointer-events-none"
|
|
727
831
|
style={TIMELINE_INSET_STYLE}
|
|
728
832
|
>
|
|
729
|
-
{
|
|
730
|
-
<
|
|
731
|
-
key={gap.
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
label={formatDurationPrecise(gap.gapMs)}
|
|
735
|
-
rowIndex={gap.rowIndex}
|
|
833
|
+
{gapMeasurements.map((gap) => (
|
|
834
|
+
<DeltaMeasureLine
|
|
835
|
+
key={gap.anchorRowIndex}
|
|
836
|
+
{...gap}
|
|
837
|
+
timelineWidth={timelineWidth}
|
|
736
838
|
/>
|
|
737
839
|
))}
|
|
738
840
|
</div>
|
|
739
841
|
)}
|
|
842
|
+
{measurement && (
|
|
843
|
+
<div
|
|
844
|
+
aria-hidden
|
|
845
|
+
className="absolute inset-y-0 pointer-events-none z-20"
|
|
846
|
+
style={TIMELINE_INSET_STYLE}
|
|
847
|
+
>
|
|
848
|
+
<DeltaMeasureLine {...measurement} timelineWidth={timelineWidth} />
|
|
849
|
+
</div>
|
|
850
|
+
)}
|
|
740
851
|
</div>
|
|
741
852
|
);
|
|
742
853
|
}
|
|
@@ -86,7 +86,7 @@ export function TraceShortcutHelper({
|
|
|
86
86
|
};
|
|
87
87
|
|
|
88
88
|
return (
|
|
89
|
-
<div className="group
|
|
89
|
+
<div className="group pointer-events-auto hidden h-8 w-fit items-center gap-1 text-xs leading-none text-gray-900 @min-[480px]:flex">
|
|
90
90
|
<span
|
|
91
91
|
aria-live="polite"
|
|
92
92
|
aria-atomic="true"
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
TIMELINE_PADDING_PX,
|
|
29
29
|
Timeline,
|
|
30
30
|
TimelineHeader,
|
|
31
|
+
type TimelineHover,
|
|
31
32
|
} from './components/timeline';
|
|
32
33
|
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
|
|
33
34
|
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
|
|
@@ -400,14 +401,14 @@ function NewTraceViewerContent({
|
|
|
400
401
|
}, [handleClearActiveSpan]);
|
|
401
402
|
|
|
402
403
|
const timelineRef = useRef<HTMLDivElement>(null);
|
|
403
|
-
const [
|
|
404
|
+
const [hover, setHover] = useState<TimelineHover | null>(null);
|
|
404
405
|
|
|
405
406
|
const hoverInfo = useMemo(() => {
|
|
406
|
-
if (
|
|
407
|
-
const absTime = viewport.start +
|
|
407
|
+
if (hover == null) return null;
|
|
408
|
+
const absTime = viewport.start + hover.fraction * viewDuration;
|
|
408
409
|
const offset = absTime - root.startTime;
|
|
409
|
-
return { fraction:
|
|
410
|
-
}, [
|
|
410
|
+
return { fraction: hover.fraction, label: formatDurationPrecise(offset) };
|
|
411
|
+
}, [hover, viewport.start, viewDuration, root.startTime]);
|
|
411
412
|
|
|
412
413
|
const handleTimelineMouseMove = useCallback(
|
|
413
414
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
@@ -423,13 +424,16 @@ function NewTraceViewerContent({
|
|
|
423
424
|
(e.clientX - rect.left - TIMELINE_PADDING_PX) / contentWidth
|
|
424
425
|
)
|
|
425
426
|
);
|
|
426
|
-
|
|
427
|
+
setHover({
|
|
428
|
+
fraction,
|
|
429
|
+
rowIndex: Math.floor((e.clientY - rect.top) / ROW_HEIGHT_PX),
|
|
430
|
+
});
|
|
427
431
|
},
|
|
428
432
|
[]
|
|
429
433
|
);
|
|
430
434
|
|
|
431
435
|
const handleTimelineMouseLeave = useCallback(() => {
|
|
432
|
-
|
|
436
|
+
setHover(null);
|
|
433
437
|
}, []);
|
|
434
438
|
|
|
435
439
|
useEffect(() => {
|
|
@@ -495,7 +499,7 @@ function NewTraceViewerContent({
|
|
|
495
499
|
>
|
|
496
500
|
<div
|
|
497
501
|
id="trace-parent"
|
|
498
|
-
className="flex-1 min-w-0 grid grid-rows-[auto_1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
|
|
502
|
+
className="@container flex-1 min-w-0 grid grid-rows-[auto_1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
|
|
499
503
|
>
|
|
500
504
|
<Minimap
|
|
501
505
|
spans={trace.spans}
|
|
@@ -579,43 +583,45 @@ function NewTraceViewerContent({
|
|
|
579
583
|
searchResult={searchResult}
|
|
580
584
|
onSelect={handleSelectSpan}
|
|
581
585
|
onRevealTime={handleRevealTime}
|
|
582
|
-
|
|
586
|
+
hover={hover}
|
|
583
587
|
altHeld={altHeld}
|
|
584
588
|
/>
|
|
589
|
+
</div>
|
|
590
|
+
<>
|
|
585
591
|
<TraceShortcutHelper
|
|
586
592
|
hasMultipleSpans={trace.spans.length > 1}
|
|
587
593
|
reducedMotion={reducedMotion}
|
|
588
594
|
/>
|
|
589
|
-
|
|
595
|
+
<div className="pointer-events-auto flex items-center border border-gray-alpha-400 rounded-md bg-background-100 shadow-sm overflow-hidden divide-x divide-gray-alpha-400">
|
|
596
|
+
<IconButton
|
|
597
|
+
variant="muted"
|
|
598
|
+
size="small"
|
|
599
|
+
onClick={zoomOut}
|
|
600
|
+
disabled={isAtMinZoom}
|
|
601
|
+
aria-label="Zoom out"
|
|
602
|
+
>
|
|
603
|
+
<ZoomOut className="w-4 h-4" />
|
|
604
|
+
</IconButton>
|
|
605
|
+
<IconButton
|
|
606
|
+
variant="muted"
|
|
607
|
+
size="small"
|
|
608
|
+
onClick={resetZoom}
|
|
609
|
+
aria-label="Reset zoom"
|
|
610
|
+
>
|
|
611
|
+
<RotateCcw className="w-3.5 h-3.5" />
|
|
612
|
+
</IconButton>
|
|
613
|
+
<IconButton
|
|
614
|
+
variant="muted"
|
|
615
|
+
size="small"
|
|
616
|
+
onClick={zoomIn}
|
|
617
|
+
disabled={isAtMaxZoom}
|
|
618
|
+
aria-label="Zoom in"
|
|
619
|
+
>
|
|
620
|
+
<ZoomIn className="w-4 h-4" />
|
|
621
|
+
</IconButton>
|
|
622
|
+
</div>
|
|
623
|
+
</>
|
|
590
624
|
</SplitPane>
|
|
591
|
-
<div className="absolute right-3 bottom-3 z-[5] flex items-center border border-gray-alpha-400 rounded-md bg-background-100 shadow-sm overflow-hidden divide-x divide-gray-alpha-400">
|
|
592
|
-
<IconButton
|
|
593
|
-
variant="muted"
|
|
594
|
-
size="small"
|
|
595
|
-
onClick={zoomOut}
|
|
596
|
-
disabled={isAtMinZoom}
|
|
597
|
-
aria-label="Zoom out"
|
|
598
|
-
>
|
|
599
|
-
<ZoomOut className="w-4 h-4" />
|
|
600
|
-
</IconButton>
|
|
601
|
-
<IconButton
|
|
602
|
-
variant="muted"
|
|
603
|
-
size="small"
|
|
604
|
-
onClick={resetZoom}
|
|
605
|
-
aria-label="Reset zoom"
|
|
606
|
-
>
|
|
607
|
-
<RotateCcw className="w-3.5 h-3.5" />
|
|
608
|
-
</IconButton>
|
|
609
|
-
<IconButton
|
|
610
|
-
variant="muted"
|
|
611
|
-
size="small"
|
|
612
|
-
onClick={zoomIn}
|
|
613
|
-
disabled={isAtMaxZoom}
|
|
614
|
-
aria-label="Zoom in"
|
|
615
|
-
>
|
|
616
|
-
<ZoomIn className="w-4 h-4" />
|
|
617
|
-
</IconButton>
|
|
618
|
-
</div>
|
|
619
625
|
</div>
|
|
620
626
|
|
|
621
627
|
<TraceDetailPanel
|
|
@@ -3,9 +3,11 @@ import type { Span, SpanEvent } from './types';
|
|
|
3
3
|
import {
|
|
4
4
|
clampViewportToRoot,
|
|
5
5
|
computeOffscreenMarkers,
|
|
6
|
+
computeSpanDelta,
|
|
6
7
|
computeSpanMarkers,
|
|
7
8
|
computeSpanSegments,
|
|
8
9
|
computeTimeMarkers,
|
|
10
|
+
getResourceColor,
|
|
9
11
|
} from './utils';
|
|
10
12
|
|
|
11
13
|
/** Build a high-res timestamp tuple ([seconds, nanoseconds]) for a given ms. */
|
|
@@ -245,3 +247,212 @@ describe('computeTimeMarkers', () => {
|
|
|
245
247
|
]);
|
|
246
248
|
});
|
|
247
249
|
});
|
|
250
|
+
|
|
251
|
+
describe('getResourceColor', () => {
|
|
252
|
+
it('uses gray for hooks (passive spans), not amber', () => {
|
|
253
|
+
expect(getResourceColor('hook')).toEqual({
|
|
254
|
+
bg: 'var(--ds-gray-200)',
|
|
255
|
+
border: 'var(--ds-gray-500)',
|
|
256
|
+
errorBg: 'var(--ds-red-200)',
|
|
257
|
+
errorBorder: 'var(--ds-red-500)',
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('computeSpanDelta', () => {
|
|
263
|
+
function span(id: string, startMs: number, endMs: number): Span {
|
|
264
|
+
return {
|
|
265
|
+
name: id,
|
|
266
|
+
kind: 0,
|
|
267
|
+
resource: 'step',
|
|
268
|
+
library: { name: 'workflow' },
|
|
269
|
+
spanId: id,
|
|
270
|
+
status: { code: 1 },
|
|
271
|
+
traceFlags: 0,
|
|
272
|
+
attributes: {},
|
|
273
|
+
links: [],
|
|
274
|
+
events: [],
|
|
275
|
+
startTime: ts(startMs),
|
|
276
|
+
endTime: ts(endMs),
|
|
277
|
+
duration: ts(endMs - startMs),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
it('measures the gap between disjoint spans (anchor earlier)', () => {
|
|
282
|
+
const delta = computeSpanDelta(
|
|
283
|
+
span('a', 0, 100),
|
|
284
|
+
span('b', 300, 400),
|
|
285
|
+
0,
|
|
286
|
+
1000
|
|
287
|
+
);
|
|
288
|
+
expect(delta).toEqual({
|
|
289
|
+
deltaMs: 200,
|
|
290
|
+
anchorFrac: 0.1,
|
|
291
|
+
hoveredFrac: 0.3,
|
|
292
|
+
anchorEdge: 'end',
|
|
293
|
+
hoveredOffscreen: null,
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('is direction-agnostic: anchor later gives the same delta with edges swapped', () => {
|
|
298
|
+
const delta = computeSpanDelta(
|
|
299
|
+
span('b', 300, 400),
|
|
300
|
+
span('a', 0, 100),
|
|
301
|
+
0,
|
|
302
|
+
1000
|
|
303
|
+
);
|
|
304
|
+
expect(delta).toEqual({
|
|
305
|
+
deltaMs: 200,
|
|
306
|
+
anchorFrac: 0.3,
|
|
307
|
+
hoveredFrac: 0.1,
|
|
308
|
+
anchorEdge: 'start',
|
|
309
|
+
hoveredOffscreen: null,
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('measures start-to-start offset for overlapping spans', () => {
|
|
314
|
+
const delta = computeSpanDelta(
|
|
315
|
+
span('a', 0, 500),
|
|
316
|
+
span('b', 200, 800),
|
|
317
|
+
0,
|
|
318
|
+
1000
|
|
319
|
+
);
|
|
320
|
+
expect(delta).toEqual({
|
|
321
|
+
deltaMs: 200,
|
|
322
|
+
anchorFrac: 0,
|
|
323
|
+
hoveredFrac: 0.2,
|
|
324
|
+
anchorEdge: 'start',
|
|
325
|
+
hoveredOffscreen: null,
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('measures start-to-start offset for contained spans (step inside run)', () => {
|
|
330
|
+
const delta = computeSpanDelta(
|
|
331
|
+
span('run', 0, 1000),
|
|
332
|
+
span('step', 400, 600),
|
|
333
|
+
0,
|
|
334
|
+
1000
|
|
335
|
+
);
|
|
336
|
+
expect(delta).toEqual({
|
|
337
|
+
deltaMs: 400,
|
|
338
|
+
anchorFrac: 0,
|
|
339
|
+
hoveredFrac: 0.4,
|
|
340
|
+
anchorEdge: 'start',
|
|
341
|
+
hoveredOffscreen: null,
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('returns a zero delta for identical starts', () => {
|
|
346
|
+
const delta = computeSpanDelta(
|
|
347
|
+
span('a', 200, 500),
|
|
348
|
+
span('b', 200, 300),
|
|
349
|
+
0,
|
|
350
|
+
1000
|
|
351
|
+
);
|
|
352
|
+
expect(delta).toEqual({
|
|
353
|
+
deltaMs: 0,
|
|
354
|
+
anchorFrac: 0.2,
|
|
355
|
+
hoveredFrac: 0.2,
|
|
356
|
+
anchorEdge: 'start',
|
|
357
|
+
hoveredOffscreen: null,
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('keeps a zero delta sitting exactly on the viewport edge (root span anchor at default zoom)', () => {
|
|
362
|
+
const delta = computeSpanDelta(
|
|
363
|
+
span('run', 0, 1000),
|
|
364
|
+
span('step', 0, 400),
|
|
365
|
+
0,
|
|
366
|
+
1000
|
|
367
|
+
);
|
|
368
|
+
expect(delta).toEqual({
|
|
369
|
+
deltaMs: 0,
|
|
370
|
+
anchorFrac: 0,
|
|
371
|
+
hoveredFrac: 0,
|
|
372
|
+
anchorEdge: 'start',
|
|
373
|
+
hoveredOffscreen: null,
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('treats touching spans as a zero gap, not an overlap', () => {
|
|
378
|
+
const delta = computeSpanDelta(
|
|
379
|
+
span('a', 0, 300),
|
|
380
|
+
span('b', 300, 400),
|
|
381
|
+
0,
|
|
382
|
+
1000
|
|
383
|
+
);
|
|
384
|
+
expect(delta).toEqual({
|
|
385
|
+
deltaMs: 0,
|
|
386
|
+
anchorFrac: 0.3,
|
|
387
|
+
hoveredFrac: 0.3,
|
|
388
|
+
anchorEdge: 'end',
|
|
389
|
+
hoveredOffscreen: null,
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('handles zero-duration spans as points', () => {
|
|
394
|
+
const delta = computeSpanDelta(
|
|
395
|
+
span('a', 100, 100),
|
|
396
|
+
span('b', 400, 400),
|
|
397
|
+
0,
|
|
398
|
+
1000
|
|
399
|
+
);
|
|
400
|
+
expect(delta).toEqual({
|
|
401
|
+
deltaMs: 300,
|
|
402
|
+
anchorFrac: 0.1,
|
|
403
|
+
hoveredFrac: 0.4,
|
|
404
|
+
anchorEdge: 'end',
|
|
405
|
+
hoveredOffscreen: null,
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('clamps edges to the viewport but keeps the true delta', () => {
|
|
410
|
+
const delta = computeSpanDelta(
|
|
411
|
+
span('a', 0, 100),
|
|
412
|
+
span('b', 900, 950),
|
|
413
|
+
500,
|
|
414
|
+
800
|
|
415
|
+
);
|
|
416
|
+
expect(delta).toEqual({
|
|
417
|
+
deltaMs: 800,
|
|
418
|
+
anchorFrac: 0,
|
|
419
|
+
hoveredFrac: 1,
|
|
420
|
+
anchorEdge: 'end',
|
|
421
|
+
hoveredOffscreen: 'right',
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('flags a hovered span lying fully left of the viewport', () => {
|
|
426
|
+
const delta = computeSpanDelta(
|
|
427
|
+
span('anchor', 600, 700),
|
|
428
|
+
span('hovered', 0, 100),
|
|
429
|
+
200,
|
|
430
|
+
1200
|
|
431
|
+
);
|
|
432
|
+
expect(delta).toEqual({
|
|
433
|
+
deltaMs: 500,
|
|
434
|
+
anchorFrac: 0.4,
|
|
435
|
+
hoveredFrac: 0,
|
|
436
|
+
anchorEdge: 'start',
|
|
437
|
+
hoveredOffscreen: 'left',
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('returns null when the measurement is entirely left of the viewport', () => {
|
|
442
|
+
expect(
|
|
443
|
+
computeSpanDelta(span('a', 0, 100), span('b', 200, 300), 500, 1000)
|
|
444
|
+
).toBeNull();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('returns null when the measurement is entirely right of the viewport', () => {
|
|
448
|
+
expect(
|
|
449
|
+
computeSpanDelta(span('a', 600, 700), span('b', 900, 950), 0, 500)
|
|
450
|
+
).toBeNull();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('returns null for an empty viewport range', () => {
|
|
454
|
+
expect(
|
|
455
|
+
computeSpanDelta(span('a', 0, 100), span('b', 200, 300), 500, 500)
|
|
456
|
+
).toBeNull();
|
|
457
|
+
});
|
|
458
|
+
});
|