@workflow/web-shared 5.0.0-beta.13 → 5.0.0-beta.14
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.js +3 -3
- package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/timeline.js +4 -4
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +8 -4
- package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.js +12 -11
- package/dist/components/sidebar/events-list.d.ts +3 -1
- package/dist/components/sidebar/events-list.d.ts.map +1 -1
- package/dist/components/sidebar/events-list.js +4 -3
- package/dist/components/sidebar/span-detail-merge.d.ts +16 -0
- package/dist/components/sidebar/span-detail-merge.d.ts.map +1 -0
- package/dist/components/sidebar/span-detail-merge.js +53 -0
- package/dist/components/trace-viewer/util/timing.d.ts +2 -2
- package/dist/components/trace-viewer/util/timing.d.ts.map +1 -1
- package/dist/components/trace-viewer/util/timing.js +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/lib/utils.d.ts +11 -0
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +49 -1
- package/package.json +3 -3
- package/src/components/new-trace-viewer/components/event-list.tsx +2 -2
- package/src/components/new-trace-viewer/components/timeline.tsx +7 -3
- package/src/components/sidebar/attribute-panel.tsx +7 -3
- package/src/components/sidebar/entity-detail-panel.tsx +28 -13
- package/src/components/sidebar/events-list.tsx +34 -25
- package/src/components/sidebar/span-detail-merge.ts +65 -0
- package/src/components/trace-viewer/util/timing.ts +2 -2
- package/src/index.ts +1 -0
- package/src/lib/utils.ts +57 -0
|
@@ -10,6 +10,10 @@ import { AttributePanel } from './attribute-panel';
|
|
|
10
10
|
import { EventsList } from './events-list';
|
|
11
11
|
import { ResolveHookModal } from './resolve-hook-modal';
|
|
12
12
|
import { useSidebarDataOptional } from './sidebar-data-context';
|
|
13
|
+
import {
|
|
14
|
+
mergeSpanDetail,
|
|
15
|
+
spanDetailMatchesSelection,
|
|
16
|
+
} from './span-detail-merge';
|
|
13
17
|
|
|
14
18
|
// Type guards for runtime validation of span attribute data
|
|
15
19
|
function isStep(data: unknown): data is Step {
|
|
@@ -215,19 +219,27 @@ export function EntityDetailPanel({
|
|
|
215
219
|
const error = spanDetailError ?? undefined;
|
|
216
220
|
const loading = spanDetailLoading ?? false;
|
|
217
221
|
|
|
222
|
+
const matchedSpanDetailData = useMemo(
|
|
223
|
+
() =>
|
|
224
|
+
spanDetailMatchesSelection(spanDetailData, resource, resourceId)
|
|
225
|
+
? spanDetailData
|
|
226
|
+
: null,
|
|
227
|
+
[spanDetailData, resource, resourceId]
|
|
228
|
+
);
|
|
229
|
+
|
|
218
230
|
// Get the hook token for resolving (prefer fetched data, then hooks array fallback)
|
|
219
231
|
const hookToken = useMemo(() => {
|
|
220
232
|
if (resource !== 'hook' || !resourceId) return undefined;
|
|
221
233
|
// 1. Try the externally-fetched detail data first
|
|
222
|
-
if (isHook(
|
|
223
|
-
return
|
|
234
|
+
if (isHook(matchedSpanDetailData) && matchedSpanDetailData.token) {
|
|
235
|
+
return matchedSpanDetailData.token;
|
|
224
236
|
}
|
|
225
237
|
// 2. Try the span's inline data (reconstructed from hook_created event)
|
|
226
238
|
if (isHook(data) && (data as Hook).token) {
|
|
227
239
|
return (data as Hook).token;
|
|
228
240
|
}
|
|
229
241
|
return undefined;
|
|
230
|
-
}, [resource, resourceId,
|
|
242
|
+
}, [resource, resourceId, matchedSpanDetailData, data]);
|
|
231
243
|
|
|
232
244
|
useEffect(() => {
|
|
233
245
|
if (error && selectedSpan && resource) {
|
|
@@ -289,7 +301,7 @@ export function EntityDetailPanel({
|
|
|
289
301
|
|
|
290
302
|
try {
|
|
291
303
|
setResolvingHook(true);
|
|
292
|
-
const candidate =
|
|
304
|
+
const candidate = matchedSpanDetailData ?? data;
|
|
293
305
|
const hook = isHook(candidate) ? candidate : undefined;
|
|
294
306
|
await onResolveHook(hookToken, payload, hook);
|
|
295
307
|
toast.success('Hook resolved', {
|
|
@@ -310,17 +322,18 @@ export function EntityDetailPanel({
|
|
|
310
322
|
setResolvingHook(false);
|
|
311
323
|
}
|
|
312
324
|
},
|
|
313
|
-
[onResolveHook, hookToken, resolvingHook,
|
|
325
|
+
[onResolveHook, hookToken, resolvingHook, matchedSpanDetailData, data]
|
|
314
326
|
);
|
|
315
327
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
328
|
+
const displayData = useMemo(
|
|
329
|
+
() =>
|
|
330
|
+
mergeSpanDetail(data, matchedSpanDetailData) as
|
|
331
|
+
| WorkflowRun
|
|
332
|
+
| Step
|
|
333
|
+
| Hook
|
|
334
|
+
| Event,
|
|
335
|
+
[data, matchedSpanDetailData]
|
|
336
|
+
);
|
|
324
337
|
|
|
325
338
|
const moduleSpecifier = useMemo(() => {
|
|
326
339
|
const displayRecord = displayData as Record<string, unknown>;
|
|
@@ -435,6 +448,8 @@ export function EntityDetailPanel({
|
|
|
435
448
|
<EventsList
|
|
436
449
|
events={rawEvents}
|
|
437
450
|
onLoadEventData={onLoadEventData}
|
|
451
|
+
onStreamClick={onStreamClick}
|
|
452
|
+
onRunClick={onRunClick}
|
|
438
453
|
encryptionKey={encryptionKey}
|
|
439
454
|
/>
|
|
440
455
|
)}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { EVENT_DATA_REF_FIELDS, type Event } from '@workflow/world';
|
|
4
4
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
5
5
|
import { hasEncryptedFields, isExpiredMarker } from '../../lib/hydration';
|
|
6
|
+
import { RunClickContext, StreamClickContext } from '../ui/data-inspector';
|
|
6
7
|
import { ErrorCard } from '../ui/error-card';
|
|
7
8
|
import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block';
|
|
8
9
|
import { Skeleton } from '../ui/skeleton';
|
|
@@ -256,6 +257,8 @@ export function EventsList({
|
|
|
256
257
|
isLoading = false,
|
|
257
258
|
error,
|
|
258
259
|
onLoadEventData,
|
|
260
|
+
onStreamClick,
|
|
261
|
+
onRunClick,
|
|
259
262
|
encryptionKey,
|
|
260
263
|
}: {
|
|
261
264
|
events: Event[];
|
|
@@ -265,6 +268,8 @@ export function EventsList({
|
|
|
265
268
|
correlationId: string,
|
|
266
269
|
eventId: string
|
|
267
270
|
) => Promise<unknown | null>;
|
|
271
|
+
onStreamClick?: (streamId: string) => void;
|
|
272
|
+
onRunClick?: (runId: string) => void;
|
|
268
273
|
/** When provided, signals that decryption is active (triggers re-load of expanded events) */
|
|
269
274
|
encryptionKey?: Uint8Array;
|
|
270
275
|
}) {
|
|
@@ -285,31 +290,35 @@ export function EventsList({
|
|
|
285
290
|
}
|
|
286
291
|
|
|
287
292
|
return (
|
|
288
|
-
<
|
|
289
|
-
{
|
|
290
|
-
<
|
|
291
|
-
{
|
|
292
|
-
<div
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
293
|
+
<RunClickContext.Provider value={onRunClick}>
|
|
294
|
+
<StreamClickContext.Provider value={onStreamClick}>
|
|
295
|
+
<DetailCard summary="Events" contentClassName="mb-0" defaultOpen>
|
|
296
|
+
{isLoading ? (
|
|
297
|
+
<div className="flex flex-col -mx-4">
|
|
298
|
+
{[0, 1, 2].map((i) => (
|
|
299
|
+
<div
|
|
300
|
+
key={i}
|
|
301
|
+
className="flex items-center justify-between gap-3 bg-background-200 px-4 py-2"
|
|
302
|
+
>
|
|
303
|
+
<Skeleton className="h-4 w-32 rounded" />
|
|
304
|
+
<Skeleton className="h-3 w-16 rounded" />
|
|
305
|
+
</div>
|
|
306
|
+
))}
|
|
298
307
|
</div>
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
)
|
|
311
|
-
</
|
|
312
|
-
|
|
313
|
-
</
|
|
308
|
+
) : (
|
|
309
|
+
<div className="flex flex-col -mx-4">
|
|
310
|
+
{sortedEvents.map((event) => (
|
|
311
|
+
<EventItem
|
|
312
|
+
key={event.eventId}
|
|
313
|
+
event={event}
|
|
314
|
+
onLoadEventData={onLoadEventData}
|
|
315
|
+
encryptionKey={encryptionKey}
|
|
316
|
+
/>
|
|
317
|
+
))}
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
</DetailCard>
|
|
321
|
+
</StreamClickContext.Provider>
|
|
322
|
+
</RunClickContext.Provider>
|
|
314
323
|
);
|
|
315
324
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const hasField = (
|
|
2
|
+
value: object,
|
|
3
|
+
key: string
|
|
4
|
+
): value is Record<string, unknown> => key in value;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns true when the fetched `detail` belongs to the current selection.
|
|
8
|
+
* The fetch lags selection, so it can briefly hold a previously selected span
|
|
9
|
+
* (even a different resource type); reject it before merging or its fields
|
|
10
|
+
* union into the wrong panel. Steps/hooks/waits carry their parent `runId`, so
|
|
11
|
+
* a `run` selection excludes objects identifiable as a child resource.
|
|
12
|
+
*/
|
|
13
|
+
export function spanDetailMatchesSelection(
|
|
14
|
+
detail: unknown,
|
|
15
|
+
resource: string | undefined,
|
|
16
|
+
resourceId: string | undefined
|
|
17
|
+
): boolean {
|
|
18
|
+
if (!detail || typeof detail !== 'object' || !resource || !resourceId) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
switch (resource) {
|
|
22
|
+
case 'step':
|
|
23
|
+
return hasField(detail, 'stepId') && detail.stepId === resourceId;
|
|
24
|
+
case 'hook':
|
|
25
|
+
return hasField(detail, 'hookId') && detail.hookId === resourceId;
|
|
26
|
+
case 'sleep':
|
|
27
|
+
return hasField(detail, 'waitId') && detail.waitId === resourceId;
|
|
28
|
+
case 'run':
|
|
29
|
+
return (
|
|
30
|
+
hasField(detail, 'runId') &&
|
|
31
|
+
!('stepId' in detail) &&
|
|
32
|
+
!('hookId' in detail) &&
|
|
33
|
+
!('waitId' in detail) &&
|
|
34
|
+
detail.runId === resourceId
|
|
35
|
+
);
|
|
36
|
+
default:
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Merges fetched `detail` over the span's own data. The detail supplies the
|
|
43
|
+
* heavy fields the trace strips (input/output/error/metadata, sleep resumeAt);
|
|
44
|
+
* the span's identity, status, and event-derived timestamps stay authoritative
|
|
45
|
+
* so they don't flicker to the backend row's millisecond-different values.
|
|
46
|
+
*/
|
|
47
|
+
export function mergeSpanDetail(spanData: unknown, detail: unknown): unknown {
|
|
48
|
+
if (!detail || typeof detail !== 'object') {
|
|
49
|
+
return spanData;
|
|
50
|
+
}
|
|
51
|
+
if (!spanData || typeof spanData !== 'object') {
|
|
52
|
+
return detail;
|
|
53
|
+
}
|
|
54
|
+
// Skip `undefined` span fields so they don't clobber a value the detail
|
|
55
|
+
// legitimately provides (e.g. a step's optional startedAt).
|
|
56
|
+
const merged: Record<string, unknown> = { ...detail };
|
|
57
|
+
for (const [key, value] of Object.entries(
|
|
58
|
+
spanData as Record<string, unknown>
|
|
59
|
+
)) {
|
|
60
|
+
if (value !== undefined) {
|
|
61
|
+
merged[key] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return merged;
|
|
65
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { formatDuration } from '../../../lib/utils';
|
|
1
|
+
import { formatDuration, formatDurationPrecise } from '../../../lib/utils';
|
|
2
2
|
|
|
3
|
-
export { formatDuration };
|
|
3
|
+
export { formatDuration, formatDurationPrecise };
|
|
4
4
|
|
|
5
5
|
export function getHighResInMs([seconds, nanoseconds]: [
|
|
6
6
|
number,
|
package/src/index.ts
CHANGED
package/src/lib/utils.ts
CHANGED
|
@@ -80,6 +80,63 @@ export function formatDuration(ms: number, compact = false): string {
|
|
|
80
80
|
return parts.join(' ');
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Locale-aware formatter that always renders exactly two fraction digits.
|
|
84
|
+
// Used for the seconds component of precise durations so values are never
|
|
85
|
+
// snapped to a whole second (and thousands separators stay correct).
|
|
86
|
+
const preciseSecondsFormatter = new Intl.NumberFormat(undefined, {
|
|
87
|
+
minimumFractionDigits: 2,
|
|
88
|
+
maximumFractionDigits: 2,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Formats a duration in milliseconds without snapping to whole seconds.
|
|
93
|
+
*
|
|
94
|
+
* Unlike {@link formatDuration}, this never rounds a sub-minute value up to
|
|
95
|
+
* the next whole second (e.g. 1626ms renders as "1.63s", not "2s").
|
|
96
|
+
*
|
|
97
|
+
* - < 1s: shows whole milliseconds (e.g. "626ms")
|
|
98
|
+
* - 1s – 1m: shows seconds with two decimals (e.g. "1.63s", "45.20s")
|
|
99
|
+
* - >= 1m: decomposes into d/h/m with two-decimal seconds (e.g. "1m 13.45s")
|
|
100
|
+
*/
|
|
101
|
+
export function formatDurationPrecise(ms: number): string {
|
|
102
|
+
if (ms === 0) {
|
|
103
|
+
return '0s';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (ms < MS_IN_SECOND) {
|
|
107
|
+
const roundedMs = Math.round(ms);
|
|
108
|
+
if (roundedMs < MS_IN_SECOND) {
|
|
109
|
+
return `${roundedMs}ms`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const normalizedMs = Math.round(ms / 10) * 10;
|
|
114
|
+
|
|
115
|
+
if (normalizedMs < MS_IN_MINUTE) {
|
|
116
|
+
return `${preciseSecondsFormatter.format(normalizedMs / MS_IN_SECOND)}s`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const days = Math.floor(normalizedMs / MS_IN_DAY);
|
|
120
|
+
const hours = Math.floor((normalizedMs % MS_IN_DAY) / MS_IN_HOUR);
|
|
121
|
+
const minutes = Math.floor((normalizedMs % MS_IN_HOUR) / MS_IN_MINUTE);
|
|
122
|
+
const seconds = (normalizedMs % MS_IN_MINUTE) / MS_IN_SECOND;
|
|
123
|
+
|
|
124
|
+
const parts: string[] = [];
|
|
125
|
+
|
|
126
|
+
if (days > 0) {
|
|
127
|
+
parts.push(`${days}d`);
|
|
128
|
+
}
|
|
129
|
+
if (hours > 0) {
|
|
130
|
+
parts.push(`${hours}h`);
|
|
131
|
+
}
|
|
132
|
+
if (minutes > 0) {
|
|
133
|
+
parts.push(`${minutes}m`);
|
|
134
|
+
}
|
|
135
|
+
parts.push(`${preciseSecondsFormatter.format(seconds)}s`);
|
|
136
|
+
|
|
137
|
+
return parts.join(' ');
|
|
138
|
+
}
|
|
139
|
+
|
|
83
140
|
/**
|
|
84
141
|
* Returns a formatted pagination display string
|
|
85
142
|
* @param currentPage - The current page number
|