Skip to content

Commit 5c6ae60

Browse files
[web-shared][web] Improve o11y UX with decoupled pagination, stream virtualization, and decrypt actions (vercel#1358)
* decouple data fetching between trace viewer and events tab * add load more button * add encryption button to events tab * virtualize streams tab * add decrypt button for stream * add changeset * fix agent comments * Fix events tab data fetching
1 parent 7df1385 commit 5c6ae60

12 files changed

Lines changed: 631 additions & 250 deletions

File tree

.changeset/flat-eels-dance.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@workflow/web-shared": patch
3+
"@workflow/web": patch
4+
---
5+
6+
Improve workflow observability UX with decoupled pagination, stream virtualization, and decrypt actions

packages/web-shared/src/components/event-list-view.tsx

Lines changed: 153 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import { Check, ChevronRight, Copy } from 'lucide-react';
66
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
77
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
88
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
9+
import { isEncryptedMarker } from '../lib/hydration';
10+
import { DecryptButton } from './ui/decrypt-button';
911
import { formatDuration } from '../lib/utils';
1012
import { DataInspector } from './ui/data-inspector';
1113
import {
1214
ErrorStackBlock,
1315
isStructuredErrorWithStack,
1416
} from './ui/error-stack-block';
17+
import { LoadMoreButton } from './ui/load-more-button';
1518
import { MenuDropdown } from './ui/menu-dropdown';
1619
import { Skeleton } from './ui/skeleton';
1720

@@ -596,6 +599,30 @@ const SORT_OPTIONS = [
596599
{ value: 'asc' as const, label: 'Oldest' },
597600
];
598601

602+
function RowsSkeleton() {
603+
return (
604+
<div className="flex-1 overflow-hidden">
605+
{Array.from({ length: 8 }, (_, i) => (
606+
<div
607+
key={i}
608+
className="flex items-center gap-3 px-4"
609+
style={{ height: 40 }}
610+
>
611+
<Skeleton
612+
className="h-2 w-2 flex-shrink-0"
613+
style={{ borderRadius: '50%' }}
614+
/>
615+
<Skeleton className="h-3" style={{ width: 90 }} />
616+
<Skeleton className="h-3" style={{ width: 100 }} />
617+
<Skeleton className="h-3" style={{ width: 80 }} />
618+
<Skeleton className="h-3 flex-1" />
619+
<Skeleton className="h-3 flex-1" />
620+
</div>
621+
))}
622+
</div>
623+
);
624+
}
625+
599626
// ──────────────────────────────────────────────────────────────────────────
600627
// Event row
601628
// ──────────────────────────────────────────────────────────────────────────
@@ -616,6 +643,10 @@ interface EventsListProps {
616643
/** Called when the user changes sort order. When provided, the sort dropdown is shown
617644
* and the parent is expected to refetch from the API with the new order. */
618645
onSortOrderChange?: (order: 'asc' | 'desc') => void;
646+
/** Called when the user clicks the Decrypt button. */
647+
onDecrypt?: () => void;
648+
/** Whether the encryption key is currently being fetched. */
649+
isDecrypting?: boolean;
619650
}
620651

621652
function EventRow({
@@ -1020,6 +1051,8 @@ export function EventListView({
10201051
isLoading = false,
10211052
sortOrder: sortOrderProp,
10221053
onSortOrderChange,
1054+
onDecrypt,
1055+
isDecrypting = false,
10231056
}: EventsListProps) {
10241057
const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>(
10251058
'asc'
@@ -1046,6 +1079,22 @@ export function EventListView({
10461079
);
10471080
}, [events, effectiveSortOrder]);
10481081

1082+
// Detect encrypted fields across all loaded events.
1083+
// Only checks top-level eventData values (input, output, result, etc.) —
1084+
// the current data model guarantees encrypted markers appear at this level.
1085+
const hasEncryptedData = useMemo(() => {
1086+
if (!events) return false;
1087+
for (const event of events) {
1088+
const ed = (event as Record<string, unknown>).eventData;
1089+
if (!ed || typeof ed !== 'object') continue;
1090+
const data = ed as Record<string, unknown>;
1091+
for (const val of Object.values(data)) {
1092+
if (isEncryptedMarker(val)) return true;
1093+
}
1094+
}
1095+
return false;
1096+
}, [events]);
1097+
10491098
const { correlationNameMap, workflowName } = useMemo(
10501099
() => buildNameMaps(events ?? null, run ?? null),
10511100
[events, run]
@@ -1201,52 +1250,43 @@ export function EventListView({
12011250
}
12021251
}, [searchQuery, searchIndex]);
12031252

1204-
if (!events || events.length === 0) {
1205-
if (isLoading) {
1206-
return (
1207-
<div className="h-full flex flex-col overflow-hidden">
1208-
{/* Skeleton search bar */}
1209-
<div style={{ padding: 6 }}>
1210-
<Skeleton style={{ height: 40, borderRadius: 6 }} />
1211-
</div>
1212-
{/* Skeleton header */}
1213-
<div
1214-
className="flex items-center gap-0 h-10 border-b flex-shrink-0 px-4"
1215-
style={{ borderColor: 'var(--ds-gray-alpha-200)' }}
1216-
>
1217-
<Skeleton className="h-3" style={{ width: 60 }} />
1218-
<div style={{ flex: 1 }} />
1219-
<Skeleton className="h-3" style={{ width: 80 }} />
1220-
<div style={{ flex: 1 }} />
1221-
<Skeleton className="h-3" style={{ width: 50 }} />
1222-
<div style={{ flex: 1 }} />
1223-
<Skeleton className="h-3" style={{ width: 90 }} />
1224-
<div style={{ flex: 1 }} />
1225-
<Skeleton className="h-3" style={{ width: 70 }} />
1226-
</div>
1227-
{/* Skeleton rows */}
1228-
<div className="flex-1 overflow-hidden">
1229-
{Array.from({ length: 8 }, (_, i) => (
1230-
<div
1231-
key={i}
1232-
className="flex items-center gap-3 px-4"
1233-
style={{ height: 40 }}
1234-
>
1235-
<Skeleton
1236-
className="h-2 w-2 flex-shrink-0"
1237-
style={{ borderRadius: '50%' }}
1238-
/>
1239-
<Skeleton className="h-3" style={{ width: 90 }} />
1240-
<Skeleton className="h-3" style={{ width: 100 }} />
1241-
<Skeleton className="h-3" style={{ width: 80 }} />
1242-
<Skeleton className="h-3 flex-1" />
1243-
<Skeleton className="h-3 flex-1" />
1244-
</div>
1245-
))}
1246-
</div>
1253+
// Track whether we've ever had events to distinguish initial load from refetch
1254+
const hasHadEventsRef = useRef(false);
1255+
if (sortedEvents.length > 0) {
1256+
hasHadEventsRef.current = true;
1257+
}
1258+
const isInitialLoad = isLoading && !hasHadEventsRef.current;
1259+
const isRefetching =
1260+
isLoading && hasHadEventsRef.current && sortedEvents.length === 0;
1261+
1262+
if (isInitialLoad) {
1263+
return (
1264+
<div className="h-full flex flex-col overflow-hidden">
1265+
{/* Skeleton search bar */}
1266+
<div style={{ padding: 6 }}>
1267+
<Skeleton style={{ height: 40, borderRadius: 6 }} />
12471268
</div>
1248-
);
1249-
}
1269+
{/* Skeleton header */}
1270+
<div
1271+
className="flex items-center gap-0 h-10 border-b flex-shrink-0 px-4"
1272+
style={{ borderColor: 'var(--ds-gray-alpha-200)' }}
1273+
>
1274+
<Skeleton className="h-3" style={{ width: 60 }} />
1275+
<div style={{ flex: 1 }} />
1276+
<Skeleton className="h-3" style={{ width: 80 }} />
1277+
<div style={{ flex: 1 }} />
1278+
<Skeleton className="h-3" style={{ width: 50 }} />
1279+
<div style={{ flex: 1 }} />
1280+
<Skeleton className="h-3" style={{ width: 90 }} />
1281+
<div style={{ flex: 1 }} />
1282+
<Skeleton className="h-3" style={{ width: 70 }} />
1283+
</div>
1284+
<RowsSkeleton />
1285+
</div>
1286+
);
1287+
}
1288+
1289+
if (!isLoading && (!events || events.length === 0)) {
12501290
return (
12511291
<div
12521292
className="flex items-center justify-center h-full text-sm"
@@ -1339,6 +1379,13 @@ export function EventListView({
13391379
value={effectiveSortOrder}
13401380
onChange={handleSortOrderChange}
13411381
/>
1382+
{(hasEncryptedData || encryptionKey) && onDecrypt && (
1383+
<DecryptButton
1384+
decrypted={!!encryptionKey}
1385+
loading={isDecrypting}
1386+
onClick={onDecrypt}
1387+
/>
1388+
)}
13421389
</div>
13431390

13441391
{/* Header */}
@@ -1369,82 +1416,75 @@ export function EventListView({
13691416
</div>
13701417
</div>
13711418

1372-
{/* Virtualized event rows */}
1373-
<Virtuoso
1374-
ref={virtuosoRef}
1375-
totalCount={sortedEvents.length}
1376-
overscan={20}
1377-
defaultItemHeight={40}
1378-
endReached={() => {
1379-
if (!hasMoreEvents || isLoadingMoreEvents) {
1380-
return;
1381-
}
1382-
void onLoadMoreEvents?.();
1383-
}}
1384-
itemContent={(index: number) => {
1385-
const ev = sortedEvents[index];
1386-
return (
1387-
<EventRow
1388-
event={ev}
1389-
index={index}
1390-
isFirst={index === 0}
1391-
isLast={index === sortedEvents.length - 1}
1392-
isExpanded={expandedEventIds.has(ev.eventId)}
1393-
onToggleExpand={toggleEventExpanded}
1394-
activeGroupKey={activeGroupKey}
1395-
selectedGroupKey={selectedGroupKey}
1396-
selectedGroupRange={selectedGroupRange}
1397-
correlationNameMap={correlationNameMap}
1398-
workflowName={workflowName}
1399-
durationMap={durationMap}
1400-
onSelectGroup={onSelectGroup}
1401-
onHoverGroup={onHoverGroup}
1402-
onLoadEventData={onLoadEventData}
1403-
cachedEventData={
1404-
eventDataCacheRef.current.get(ev.eventId) ?? null
1405-
}
1406-
onCacheEventData={cacheEventData}
1407-
encryptionKey={encryptionKey}
1408-
/>
1409-
);
1410-
}}
1411-
components={{
1412-
Footer: hasMoreEvents
1413-
? () => (
1414-
<div className="px-3 pt-3 flex justify-center">
1415-
<button
1416-
type="button"
1417-
onClick={() => void onLoadMoreEvents?.()}
1418-
disabled={isLoadingMoreEvents}
1419-
className="h-8 px-3 text-xs rounded-md border transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
1420-
style={{
1421-
borderColor: 'var(--ds-gray-alpha-400)',
1422-
color: 'var(--ds-gray-900)',
1423-
backgroundColor: 'var(--ds-background-100)',
1424-
}}
1425-
>
1426-
{isLoadingMoreEvents
1427-
? 'Loading more events...'
1428-
: 'Load more'}
1429-
</button>
1430-
</div>
1431-
)
1432-
: undefined,
1433-
}}
1434-
style={{ flex: 1, minHeight: 0 }}
1435-
/>
1419+
{/* Virtualized event rows or refetching skeleton */}
1420+
{isRefetching ? (
1421+
<RowsSkeleton />
1422+
) : (
1423+
<Virtuoso
1424+
ref={virtuosoRef}
1425+
totalCount={sortedEvents.length}
1426+
overscan={20}
1427+
defaultItemHeight={40}
1428+
endReached={() => {
1429+
if (!hasMoreEvents || isLoadingMoreEvents) {
1430+
return;
1431+
}
1432+
void onLoadMoreEvents?.();
1433+
}}
1434+
itemContent={(index: number) => {
1435+
const ev = sortedEvents[index];
1436+
return (
1437+
<EventRow
1438+
event={ev}
1439+
index={index}
1440+
isFirst={index === 0}
1441+
isLast={index === sortedEvents.length - 1}
1442+
isExpanded={expandedEventIds.has(ev.eventId)}
1443+
onToggleExpand={toggleEventExpanded}
1444+
activeGroupKey={activeGroupKey}
1445+
selectedGroupKey={selectedGroupKey}
1446+
selectedGroupRange={selectedGroupRange}
1447+
correlationNameMap={correlationNameMap}
1448+
workflowName={workflowName}
1449+
durationMap={durationMap}
1450+
onSelectGroup={onSelectGroup}
1451+
onHoverGroup={onHoverGroup}
1452+
onLoadEventData={onLoadEventData}
1453+
cachedEventData={
1454+
eventDataCacheRef.current.get(ev.eventId) ?? null
1455+
}
1456+
onCacheEventData={cacheEventData}
1457+
encryptionKey={encryptionKey}
1458+
/>
1459+
);
1460+
}}
1461+
style={{ flex: 1, minHeight: 0 }}
1462+
/>
1463+
)}
14361464

1437-
{/* Fixed footer — always at the bottom of the visible area */}
1465+
{/* Fixed footer — count + load more */}
14381466
<div
1439-
className="flex-shrink-0 border-t text-xs px-3 py-2"
1467+
className="relative flex-shrink-0 flex items-center h-10 border-t px-4 text-xs"
14401468
style={{
14411469
borderColor: 'var(--ds-gray-alpha-200)',
14421470
color: 'var(--ds-gray-900)',
14431471
backgroundColor: 'var(--ds-background-100)',
14441472
}}
14451473
>
1446-
{sortedEvents.length} event
1447-
{sortedEvents.length !== 1 ? 's' : ''} total
1474+
<span>
1475+
{sortedEvents.length} event
1476+
{sortedEvents.length !== 1 ? 's' : ''} loaded
1477+
</span>
1478+
{hasMoreEvents && (
1479+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
1480+
<div className="pointer-events-auto">
1481+
<LoadMoreButton
1482+
loading={isLoadingMoreEvents}
1483+
onClick={() => void onLoadMoreEvents?.()}
1484+
/>
1485+
</div>
1486+
</div>
1487+
)}
14481488
</div>
14491489
</div>
14501490
);

packages/web-shared/src/components/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,8 @@ export type {
2222
export { type StreamChunk, StreamViewer } from './stream-viewer';
2323
export type { Span, SpanEvent } from './trace-viewer/types';
2424
export { DataInspector, type DataInspectorProps } from './ui/data-inspector';
25+
export { DecryptButton } from './ui/decrypt-button';
26+
export { LoadMoreButton } from './ui/load-more-button';
2527
export { MenuDropdown, type MenuDropdownOption } from './ui/menu-dropdown';
28+
export { Spinner } from './ui/spinner';
2629
export { WorkflowTraceViewer } from './workflow-trace-view';

0 commit comments

Comments
 (0)