@workflow/web-shared 4.1.0-beta.62 → 4.1.0-beta.63
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 +2 -3
- package/dist/components/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +13 -10
- package/dist/components/event-list-view.js.map +1 -1
- package/dist/components/run-trace-view.d.ts +1 -3
- package/dist/components/run-trace-view.d.ts.map +1 -1
- package/dist/components/run-trace-view.js +2 -2
- package/dist/components/run-trace-view.js.map +1 -1
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +11 -1
- package/dist/components/sidebar/attribute-panel.js.map +1 -1
- package/dist/components/sidebar/detail-card.d.ts.map +1 -1
- package/dist/components/sidebar/detail-card.js +4 -2
- package/dist/components/sidebar/detail-card.js.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.d.ts +3 -3
- package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.js +43 -26
- package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
- package/dist/components/trace-viewer/trace-viewer.d.ts +7 -1
- package/dist/components/trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/trace-viewer/trace-viewer.js +36 -11
- package/dist/components/trace-viewer/trace-viewer.js.map +1 -1
- package/dist/components/workflow-trace-view.d.ts +3 -3
- package/dist/components/workflow-trace-view.d.ts.map +1 -1
- package/dist/components/workflow-trace-view.js +31 -129
- package/dist/components/workflow-trace-view.js.map +1 -1
- package/dist/components/workflow-traces/trace-span-construction.d.ts +18 -5
- package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
- package/dist/components/workflow-traces/trace-span-construction.js +65 -18
- package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/event-materialization.d.ts +72 -0
- package/dist/lib/event-materialization.d.ts.map +1 -0
- package/dist/lib/event-materialization.js +171 -0
- package/dist/lib/event-materialization.js.map +1 -0
- package/dist/lib/trace-builder.d.ts +32 -0
- package/dist/lib/trace-builder.d.ts.map +1 -0
- package/dist/lib/trace-builder.js +129 -0
- package/dist/lib/trace-builder.js.map +1 -0
- package/package.json +2 -2
- package/src/components/event-list-view.tsx +17 -13
- package/src/components/run-trace-view.tsx +0 -6
- package/src/components/sidebar/attribute-panel.tsx +17 -2
- package/src/components/sidebar/detail-card.tsx +10 -2
- package/src/components/sidebar/entity-detail-panel.tsx +59 -21
- package/src/components/trace-viewer/trace-viewer.tsx +47 -2
- package/src/components/workflow-trace-view.tsx +89 -195
- package/src/components/workflow-traces/trace-span-construction.ts +85 -32
- package/src/index.ts +13 -0
- package/src/lib/event-materialization.ts +243 -0
- package/src/lib/trace-builder.ts +201 -0
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
|
-
import { Send, Zap } from 'lucide-react';
|
|
5
|
+
import { Lock, Send, Unlock, Zap } from 'lucide-react';
|
|
6
6
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
7
7
|
import { toast } from 'sonner';
|
|
8
|
+
import { isEncryptedMarker } from '../../lib/hydration';
|
|
8
9
|
import { AttributePanel } from './attribute-panel';
|
|
9
10
|
import { EventsList } from './events-list';
|
|
10
11
|
import { ResolveHookModal } from './resolve-hook-modal';
|
|
@@ -53,7 +54,6 @@ export interface SelectedSpanInfo {
|
|
|
53
54
|
*/
|
|
54
55
|
export function EntityDetailPanel({
|
|
55
56
|
run,
|
|
56
|
-
hooks,
|
|
57
57
|
onStreamClick,
|
|
58
58
|
spanDetailData,
|
|
59
59
|
spanDetailError,
|
|
@@ -63,11 +63,10 @@ export function EntityDetailPanel({
|
|
|
63
63
|
onLoadEventData,
|
|
64
64
|
onResolveHook,
|
|
65
65
|
encryptionKey,
|
|
66
|
+
onDecrypt,
|
|
66
67
|
selectedSpan,
|
|
67
68
|
}: {
|
|
68
69
|
run: WorkflowRun;
|
|
69
|
-
/** All hooks for the current run (used as fallback for token lookup). */
|
|
70
|
-
hooks?: Hook[];
|
|
71
70
|
/** Callback when a stream reference is clicked */
|
|
72
71
|
onStreamClick?: (streamId: string) => void;
|
|
73
72
|
/** Pre-fetched span detail data for the selected span. */
|
|
@@ -96,6 +95,8 @@ export function EntityDetailPanel({
|
|
|
96
95
|
) => Promise<void>;
|
|
97
96
|
/** Encryption key (available after Decrypt is clicked), used to re-load event data */
|
|
98
97
|
encryptionKey?: Uint8Array;
|
|
98
|
+
/** Callback to initiate decryption of encrypted run data */
|
|
99
|
+
onDecrypt?: () => void;
|
|
99
100
|
/** Info about the currently selected span from the trace viewer */
|
|
100
101
|
selectedSpan: SelectedSpanInfo | null;
|
|
101
102
|
}): React.JSX.Element | null {
|
|
@@ -129,10 +130,11 @@ export function EntityDetailPanel({
|
|
|
129
130
|
return { resource: 'hook', resourceId: data.hookId, runId: undefined };
|
|
130
131
|
}
|
|
131
132
|
if (res === 'sleep') {
|
|
133
|
+
const waitData = data as { runId?: string } | undefined;
|
|
132
134
|
return {
|
|
133
135
|
resource: 'sleep',
|
|
134
136
|
resourceId: selectedSpan.spanId,
|
|
135
|
-
runId:
|
|
137
|
+
runId: waitData?.runId,
|
|
136
138
|
};
|
|
137
139
|
}
|
|
138
140
|
return { resource: undefined, resourceId: undefined, runId: undefined };
|
|
@@ -196,6 +198,17 @@ export function EntityDetailPanel({
|
|
|
196
198
|
const error = spanDetailError ?? undefined;
|
|
197
199
|
const loading = spanDetailLoading ?? false;
|
|
198
200
|
|
|
201
|
+
const hasEncryptedFields = useMemo(() => {
|
|
202
|
+
if (!spanDetailData) return false;
|
|
203
|
+
const d = spanDetailData as Record<string, unknown>;
|
|
204
|
+
return (
|
|
205
|
+
isEncryptedMarker(d.input) ||
|
|
206
|
+
isEncryptedMarker(d.output) ||
|
|
207
|
+
isEncryptedMarker(d.error) ||
|
|
208
|
+
isEncryptedMarker(d.metadata)
|
|
209
|
+
);
|
|
210
|
+
}, [spanDetailData]);
|
|
211
|
+
|
|
199
212
|
// Get the hook token for resolving (prefer fetched data, then hooks array fallback)
|
|
200
213
|
const hookToken = useMemo(() => {
|
|
201
214
|
if (resource !== 'hook' || !resourceId) return undefined;
|
|
@@ -203,17 +216,12 @@ export function EntityDetailPanel({
|
|
|
203
216
|
if (isHook(spanDetailData) && spanDetailData.token) {
|
|
204
217
|
return spanDetailData.token;
|
|
205
218
|
}
|
|
206
|
-
// 2. Try the
|
|
207
|
-
const hookFromArray = hooks?.find((h) => h.hookId === resourceId);
|
|
208
|
-
if (hookFromArray?.token) {
|
|
209
|
-
return hookFromArray.token;
|
|
210
|
-
}
|
|
211
|
-
// 3. Try the span's inline data (partial hook from events - may lack token)
|
|
219
|
+
// 2. Try the span's inline data (reconstructed from hook_created event)
|
|
212
220
|
if (isHook(data) && (data as Hook).token) {
|
|
213
221
|
return (data as Hook).token;
|
|
214
222
|
}
|
|
215
223
|
return undefined;
|
|
216
|
-
}, [resource, resourceId, spanDetailData, data
|
|
224
|
+
}, [resource, resourceId, spanDetailData, data]);
|
|
217
225
|
|
|
218
226
|
useEffect(() => {
|
|
219
227
|
if (error && selectedSpan && resource) {
|
|
@@ -299,16 +307,15 @@ export function EntityDetailPanel({
|
|
|
299
307
|
[onResolveHook, hookToken, resolvingHook, spanDetailData, data]
|
|
300
308
|
);
|
|
301
309
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
310
|
+
// Prefer externally-fetched details when available. For sleep spans, the
|
|
311
|
+
// host fetches full correlated events (withData=true) and materializes a wait
|
|
312
|
+
// entity, so this includes resumeAt/completedAt without bloating trace payloads.
|
|
313
|
+
const displayData = (spanDetailData ?? data) as
|
|
314
|
+
| WorkflowRun
|
|
315
|
+
| Step
|
|
316
|
+
| Hook
|
|
317
|
+
| Event;
|
|
305
318
|
|
|
306
|
-
// For sleep spans, spanDetailData from the host is typically an events array
|
|
307
|
-
// (not a single entity), so always prefer the inline wait entity from span
|
|
308
|
-
// attributes which contains waitId, runId, createdAt, resumeAt, completedAt.
|
|
309
|
-
const displayData = (
|
|
310
|
-
resource === 'sleep' ? data : (spanDetailData ?? data)
|
|
311
|
-
) as WorkflowRun | Step | Hook | Event;
|
|
312
319
|
const moduleSpecifier = useMemo(() => {
|
|
313
320
|
const displayRecord = displayData as Record<string, unknown>;
|
|
314
321
|
const displayStepName = displayRecord.stepName;
|
|
@@ -325,6 +332,10 @@ export function EntityDetailPanel({
|
|
|
325
332
|
return undefined;
|
|
326
333
|
}, [displayData, run.workflowName]);
|
|
327
334
|
|
|
335
|
+
if (!selectedSpan || !resource || !resourceId) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
328
339
|
const resourceLabel = resource.charAt(0).toUpperCase() + resource.slice(1);
|
|
329
340
|
const hasPendingActions =
|
|
330
341
|
(resource === 'sleep' && canWakeUp) ||
|
|
@@ -369,6 +380,33 @@ export function EntityDetailPanel({
|
|
|
369
380
|
{resourceId}
|
|
370
381
|
</p>
|
|
371
382
|
</div>
|
|
383
|
+
{(hasEncryptedFields || encryptionKey) && onDecrypt && (
|
|
384
|
+
<button
|
|
385
|
+
type="button"
|
|
386
|
+
onClick={onDecrypt}
|
|
387
|
+
disabled={!!encryptionKey}
|
|
388
|
+
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors flex-shrink-0"
|
|
389
|
+
style={{
|
|
390
|
+
borderColor: encryptionKey
|
|
391
|
+
? 'var(--ds-green-400)'
|
|
392
|
+
: 'var(--ds-gray-300)',
|
|
393
|
+
color: encryptionKey
|
|
394
|
+
? 'var(--ds-green-900)'
|
|
395
|
+
: 'var(--ds-gray-900)',
|
|
396
|
+
backgroundColor: encryptionKey
|
|
397
|
+
? 'var(--ds-green-100)'
|
|
398
|
+
: 'var(--ds-background-100)',
|
|
399
|
+
cursor: encryptionKey ? 'default' : 'pointer',
|
|
400
|
+
}}
|
|
401
|
+
>
|
|
402
|
+
{encryptionKey ? (
|
|
403
|
+
<Unlock className="h-3 w-3" />
|
|
404
|
+
) : (
|
|
405
|
+
<Lock className="h-3 w-3" />
|
|
406
|
+
)}
|
|
407
|
+
{encryptionKey ? 'Decrypted' : 'Decrypt'}
|
|
408
|
+
</button>
|
|
409
|
+
)}
|
|
372
410
|
</div>
|
|
373
411
|
</div>
|
|
374
412
|
|
|
@@ -103,7 +103,16 @@ export function TraceViewerTimeline({
|
|
|
103
103
|
highlightedSpans,
|
|
104
104
|
eagerRender = false,
|
|
105
105
|
isLive = false,
|
|
106
|
-
|
|
106
|
+
footer,
|
|
107
|
+
knownDurationMs,
|
|
108
|
+
hasMoreData = false,
|
|
109
|
+
}: Omit<TraceViewerProps, 'getQuickLinks'> & {
|
|
110
|
+
footer?: ReactNode;
|
|
111
|
+
/** Duration in ms from trace start to the latest known event. Used to render the unknown-time overlay. */
|
|
112
|
+
knownDurationMs?: number;
|
|
113
|
+
/** Whether more data pages are expected. Controls the unknown-data overlay visibility. */
|
|
114
|
+
hasMoreData?: boolean;
|
|
115
|
+
}): ReactNode {
|
|
107
116
|
const isSkeleton = trace === skeletonTrace;
|
|
108
117
|
const { state, dispatch } = useTraceViewer();
|
|
109
118
|
const { timelineRef, scrollSnapshotRef } = state;
|
|
@@ -450,7 +459,7 @@ export function TraceViewerTimeline({
|
|
|
450
459
|
style={{
|
|
451
460
|
position: 'relative',
|
|
452
461
|
width: state.timelineWidth,
|
|
453
|
-
|
|
462
|
+
minHeight: state.timelineHeight - TIMELINE_PADDING * 2,
|
|
454
463
|
padding: TIMELINE_PADDING,
|
|
455
464
|
paddingBottom: 0,
|
|
456
465
|
}}
|
|
@@ -487,8 +496,44 @@ export function TraceViewerTimeline({
|
|
|
487
496
|
scrollSnapshotRef={scrollSnapshotRef}
|
|
488
497
|
spans={spans}
|
|
489
498
|
/>
|
|
499
|
+
{/* Horizontal "unknown time" overlay — covers the region to the
|
|
500
|
+
right of the latest known event, indicating data beyond this
|
|
501
|
+
point hasn't been loaded yet. */}
|
|
502
|
+
{knownDurationMs != null &&
|
|
503
|
+
knownDurationMs > 0 &&
|
|
504
|
+
(hasMoreData || isLive) &&
|
|
505
|
+
state.root.duration > 0 &&
|
|
506
|
+
(() => {
|
|
507
|
+
const knownPx = knownDurationMs * scale;
|
|
508
|
+
const totalPx = state.root.duration * scale;
|
|
509
|
+
const unknownWidth = totalPx - knownPx;
|
|
510
|
+
// Only show if the unknown region is meaningfully wide
|
|
511
|
+
if (unknownWidth < 4) return null;
|
|
512
|
+
// Offset ~5% into the unknown region so it doesn't touch spans
|
|
513
|
+
const insetPx = Math.min(unknownWidth * 0.05, 20);
|
|
514
|
+
return (
|
|
515
|
+
<div
|
|
516
|
+
style={{
|
|
517
|
+
position: 'absolute',
|
|
518
|
+
top: 0,
|
|
519
|
+
left: knownPx + insetPx,
|
|
520
|
+
width: unknownWidth - insetPx,
|
|
521
|
+
height: '100%',
|
|
522
|
+
pointerEvents: 'none',
|
|
523
|
+
zIndex: 1,
|
|
524
|
+
maskImage:
|
|
525
|
+
'linear-gradient(to right, transparent 1%, black 3%)',
|
|
526
|
+
WebkitMaskImage:
|
|
527
|
+
'linear-gradient(to right, transparent 1%, black 3%)',
|
|
528
|
+
background:
|
|
529
|
+
'repeating-linear-gradient(-45deg, var(--ds-background-200) 0, var(--ds-background-200) 11px, var(--ds-gray-200) 11px, var(--ds-gray-200) 12px)',
|
|
530
|
+
}}
|
|
531
|
+
/>
|
|
532
|
+
);
|
|
533
|
+
})()}
|
|
490
534
|
</div>
|
|
491
535
|
</div>
|
|
536
|
+
{footer}
|
|
492
537
|
</div>
|
|
493
538
|
<div className={styles.zoomButtonTraceViewer}>
|
|
494
539
|
<ZoomButton />
|
|
@@ -35,14 +35,7 @@ import {
|
|
|
35
35
|
getCustomSpanClassName,
|
|
36
36
|
getCustomSpanEventClassName,
|
|
37
37
|
} from './workflow-traces/trace-colors';
|
|
38
|
-
import {
|
|
39
|
-
hookToSpan,
|
|
40
|
-
runToSpan,
|
|
41
|
-
stepToSpan,
|
|
42
|
-
WORKFLOW_LIBRARY,
|
|
43
|
-
waitToSpan,
|
|
44
|
-
} from './workflow-traces/trace-span-construction';
|
|
45
|
-
import { otelTimeToMs } from './workflow-traces/trace-time-utils';
|
|
38
|
+
import { buildTrace, type TraceWithMeta } from '../lib/trace-builder';
|
|
46
39
|
|
|
47
40
|
/**
|
|
48
41
|
* While a run is live, continuously grow root.duration and rescale so the
|
|
@@ -273,7 +266,6 @@ function SpanContextMenu({
|
|
|
273
266
|
function TraceViewerWithContextMenu({
|
|
274
267
|
trace,
|
|
275
268
|
run,
|
|
276
|
-
hooks,
|
|
277
269
|
isLive,
|
|
278
270
|
onWakeUpSleep,
|
|
279
271
|
onCancelRun,
|
|
@@ -285,7 +277,6 @@ function TraceViewerWithContextMenu({
|
|
|
285
277
|
}: {
|
|
286
278
|
trace: { spans: Span[] };
|
|
287
279
|
run: WorkflowRun;
|
|
288
|
-
hooks: Hook[];
|
|
289
280
|
isLive: boolean;
|
|
290
281
|
onWakeUpSleep?: (
|
|
291
282
|
runId: string,
|
|
@@ -307,7 +298,10 @@ function TraceViewerWithContextMenu({
|
|
|
307
298
|
// Drive active span widths at 60fps without React re-renders
|
|
308
299
|
useLiveTick(isLive);
|
|
309
300
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
310
|
-
const [resolveHookTarget, setResolveHookTarget] = useState<
|
|
301
|
+
const [resolveHookTarget, setResolveHookTarget] = useState<{
|
|
302
|
+
hookId: string;
|
|
303
|
+
token: string;
|
|
304
|
+
} | null>(null);
|
|
311
305
|
const [resolvingHook, setResolvingHook] = useState(false);
|
|
312
306
|
// Track hooks resolved in this session so the context menu item hides immediately
|
|
313
307
|
const [resolvedHookIds, setResolvedHookIds] = useState<Set<string>>(
|
|
@@ -323,15 +317,6 @@ function TraceViewerWithContextMenu({
|
|
|
323
317
|
return map;
|
|
324
318
|
}, [trace.spans]);
|
|
325
319
|
|
|
326
|
-
// Build a lookup map: hookId -> Hook
|
|
327
|
-
const hookLookup = useMemo(() => {
|
|
328
|
-
const map = new Map<string, Hook>();
|
|
329
|
-
for (const hook of hooks) {
|
|
330
|
-
map.set(hook.hookId, hook);
|
|
331
|
-
}
|
|
332
|
-
return map;
|
|
333
|
-
}, [hooks]);
|
|
334
|
-
|
|
335
320
|
const handleResolveHook = useCallback(
|
|
336
321
|
async (payload: unknown) => {
|
|
337
322
|
if (resolvingHook || !resolveHookTarget || !onResolveHook) return;
|
|
@@ -344,11 +329,7 @@ function TraceViewerWithContextMenu({
|
|
|
344
329
|
}
|
|
345
330
|
try {
|
|
346
331
|
setResolvingHook(true);
|
|
347
|
-
await onResolveHook(
|
|
348
|
-
resolveHookTarget.token,
|
|
349
|
-
payload,
|
|
350
|
-
resolveHookTarget
|
|
351
|
-
);
|
|
332
|
+
await onResolveHook(resolveHookTarget.token, payload);
|
|
352
333
|
toast.success('Hook resolved', {
|
|
353
334
|
description: 'The payload has been sent and the hook resolved.',
|
|
354
335
|
});
|
|
@@ -496,22 +477,24 @@ function TraceViewerWithContextMenu({
|
|
|
496
477
|
|
|
497
478
|
// Hook-specific: Resolve Hook (only on active, unresolved hooks)
|
|
498
479
|
if (menu.resourceType === 'hook' && isRunActive && onResolveHook) {
|
|
499
|
-
const hook = hookLookup.get(menu.spanId);
|
|
500
480
|
const span = spanLookup.get(menu.spanId);
|
|
501
|
-
// Check data-level disposedAt, span events, AND local resolved state
|
|
502
481
|
const hookData = span?.attributes?.data as
|
|
503
|
-
| { disposedAt?: unknown }
|
|
482
|
+
| { token?: string; disposedAt?: unknown }
|
|
504
483
|
| undefined;
|
|
484
|
+
// Check data-level disposedAt, span events, AND local resolved state
|
|
505
485
|
const isDisposed =
|
|
506
486
|
Boolean(hookData?.disposedAt) ||
|
|
507
487
|
Boolean(span?.events?.some((e) => e.name === 'hook_disposed')) ||
|
|
508
488
|
resolvedHookIds.has(menu.spanId);
|
|
509
|
-
if (
|
|
489
|
+
if (hookData?.token && !isDisposed) {
|
|
510
490
|
items.push({
|
|
511
491
|
label: 'Resolve Hook',
|
|
512
492
|
icon: <Send className="h-3.5 w-3.5" />,
|
|
513
493
|
action: () => {
|
|
514
|
-
setResolveHookTarget(
|
|
494
|
+
setResolveHookTarget({
|
|
495
|
+
hookId: menu.spanId,
|
|
496
|
+
token: hookData.token!,
|
|
497
|
+
});
|
|
515
498
|
},
|
|
516
499
|
});
|
|
517
500
|
}
|
|
@@ -582,7 +565,6 @@ function TraceViewerWithContextMenu({
|
|
|
582
565
|
onWakeUpSleep,
|
|
583
566
|
onCancelRun,
|
|
584
567
|
onResolveHook,
|
|
585
|
-
hookLookup,
|
|
586
568
|
spanLookup,
|
|
587
569
|
resolvedHookIds,
|
|
588
570
|
run.runId,
|
|
@@ -610,159 +592,6 @@ function TraceViewerWithContextMenu({
|
|
|
610
592
|
);
|
|
611
593
|
}
|
|
612
594
|
|
|
613
|
-
type GroupedEvents = {
|
|
614
|
-
eventsByStepId: Map<string, Event[]>;
|
|
615
|
-
eventsByHookId: Map<string, Event[]>;
|
|
616
|
-
runLevelEvents: Event[];
|
|
617
|
-
timerEvents: Map<string, Event[]>;
|
|
618
|
-
hookEvents: Map<string, Event[]>;
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
const isTimerEvent = (eventType: string) =>
|
|
622
|
-
eventType === 'wait_created' || eventType === 'wait_completed';
|
|
623
|
-
|
|
624
|
-
const isHookLifecycleEvent = (eventType: string) =>
|
|
625
|
-
eventType === 'hook_received' ||
|
|
626
|
-
eventType === 'hook_created' ||
|
|
627
|
-
eventType === 'hook_disposed';
|
|
628
|
-
|
|
629
|
-
const pushEvent = (
|
|
630
|
-
map: Map<string, Event[]>,
|
|
631
|
-
correlationId: string,
|
|
632
|
-
event: Event
|
|
633
|
-
) => {
|
|
634
|
-
const existing = map.get(correlationId);
|
|
635
|
-
if (existing) {
|
|
636
|
-
existing.push(event);
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
map.set(correlationId, [event]);
|
|
640
|
-
};
|
|
641
|
-
|
|
642
|
-
const groupEventsByCorrelation = (
|
|
643
|
-
events: Event[],
|
|
644
|
-
steps: Step[],
|
|
645
|
-
hooks: Hook[]
|
|
646
|
-
): GroupedEvents => {
|
|
647
|
-
const eventsByStepId = new Map<string, Event[]>();
|
|
648
|
-
const eventsByHookId = new Map<string, Event[]>();
|
|
649
|
-
const runLevelEvents: Event[] = [];
|
|
650
|
-
const timerEvents = new Map<string, Event[]>();
|
|
651
|
-
const hookEvents = new Map<string, Event[]>();
|
|
652
|
-
const stepIds = new Set(steps.map((step) => step.stepId));
|
|
653
|
-
const hookIds = new Set(hooks.map((hook) => hook.hookId));
|
|
654
|
-
|
|
655
|
-
for (const event of events) {
|
|
656
|
-
const correlationId = event.correlationId;
|
|
657
|
-
if (!correlationId) {
|
|
658
|
-
runLevelEvents.push(event);
|
|
659
|
-
continue;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
if (isTimerEvent(event.eventType)) {
|
|
663
|
-
pushEvent(timerEvents, correlationId, event);
|
|
664
|
-
continue;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
if (isHookLifecycleEvent(event.eventType)) {
|
|
668
|
-
pushEvent(hookEvents, correlationId, event);
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
if (stepIds.has(correlationId)) {
|
|
673
|
-
pushEvent(eventsByStepId, correlationId, event);
|
|
674
|
-
continue;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
if (hookIds.has(correlationId)) {
|
|
678
|
-
pushEvent(eventsByHookId, correlationId, event);
|
|
679
|
-
continue;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
runLevelEvents.push(event);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
return {
|
|
686
|
-
eventsByStepId,
|
|
687
|
-
eventsByHookId,
|
|
688
|
-
runLevelEvents,
|
|
689
|
-
timerEvents,
|
|
690
|
-
hookEvents,
|
|
691
|
-
};
|
|
692
|
-
};
|
|
693
|
-
|
|
694
|
-
const buildSpans = (
|
|
695
|
-
run: WorkflowRun,
|
|
696
|
-
steps: Step[],
|
|
697
|
-
groupedEvents: GroupedEvents,
|
|
698
|
-
now: Date
|
|
699
|
-
) => {
|
|
700
|
-
const viewerEndTime = new Date(run.completedAt || now);
|
|
701
|
-
const stepSpans = steps.map((step) => {
|
|
702
|
-
const stepEvents = groupedEvents.eventsByStepId.get(step.stepId) || [];
|
|
703
|
-
return stepToSpan(step, stepEvents, now, viewerEndTime);
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
const hookSpans = Array.from(groupedEvents.hookEvents.values())
|
|
707
|
-
.map((events) => hookToSpan(events, run, now))
|
|
708
|
-
.filter((span): span is Span => span !== null);
|
|
709
|
-
|
|
710
|
-
const waitSpans = Array.from(groupedEvents.timerEvents.values())
|
|
711
|
-
.map((events) => waitToSpan(events, run, now))
|
|
712
|
-
.filter((span): span is Span => span !== null);
|
|
713
|
-
|
|
714
|
-
return {
|
|
715
|
-
runSpan: runToSpan(run, groupedEvents.runLevelEvents, now),
|
|
716
|
-
spans: [...stepSpans, ...hookSpans, ...waitSpans],
|
|
717
|
-
};
|
|
718
|
-
};
|
|
719
|
-
|
|
720
|
-
const cascadeSpans = (runSpan: Span, spans: Span[]) => {
|
|
721
|
-
const sortedSpans = [
|
|
722
|
-
runSpan,
|
|
723
|
-
...spans.slice().sort((a, b) => {
|
|
724
|
-
const aStart = otelTimeToMs(a.startTime);
|
|
725
|
-
const bStart = otelTimeToMs(b.startTime);
|
|
726
|
-
return aStart - bStart;
|
|
727
|
-
}),
|
|
728
|
-
];
|
|
729
|
-
|
|
730
|
-
return sortedSpans.map((span, index) => {
|
|
731
|
-
const parentSpanId =
|
|
732
|
-
index === 0 ? undefined : String(sortedSpans[index - 1].spanId);
|
|
733
|
-
return {
|
|
734
|
-
...span,
|
|
735
|
-
parentSpanId,
|
|
736
|
-
};
|
|
737
|
-
});
|
|
738
|
-
};
|
|
739
|
-
|
|
740
|
-
const buildTrace = (
|
|
741
|
-
run: WorkflowRun,
|
|
742
|
-
steps: Step[],
|
|
743
|
-
hooks: Hook[],
|
|
744
|
-
events: Event[],
|
|
745
|
-
now: Date
|
|
746
|
-
) => {
|
|
747
|
-
const groupedEvents = groupEventsByCorrelation(events, steps, hooks);
|
|
748
|
-
const { runSpan, spans } = buildSpans(run, steps, groupedEvents, now);
|
|
749
|
-
const sortedCascadingSpans = cascadeSpans(runSpan, spans);
|
|
750
|
-
|
|
751
|
-
return {
|
|
752
|
-
traceId: run.runId,
|
|
753
|
-
rootSpanId: run.runId,
|
|
754
|
-
spans: sortedCascadingSpans,
|
|
755
|
-
resources: [
|
|
756
|
-
{
|
|
757
|
-
name: 'workflow',
|
|
758
|
-
attributes: {
|
|
759
|
-
'service.name': WORKFLOW_LIBRARY.name,
|
|
760
|
-
},
|
|
761
|
-
},
|
|
762
|
-
],
|
|
763
|
-
};
|
|
764
|
-
};
|
|
765
|
-
|
|
766
595
|
/** Re-export SpanSelectionInfo for consumers */
|
|
767
596
|
export type { SpanSelectionInfo };
|
|
768
597
|
|
|
@@ -870,14 +699,73 @@ function PanelResizeHandle({
|
|
|
870
699
|
);
|
|
871
700
|
}
|
|
872
701
|
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// Trace viewer footer — loading / waiting indicator
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
function TraceViewerFooter({
|
|
707
|
+
hasMore,
|
|
708
|
+
isLive,
|
|
709
|
+
}: {
|
|
710
|
+
hasMore: boolean;
|
|
711
|
+
isLive: boolean;
|
|
712
|
+
}): ReactNode {
|
|
713
|
+
const style = { color: 'var(--ds-gray-900)' };
|
|
714
|
+
if (hasMore) {
|
|
715
|
+
return (
|
|
716
|
+
<div
|
|
717
|
+
className="flex items-center justify-center gap-2 py-3 text-xs"
|
|
718
|
+
style={style}
|
|
719
|
+
>
|
|
720
|
+
<svg
|
|
721
|
+
className="h-3.5 w-3.5 animate-spin"
|
|
722
|
+
viewBox="0 0 24 24"
|
|
723
|
+
fill="none"
|
|
724
|
+
>
|
|
725
|
+
<circle
|
|
726
|
+
className="opacity-25"
|
|
727
|
+
cx="12"
|
|
728
|
+
cy="12"
|
|
729
|
+
r="10"
|
|
730
|
+
stroke="currentColor"
|
|
731
|
+
strokeWidth="4"
|
|
732
|
+
/>
|
|
733
|
+
<path
|
|
734
|
+
className="opacity-75"
|
|
735
|
+
fill="currentColor"
|
|
736
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
737
|
+
/>
|
|
738
|
+
</svg>
|
|
739
|
+
Loading more events…
|
|
740
|
+
</div>
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
if (isLive) {
|
|
744
|
+
return (
|
|
745
|
+
<div
|
|
746
|
+
className="flex items-center justify-center py-3 text-xs"
|
|
747
|
+
style={style}
|
|
748
|
+
>
|
|
749
|
+
Waiting for more events…
|
|
750
|
+
</div>
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
return (
|
|
754
|
+
<div
|
|
755
|
+
className="flex items-center justify-center py-3 text-xs"
|
|
756
|
+
style={style}
|
|
757
|
+
>
|
|
758
|
+
End of run
|
|
759
|
+
</div>
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
873
763
|
// ---------------------------------------------------------------------------
|
|
874
764
|
// Main component
|
|
875
765
|
// ---------------------------------------------------------------------------
|
|
876
766
|
|
|
877
767
|
export const WorkflowTraceViewer = ({
|
|
878
768
|
run,
|
|
879
|
-
steps,
|
|
880
|
-
hooks,
|
|
881
769
|
events,
|
|
882
770
|
isLoading,
|
|
883
771
|
error,
|
|
@@ -894,10 +782,9 @@ export const WorkflowTraceViewer = ({
|
|
|
894
782
|
hasMoreSpans = false,
|
|
895
783
|
isLoadingMoreSpans = false,
|
|
896
784
|
encryptionKey,
|
|
785
|
+
onDecrypt,
|
|
897
786
|
}: {
|
|
898
787
|
run: WorkflowRun;
|
|
899
|
-
steps: Step[];
|
|
900
|
-
hooks: Hook[];
|
|
901
788
|
events: Event[];
|
|
902
789
|
isLoading?: boolean;
|
|
903
790
|
error?: Error | null;
|
|
@@ -932,6 +819,8 @@ export const WorkflowTraceViewer = ({
|
|
|
932
819
|
isLoadingMoreSpans?: boolean;
|
|
933
820
|
/** Encryption key (available after Decrypt), threaded to event list for re-loading */
|
|
934
821
|
encryptionKey?: Uint8Array;
|
|
822
|
+
/** Callback to initiate decryption of encrypted run data */
|
|
823
|
+
onDecrypt?: () => void;
|
|
935
824
|
}) => {
|
|
936
825
|
const [selectedSpan, setSelectedSpan] = useState<SelectedSpanInfo | null>(
|
|
937
826
|
null
|
|
@@ -947,13 +836,14 @@ export const WorkflowTraceViewer = ({
|
|
|
947
836
|
|
|
948
837
|
// Build trace only when actual data changes — no timer-driven rebuilds.
|
|
949
838
|
// Active span widths are animated imperatively by useLiveTick at 60fps.
|
|
950
|
-
const
|
|
951
|
-
if (!run) {
|
|
839
|
+
const traceWithMeta: TraceWithMeta | undefined = useMemo(() => {
|
|
840
|
+
if (!run?.runId) {
|
|
952
841
|
return undefined;
|
|
953
842
|
}
|
|
954
|
-
return buildTrace(run,
|
|
843
|
+
return buildTrace(run, events, new Date());
|
|
955
844
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep; useLiveTick handles live growth
|
|
956
|
-
}, [run,
|
|
845
|
+
}, [run, events]);
|
|
846
|
+
const trace = traceWithMeta;
|
|
957
847
|
|
|
958
848
|
useEffect(() => {
|
|
959
849
|
if (error && !isLoading) {
|
|
@@ -1064,7 +954,7 @@ export const WorkflowTraceViewer = ({
|
|
|
1064
954
|
);
|
|
1065
955
|
}, [selectedSpan?.data]);
|
|
1066
956
|
|
|
1067
|
-
if (
|
|
957
|
+
if (!trace) {
|
|
1068
958
|
return (
|
|
1069
959
|
<div className="relative w-full h-full">
|
|
1070
960
|
<div className="border-b border-gray-alpha-400 w-full" />
|
|
@@ -1093,7 +983,6 @@ export const WorkflowTraceViewer = ({
|
|
|
1093
983
|
<TraceViewerWithContextMenu
|
|
1094
984
|
trace={trace}
|
|
1095
985
|
run={run}
|
|
1096
|
-
hooks={hooks}
|
|
1097
986
|
isLive={isLive}
|
|
1098
987
|
onWakeUpSleep={onWakeUpSleep}
|
|
1099
988
|
onCancelRun={onCancelRun}
|
|
@@ -1107,6 +996,11 @@ export const WorkflowTraceViewer = ({
|
|
|
1107
996
|
height="100%"
|
|
1108
997
|
isLive={isLive}
|
|
1109
998
|
trace={trace}
|
|
999
|
+
knownDurationMs={traceWithMeta?.knownDurationMs}
|
|
1000
|
+
hasMoreData={hasMoreSpans}
|
|
1001
|
+
footer={
|
|
1002
|
+
<TraceViewerFooter hasMore={hasMoreSpans} isLive={isLive} />
|
|
1003
|
+
}
|
|
1110
1004
|
/>
|
|
1111
1005
|
</TraceViewerWithContextMenu>
|
|
1112
1006
|
</TraceViewerContextProvider>
|
|
@@ -1246,7 +1140,6 @@ export const WorkflowTraceViewer = ({
|
|
|
1246
1140
|
<ErrorBoundary title="Failed to load entity details">
|
|
1247
1141
|
<EntityDetailPanel
|
|
1248
1142
|
run={run}
|
|
1249
|
-
hooks={hooks}
|
|
1250
1143
|
onStreamClick={onStreamClick}
|
|
1251
1144
|
spanDetailData={spanDetailData ?? null}
|
|
1252
1145
|
spanDetailError={spanDetailError}
|
|
@@ -1256,6 +1149,7 @@ export const WorkflowTraceViewer = ({
|
|
|
1256
1149
|
onLoadEventData={onLoadEventData}
|
|
1257
1150
|
onResolveHook={onResolveHook}
|
|
1258
1151
|
encryptionKey={encryptionKey}
|
|
1152
|
+
onDecrypt={onDecrypt}
|
|
1259
1153
|
selectedSpan={selectedSpan}
|
|
1260
1154
|
/>
|
|
1261
1155
|
</ErrorBoundary>
|