@workflow/web-shared 5.0.0-beta.27 → 5.0.0-beta.29
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/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +13 -1
- package/dist/components/new-trace-viewer/components/detail-panel.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/detail-panel.js +2 -2
- package/dist/components/new-trace-viewer/components/event-list.d.ts +1 -1
- package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/event-list.js +4 -6
- package/dist/components/new-trace-viewer/components/minimap.d.ts +14 -0
- package/dist/components/new-trace-viewer/components/minimap.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/minimap.js +337 -0
- package/dist/components/new-trace-viewer/components/split-pane.d.ts +0 -2
- package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/split-pane.js +9 -75
- package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/timeline.js +5 -7
- package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +6 -2
- package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.js +48 -96
- package/dist/components/new-trace-viewer/utils.d.ts +21 -0
- package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/utils.js +44 -3
- package/dist/components/sidebar/attribute-panel.d.ts +4 -1
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +32 -24
- package/dist/components/sidebar/attributes-block.d.ts +1 -0
- package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
- package/dist/components/sidebar/attributes-block.js +6 -5
- package/dist/components/sidebar/entity-detail-panel.d.ts +5 -1
- package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.js +18 -3
- package/dist/components/sidebar/sidebar-data-context.d.ts +4 -0
- package/dist/components/sidebar/sidebar-data-context.d.ts.map +1 -1
- package/dist/components/sidebar/sidebar-data-context.js +1 -1
- package/dist/lib/utils.d.ts +8 -5
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +13 -16
- package/package.json +5 -5
- package/src/components/event-list-view.tsx +21 -0
- package/src/components/new-trace-viewer/components/detail-panel.tsx +1 -0
- package/src/components/new-trace-viewer/components/event-list.tsx +4 -6
- package/src/components/new-trace-viewer/components/minimap.tsx +559 -0
- package/src/components/new-trace-viewer/components/split-pane.tsx +39 -95
- package/src/components/new-trace-viewer/components/timeline.tsx +3 -5
- package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +17 -0
- package/src/components/new-trace-viewer/trace-viewer.tsx +79 -117
- package/src/components/new-trace-viewer/utils.test.ts +65 -0
- package/src/components/new-trace-viewer/utils.ts +76 -2
- package/src/components/sidebar/attribute-panel.tsx +77 -22
- package/src/components/sidebar/attributes-block.tsx +43 -10
- package/src/components/sidebar/entity-detail-panel.tsx +22 -0
- package/src/components/sidebar/sidebar-data-context.tsx +4 -0
- package/src/lib/utils.test.ts +33 -0
- package/src/lib/utils.ts +12 -16
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
formatDuration,
|
|
3
|
+
formatDurationPrecise,
|
|
4
|
+
getHighResInMs,
|
|
5
|
+
} from '../trace-viewer/util/timing';
|
|
2
6
|
import type { Span, SpanEvent } from './types';
|
|
3
7
|
|
|
4
8
|
// ---------------------------------------------------------------------------
|
|
@@ -37,6 +41,64 @@ export function getSpanDurationMs(span: Span): number {
|
|
|
37
41
|
);
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
export function isSpanErrored(span: Span): boolean {
|
|
45
|
+
const workflowStatus = (span.attributes.data as Record<string, unknown>)
|
|
46
|
+
?.status as string | undefined;
|
|
47
|
+
return span.status.code === 2 || workflowStatus === 'failed';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Viewport
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export interface ViewportRange {
|
|
55
|
+
start: number;
|
|
56
|
+
end: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Clamp a candidate viewport to the root extent. The requested duration is
|
|
61
|
+
* preserved where possible (clamped to [minDurationMs, root duration]), then
|
|
62
|
+
* the window is shifted back inside the root bounds.
|
|
63
|
+
*/
|
|
64
|
+
export function clampViewportToRoot(
|
|
65
|
+
next: ViewportRange,
|
|
66
|
+
rootStart: number,
|
|
67
|
+
rootEnd: number,
|
|
68
|
+
minDurationMs: number
|
|
69
|
+
): ViewportRange {
|
|
70
|
+
const rootDuration = Math.max(rootEnd - rootStart, minDurationMs);
|
|
71
|
+
const duration = Math.min(
|
|
72
|
+
rootDuration,
|
|
73
|
+
Math.max(minDurationMs, next.end - next.start)
|
|
74
|
+
);
|
|
75
|
+
const maxStart = rootEnd - duration;
|
|
76
|
+
const start = Math.min(Math.max(next.start, rootStart), maxStart);
|
|
77
|
+
return { start, end: start + duration };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Wheel gestures — shared between the timeline and the minimap
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/** Convert a wheel delta to pixel units (line-mode deltas arrive in lines). */
|
|
85
|
+
export function wheelDeltaToPixels(delta: number, deltaMode: number): number {
|
|
86
|
+
return deltaMode === 1 ? delta * 16 : delta;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Exponential zoom factor for a wheel gesture. Coarse mouse-wheel steps are
|
|
91
|
+
* damped harder than trackpad pinches so both feel similar.
|
|
92
|
+
*/
|
|
93
|
+
export function wheelZoomScaleFactor(event: {
|
|
94
|
+
deltaY: number;
|
|
95
|
+
deltaMode: number;
|
|
96
|
+
}): number {
|
|
97
|
+
const dy = wheelDeltaToPixels(event.deltaY, event.deltaMode);
|
|
98
|
+
const isMouseWheel = event.deltaMode === 1 || Math.abs(event.deltaY) >= 50;
|
|
99
|
+
return 2 ** (dy / (isMouseWheel ? 200 : 60));
|
|
100
|
+
}
|
|
101
|
+
|
|
40
102
|
// ---------------------------------------------------------------------------
|
|
41
103
|
// Time markers
|
|
42
104
|
// ---------------------------------------------------------------------------
|
|
@@ -56,6 +118,8 @@ const NICE_INTERVALS = [
|
|
|
56
118
|
|
|
57
119
|
const MAX_MARKERS = 8;
|
|
58
120
|
|
|
121
|
+
const MS_IN_SECOND = 1000;
|
|
122
|
+
|
|
59
123
|
function pickInterval(viewDuration: number, maxTicks: number): number {
|
|
60
124
|
for (const interval of NICE_INTERVALS) {
|
|
61
125
|
if (viewDuration / interval <= maxTicks) return interval;
|
|
@@ -72,6 +136,13 @@ export function computeTimeMarkers(
|
|
|
72
136
|
const maxTicks = 6;
|
|
73
137
|
const interval = pickInterval(viewDuration, maxTicks);
|
|
74
138
|
|
|
139
|
+
// Sub-second steps need fractional labels, or ticks past 1s collide as
|
|
140
|
+
// duplicate whole seconds ("…1s, 2s, 2s, 3s"). Scale decimals to the step.
|
|
141
|
+
const fractionDigits =
|
|
142
|
+
interval >= MS_IN_SECOND
|
|
143
|
+
? 0
|
|
144
|
+
: Math.ceil(-Math.log10(interval / MS_IN_SECOND));
|
|
145
|
+
|
|
75
146
|
const firstTick = Math.ceil(offset / interval) * interval;
|
|
76
147
|
const markers: TimeMarker[] = [];
|
|
77
148
|
|
|
@@ -80,7 +151,10 @@ export function computeTimeMarkers(
|
|
|
80
151
|
if (position < -0.01 || position > 1.01) continue;
|
|
81
152
|
markers.push({
|
|
82
153
|
position: Math.min(Math.max(position, 0), 1),
|
|
83
|
-
label:
|
|
154
|
+
label:
|
|
155
|
+
fractionDigits === 0
|
|
156
|
+
? formatDuration(Math.abs(t), true)
|
|
157
|
+
: formatDurationPrecise(Math.abs(t), fractionDigits),
|
|
84
158
|
value: t,
|
|
85
159
|
});
|
|
86
160
|
if (markers.length >= MAX_MARKERS) break;
|
|
@@ -5,7 +5,7 @@ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
|
|
|
5
5
|
import type { ModelMessage } from 'ai';
|
|
6
6
|
import { format } from 'date-fns';
|
|
7
7
|
import type { KeyboardEvent, ReactNode } from 'react';
|
|
8
|
-
import { useCallback, useContext, useMemo, useState } from 'react';
|
|
8
|
+
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
9
9
|
import { isEncryptedMarker, isExpiredMarker } from '../../lib/hydration';
|
|
10
10
|
import { extractConversation, isDoStreamStep } from '../../lib/utils';
|
|
11
11
|
import {
|
|
@@ -148,16 +148,24 @@ const conversationTabs = [
|
|
|
148
148
|
function ConversationWithTabs({
|
|
149
149
|
conversation,
|
|
150
150
|
args,
|
|
151
|
+
defaultOpen,
|
|
152
|
+
onOpenChange,
|
|
151
153
|
}: {
|
|
152
154
|
conversation: ModelMessage[];
|
|
153
155
|
args: unknown[];
|
|
156
|
+
defaultOpen?: boolean;
|
|
157
|
+
onOpenChange?: (open: boolean) => void;
|
|
154
158
|
}) {
|
|
155
159
|
const [activeTab, setActiveTab] = useState<'conversation' | 'json'>(
|
|
156
160
|
'conversation'
|
|
157
161
|
);
|
|
158
162
|
|
|
159
163
|
return (
|
|
160
|
-
<Collapsible
|
|
164
|
+
<Collapsible
|
|
165
|
+
label="Input"
|
|
166
|
+
defaultOpen={defaultOpen}
|
|
167
|
+
onOpenChange={onOpenChange}
|
|
168
|
+
>
|
|
161
169
|
<TabbedContainer
|
|
162
170
|
tabs={conversationTabs}
|
|
163
171
|
activeTab={activeTab}
|
|
@@ -382,6 +390,8 @@ const timestampWithTooltipOrNull = (value: unknown): ReactNode | null => {
|
|
|
382
390
|
|
|
383
391
|
interface DisplayContext {
|
|
384
392
|
stepName?: string;
|
|
393
|
+
sectionOpen?: boolean;
|
|
394
|
+
onSectionOpenChange?: (open: boolean) => void;
|
|
385
395
|
}
|
|
386
396
|
|
|
387
397
|
const attributeToDisplayFn: Record<
|
|
@@ -457,7 +467,11 @@ const attributeToDisplayFn: Record<
|
|
|
457
467
|
input: (value: unknown, context?: DisplayContext) => {
|
|
458
468
|
if (isEncryptedMarker(value)) {
|
|
459
469
|
return (
|
|
460
|
-
<Collapsible
|
|
470
|
+
<Collapsible
|
|
471
|
+
label="Input"
|
|
472
|
+
defaultOpen={context?.sectionOpen}
|
|
473
|
+
onOpenChange={context?.onSectionOpenChange}
|
|
474
|
+
>
|
|
461
475
|
<EncryptedFieldBlock />
|
|
462
476
|
</Collapsible>
|
|
463
477
|
);
|
|
@@ -480,7 +494,12 @@ const attributeToDisplayFn: Record<
|
|
|
480
494
|
if (conversation && conversation.length > 0) {
|
|
481
495
|
return (
|
|
482
496
|
<>
|
|
483
|
-
<ConversationWithTabs
|
|
497
|
+
<ConversationWithTabs
|
|
498
|
+
conversation={conversation}
|
|
499
|
+
args={args}
|
|
500
|
+
defaultOpen={context?.sectionOpen}
|
|
501
|
+
onOpenChange={context?.onSectionOpenChange}
|
|
502
|
+
/>
|
|
484
503
|
{hasClosureVars && (
|
|
485
504
|
<Collapsible label="Closure Variables">
|
|
486
505
|
{JsonBlock(closureVars)}
|
|
@@ -503,7 +522,11 @@ const attributeToDisplayFn: Record<
|
|
|
503
522
|
|
|
504
523
|
return (
|
|
505
524
|
<>
|
|
506
|
-
<Collapsible
|
|
525
|
+
<Collapsible
|
|
526
|
+
label="Input"
|
|
527
|
+
defaultOpen={context?.sectionOpen}
|
|
528
|
+
onOpenChange={context?.onSectionOpenChange}
|
|
529
|
+
>
|
|
507
530
|
{Array.isArray(args)
|
|
508
531
|
? args.map((v, i) => (
|
|
509
532
|
<div className="mt-2 first:mt-0" key={i}>
|
|
@@ -529,7 +552,11 @@ const attributeToDisplayFn: Record<
|
|
|
529
552
|
return <Collapsible label="Input (no data)" disabled />;
|
|
530
553
|
}
|
|
531
554
|
return (
|
|
532
|
-
<Collapsible
|
|
555
|
+
<Collapsible
|
|
556
|
+
label="Input"
|
|
557
|
+
defaultOpen={context?.sectionOpen}
|
|
558
|
+
onOpenChange={context?.onSectionOpenChange}
|
|
559
|
+
>
|
|
533
560
|
{Array.isArray(value)
|
|
534
561
|
? value.map((v, i) => (
|
|
535
562
|
<div className="mt-2 first:mt-0" key={i}>
|
|
@@ -540,17 +567,29 @@ const attributeToDisplayFn: Record<
|
|
|
540
567
|
</Collapsible>
|
|
541
568
|
);
|
|
542
569
|
},
|
|
543
|
-
output: (value: unknown) => {
|
|
570
|
+
output: (value: unknown, context?: DisplayContext) => {
|
|
544
571
|
if (isEncryptedMarker(value)) {
|
|
545
572
|
return (
|
|
546
|
-
<Collapsible
|
|
573
|
+
<Collapsible
|
|
574
|
+
label="Output"
|
|
575
|
+
defaultOpen={context?.sectionOpen}
|
|
576
|
+
onOpenChange={context?.onSectionOpenChange}
|
|
577
|
+
>
|
|
547
578
|
<EncryptedFieldBlock />
|
|
548
579
|
</Collapsible>
|
|
549
580
|
);
|
|
550
581
|
}
|
|
551
582
|
if (!hasDisplayContent(value)) return null;
|
|
552
583
|
if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
|
|
553
|
-
return
|
|
584
|
+
return (
|
|
585
|
+
<Collapsible
|
|
586
|
+
label="Output"
|
|
587
|
+
defaultOpen={context?.sectionOpen}
|
|
588
|
+
onOpenChange={context?.onSectionOpenChange}
|
|
589
|
+
>
|
|
590
|
+
{JsonBlock(value)}
|
|
591
|
+
</Collapsible>
|
|
592
|
+
);
|
|
554
593
|
},
|
|
555
594
|
error: (value: unknown) => {
|
|
556
595
|
if (isEncryptedMarker(value)) {
|
|
@@ -642,6 +681,12 @@ const copyableBasicAttributes = new Set<AttributeKey>([
|
|
|
642
681
|
'moduleSpecifier',
|
|
643
682
|
]);
|
|
644
683
|
|
|
684
|
+
const loadingSectionLabels: Partial<Record<AttributeKey, string>> = {
|
|
685
|
+
input: 'Input',
|
|
686
|
+
output: 'Output',
|
|
687
|
+
eventData: 'Event Data',
|
|
688
|
+
};
|
|
689
|
+
|
|
645
690
|
export const AttributeBlock = ({
|
|
646
691
|
attribute,
|
|
647
692
|
value,
|
|
@@ -656,20 +701,19 @@ export const AttributeBlock = ({
|
|
|
656
701
|
context?: DisplayContext;
|
|
657
702
|
}) => {
|
|
658
703
|
const decryptCtx = useContext(DecryptClickContext);
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
attribute === 'eventData'
|
|
666
|
-
? 'Event Data'
|
|
667
|
-
: attribute === 'output'
|
|
668
|
-
? 'Output'
|
|
669
|
-
: 'Input';
|
|
704
|
+
const sectionOpenRef = useRef(false);
|
|
705
|
+
const handleSectionOpenChange = useCallback((open: boolean) => {
|
|
706
|
+
sectionOpenRef.current = open;
|
|
707
|
+
}, []);
|
|
708
|
+
const label = loadingSectionLabels[attribute as AttributeKey];
|
|
709
|
+
if (isLoading && label && !hasDisplayContent(value)) {
|
|
670
710
|
if (decryptCtx?.hasEncryptedData) {
|
|
671
711
|
return (
|
|
672
|
-
<Collapsible
|
|
712
|
+
<Collapsible
|
|
713
|
+
label={label}
|
|
714
|
+
defaultOpen={attribute === 'eventData' || sectionOpenRef.current}
|
|
715
|
+
onOpenChange={handleSectionOpenChange}
|
|
716
|
+
>
|
|
673
717
|
<EncryptedFieldBlock />
|
|
674
718
|
</Collapsible>
|
|
675
719
|
);
|
|
@@ -682,7 +726,11 @@ export const AttributeBlock = ({
|
|
|
682
726
|
if (!displayFn) {
|
|
683
727
|
return null;
|
|
684
728
|
}
|
|
685
|
-
const displayValue = displayFn(value,
|
|
729
|
+
const displayValue = displayFn(value, {
|
|
730
|
+
...context,
|
|
731
|
+
sectionOpen: sectionOpenRef.current,
|
|
732
|
+
onSectionOpenChange: handleSectionOpenChange,
|
|
733
|
+
});
|
|
686
734
|
if (!displayValue) {
|
|
687
735
|
return null;
|
|
688
736
|
}
|
|
@@ -732,6 +780,7 @@ export const AttributeBlock = ({
|
|
|
732
780
|
export const AttributePanel = ({
|
|
733
781
|
data,
|
|
734
782
|
moduleSpecifier,
|
|
783
|
+
moduleSourceUrl,
|
|
735
784
|
isLoading,
|
|
736
785
|
error,
|
|
737
786
|
expiredAt,
|
|
@@ -743,6 +792,7 @@ export const AttributePanel = ({
|
|
|
743
792
|
}: {
|
|
744
793
|
data: Record<string, unknown>;
|
|
745
794
|
moduleSpecifier?: string;
|
|
795
|
+
moduleSourceUrl?: string;
|
|
746
796
|
isLoading?: boolean;
|
|
747
797
|
error?: Error;
|
|
748
798
|
expiredAt?: string | Date;
|
|
@@ -877,6 +927,11 @@ export const AttributePanel = ({
|
|
|
877
927
|
copyText={
|
|
878
928
|
isCopyableBasicAttribute ? displayValue : undefined
|
|
879
929
|
}
|
|
930
|
+
href={
|
|
931
|
+
attribute === 'moduleSpecifier'
|
|
932
|
+
? moduleSourceUrl
|
|
933
|
+
: undefined
|
|
934
|
+
}
|
|
880
935
|
/>
|
|
881
936
|
);
|
|
882
937
|
})}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
RESERVED_ATTRIBUTE_KEY_PREFIX,
|
|
6
6
|
} from '@workflow/world';
|
|
7
7
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
8
|
+
import { ArrowUpRight } from 'lucide-react';
|
|
8
9
|
import type { ReactNode } from 'react';
|
|
9
10
|
import { cn } from '../../lib/cn';
|
|
10
11
|
import { CopyButton } from '../new-trace-viewer/components/copy-button';
|
|
@@ -21,7 +22,7 @@ function isReservedAttributeKey(key: string): boolean {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const rowValueVariants = cva(
|
|
24
|
-
'
|
|
25
|
+
'min-w-0 flex-1 truncate text-right text-copy-13 text-gray-1000',
|
|
25
26
|
{
|
|
26
27
|
variants: {
|
|
27
28
|
variant: {
|
|
@@ -36,7 +37,7 @@ const rowValueVariants = cva(
|
|
|
36
37
|
);
|
|
37
38
|
|
|
38
39
|
const rowCopyValueVariants = cva(
|
|
39
|
-
'flex min-w-0
|
|
40
|
+
'flex min-w-0 flex-1 items-center justify-end gap-1 text-copy-13 text-gray-1000',
|
|
40
41
|
{
|
|
41
42
|
variants: {
|
|
42
43
|
variant: {
|
|
@@ -54,6 +55,7 @@ type DetailKeyValueRowProps = {
|
|
|
54
55
|
label: string;
|
|
55
56
|
value?: ReactNode;
|
|
56
57
|
copyText?: string;
|
|
58
|
+
href?: string;
|
|
57
59
|
removed?: boolean;
|
|
58
60
|
};
|
|
59
61
|
|
|
@@ -61,6 +63,7 @@ function DetailKeyValueRowBase({
|
|
|
61
63
|
label,
|
|
62
64
|
value,
|
|
63
65
|
copyText,
|
|
66
|
+
href,
|
|
64
67
|
removed = false,
|
|
65
68
|
variant,
|
|
66
69
|
}: DetailKeyValueRowProps & VariantProps<typeof rowValueVariants>) {
|
|
@@ -77,13 +80,32 @@ function DetailKeyValueRowBase({
|
|
|
77
80
|
</span>
|
|
78
81
|
) : copyText ? (
|
|
79
82
|
<div className={cn(rowCopyValueVariants({ variant }))} title={copyText}>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
{href ? (
|
|
84
|
+
<a
|
|
85
|
+
href={href}
|
|
86
|
+
target="_blank"
|
|
87
|
+
rel="noreferrer"
|
|
88
|
+
className={cn(
|
|
89
|
+
rowValueVariants({ variant }),
|
|
90
|
+
'flex min-w-0 items-center gap-0.5 hover:underline'
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
<MiddleTruncate
|
|
94
|
+
value={copyText}
|
|
95
|
+
className="min-w-0 text-right"
|
|
96
|
+
style={{ gridTemplateColumns: 'minmax(0, 1fr)' }}
|
|
97
|
+
/>
|
|
98
|
+
<ArrowUpRight aria-hidden className="h-3 w-3 shrink-0" />
|
|
99
|
+
</a>
|
|
100
|
+
) : (
|
|
101
|
+
<MiddleTruncate
|
|
102
|
+
value={copyText}
|
|
103
|
+
className={cn(
|
|
104
|
+
rowValueVariants({ variant, className: 'text-right' })
|
|
105
|
+
)}
|
|
106
|
+
style={{ gridTemplateColumns: 'minmax(0, 1fr)' }}
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
87
109
|
<CopyButton
|
|
88
110
|
copyText={copyText}
|
|
89
111
|
ariaLabel={`Copy ${label}`}
|
|
@@ -92,7 +114,18 @@ function DetailKeyValueRowBase({
|
|
|
92
114
|
</div>
|
|
93
115
|
) : (
|
|
94
116
|
<span className={cn(rowValueVariants({ variant }))} title={stringValue}>
|
|
95
|
-
{
|
|
117
|
+
{href ? (
|
|
118
|
+
<a
|
|
119
|
+
href={href}
|
|
120
|
+
target="_blank"
|
|
121
|
+
rel="noreferrer"
|
|
122
|
+
className="hover:underline"
|
|
123
|
+
>
|
|
124
|
+
{value}
|
|
125
|
+
</a>
|
|
126
|
+
) : (
|
|
127
|
+
value
|
|
128
|
+
)}
|
|
96
129
|
</span>
|
|
97
130
|
)}
|
|
98
131
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
|
|
3
4
|
import type { Event, Hook, WorkflowRun } from '@workflow/world';
|
|
4
5
|
import clsx from 'clsx';
|
|
5
6
|
import { Send, Zap } from 'lucide-react';
|
|
@@ -62,6 +63,7 @@ export function EntityDetailPanel({
|
|
|
62
63
|
isDecrypting = false,
|
|
63
64
|
selectedSpan,
|
|
64
65
|
showSeparateEventOccurrenceTimestamps = false,
|
|
66
|
+
getModuleSourceUrl,
|
|
65
67
|
}: {
|
|
66
68
|
run: WorkflowRun;
|
|
67
69
|
/** Callback when a stream reference is clicked */
|
|
@@ -95,6 +97,10 @@ export function EntityDetailPanel({
|
|
|
95
97
|
selectedSpan: SelectedSpanInfo | null;
|
|
96
98
|
/** Show occurredAt separately instead of folding it into the Created timestamp. */
|
|
97
99
|
showSeparateEventOccurrenceTimestamps?: boolean;
|
|
100
|
+
getModuleSourceUrl?: (info: {
|
|
101
|
+
moduleSpecifier: string;
|
|
102
|
+
deploymentId: string;
|
|
103
|
+
}) => string | undefined;
|
|
98
104
|
}): React.JSX.Element | null {
|
|
99
105
|
const toast = useToast();
|
|
100
106
|
const [stoppingSleep, setStoppingSleep] = useState(false);
|
|
@@ -277,6 +283,21 @@ export function EntityDetailPanel({
|
|
|
277
283
|
return undefined;
|
|
278
284
|
}, [displayData, run.workflowName]);
|
|
279
285
|
|
|
286
|
+
const moduleSourceUrl = useMemo(() => {
|
|
287
|
+
if (!getModuleSourceUrl || !moduleSpecifier) return undefined;
|
|
288
|
+
const parsed =
|
|
289
|
+
parseStepName(moduleSpecifier) ?? parseWorkflowName(moduleSpecifier);
|
|
290
|
+
if (!parsed) return undefined;
|
|
291
|
+
const dataDeploymentId = displayData.deploymentId;
|
|
292
|
+
return getModuleSourceUrl({
|
|
293
|
+
moduleSpecifier: parsed.moduleSpecifier,
|
|
294
|
+
deploymentId:
|
|
295
|
+
typeof dataDeploymentId === 'string'
|
|
296
|
+
? dataDeploymentId
|
|
297
|
+
: run.deploymentId,
|
|
298
|
+
});
|
|
299
|
+
}, [getModuleSourceUrl, moduleSpecifier, displayData, run.deploymentId]);
|
|
300
|
+
|
|
280
301
|
if (!selectedSpan || !resource || !resourceId) {
|
|
281
302
|
return null;
|
|
282
303
|
}
|
|
@@ -360,6 +381,7 @@ export function EntityDetailPanel({
|
|
|
360
381
|
<AttributePanel
|
|
361
382
|
data={displayData}
|
|
362
383
|
moduleSpecifier={moduleSpecifier}
|
|
384
|
+
moduleSourceUrl={moduleSourceUrl}
|
|
363
385
|
expiredAt={run.expiredAt}
|
|
364
386
|
isLoading={loading}
|
|
365
387
|
error={error ?? undefined}
|
|
@@ -29,6 +29,10 @@ export interface SidebarDataContextValue {
|
|
|
29
29
|
hasEncryptedData?: boolean;
|
|
30
30
|
/** Show occurredAt separately instead of folding it into the Created timestamp. */
|
|
31
31
|
showSeparateEventOccurrenceTimestamps?: boolean;
|
|
32
|
+
getModuleSourceUrl?: (info: {
|
|
33
|
+
moduleSpecifier: string;
|
|
34
|
+
deploymentId: string;
|
|
35
|
+
}) => string | undefined;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatDurationPrecise } from './utils';
|
|
3
|
+
|
|
4
|
+
describe('formatDurationPrecise', () => {
|
|
5
|
+
it('shows whole milliseconds below 1s', () => {
|
|
6
|
+
expect(formatDurationPrecise(0)).toBe('0s');
|
|
7
|
+
expect(formatDurationPrecise(626)).toBe('626ms');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('keeps sub-second precision instead of rounding up to a whole second', () => {
|
|
11
|
+
// The bug this guards: 1626ms must not read as "2s".
|
|
12
|
+
expect(formatDurationPrecise(1626)).toBe('1.63s');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('trims trailing zeros so whole/half seconds read cleanly', () => {
|
|
16
|
+
expect(formatDurationPrecise(2000)).toBe('2s');
|
|
17
|
+
expect(formatDurationPrecise(1500)).toBe('1.5s');
|
|
18
|
+
expect(formatDurationPrecise(1600)).toBe('1.6s');
|
|
19
|
+
expect(formatDurationPrecise(45_000)).toBe('45s');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('honors a custom fraction-digit count (used by the timeline ruler)', () => {
|
|
23
|
+
// Fewer digits also coarsen the rounding: 1 digit rounds to 100ms.
|
|
24
|
+
expect(formatDurationPrecise(1500, 1)).toBe('1.5s');
|
|
25
|
+
expect(formatDurationPrecise(1620, 1)).toBe('1.6s');
|
|
26
|
+
expect(formatDurationPrecise(2000, 1)).toBe('2s');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('decomposes durations of a minute or more', () => {
|
|
30
|
+
expect(formatDurationPrecise(65_000)).toBe('1m 5s');
|
|
31
|
+
expect(formatDurationPrecise(63_450)).toBe('1m 3.45s');
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/lib/utils.ts
CHANGED
|
@@ -74,25 +74,20 @@ export function formatDuration(ms: number, compact = false): string {
|
|
|
74
74
|
return parts.join(' ');
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// Locale-aware formatter that always renders exactly two fraction digits.
|
|
78
|
-
// Used for the seconds component of precise durations so values are never
|
|
79
|
-
// snapped to a whole second (and thousands separators stay correct).
|
|
80
|
-
const preciseSecondsFormatter = new Intl.NumberFormat(undefined, {
|
|
81
|
-
minimumFractionDigits: 2,
|
|
82
|
-
maximumFractionDigits: 2,
|
|
83
|
-
});
|
|
84
|
-
|
|
85
77
|
/**
|
|
86
78
|
* Formats a duration in milliseconds without snapping to whole seconds.
|
|
87
79
|
*
|
|
88
|
-
* Unlike {@link formatDuration}, this
|
|
89
|
-
*
|
|
80
|
+
* Unlike {@link formatDuration}, this keeps sub-second detail, so 1626ms
|
|
81
|
+
* renders as "1.63s" rather than "2s". `fractionDigits` sets both the number of
|
|
82
|
+
* decimals on the seconds component (trailing zeros trimmed, so 2000ms is "2s"
|
|
83
|
+
* and 1500ms is "1.5s") and the rounding granularity — the default of 2 rounds
|
|
84
|
+
* to 10ms; the timeline ruler passes fewer digits to match its tick step.
|
|
90
85
|
*
|
|
91
86
|
* - < 1s: shows whole milliseconds (e.g. "626ms")
|
|
92
|
-
* - 1s – 1m: shows seconds with
|
|
93
|
-
* - >= 1m: decomposes into d/h/m with
|
|
87
|
+
* - 1s – 1m: shows seconds with trimmed decimals (e.g. "1.63s", "1.5s", "45s")
|
|
88
|
+
* - >= 1m: decomposes into d/h/m with trimmed-decimal seconds (e.g. "1m 13.45s")
|
|
94
89
|
*/
|
|
95
|
-
export function formatDurationPrecise(ms: number): string {
|
|
90
|
+
export function formatDurationPrecise(ms: number, fractionDigits = 2): string {
|
|
96
91
|
if (ms === 0) {
|
|
97
92
|
return '0s';
|
|
98
93
|
}
|
|
@@ -104,10 +99,11 @@ export function formatDurationPrecise(ms: number): string {
|
|
|
104
99
|
}
|
|
105
100
|
}
|
|
106
101
|
|
|
107
|
-
const
|
|
102
|
+
const granularityMs = MS_IN_SECOND / 10 ** fractionDigits;
|
|
103
|
+
const normalizedMs = Math.round(ms / granularityMs) * granularityMs;
|
|
108
104
|
|
|
109
105
|
if (normalizedMs < MS_IN_MINUTE) {
|
|
110
|
-
return `${
|
|
106
|
+
return `${Number((normalizedMs / MS_IN_SECOND).toFixed(fractionDigits))}s`;
|
|
111
107
|
}
|
|
112
108
|
|
|
113
109
|
const days = Math.floor(normalizedMs / MS_IN_DAY);
|
|
@@ -126,7 +122,7 @@ export function formatDurationPrecise(ms: number): string {
|
|
|
126
122
|
if (minutes > 0) {
|
|
127
123
|
parts.push(`${minutes}m`);
|
|
128
124
|
}
|
|
129
|
-
parts.push(`${
|
|
125
|
+
parts.push(`${Number(seconds.toFixed(fractionDigits))}s`);
|
|
130
126
|
|
|
131
127
|
return parts.join(' ');
|
|
132
128
|
}
|