@workflow/web-shared 4.1.0-beta.64 → 4.1.0-beta.65
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 +5 -1
- package/dist/components/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +40 -19
- package/dist/components/event-list-view.js.map +1 -1
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +3 -0
- package/dist/components/index.js.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.d.ts +3 -1
- package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.js +4 -14
- package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
- package/dist/components/stream-viewer.d.ts +3 -1
- package/dist/components/stream-viewer.d.ts.map +1 -1
- package/dist/components/stream-viewer.js +23 -28
- package/dist/components/stream-viewer.js.map +1 -1
- package/dist/components/ui/decrypt-button.d.ts +15 -0
- package/dist/components/ui/decrypt-button.d.ts.map +1 -0
- package/dist/components/ui/decrypt-button.js +12 -0
- package/dist/components/ui/decrypt-button.js.map +1 -0
- package/dist/components/ui/load-more-button.d.ts +13 -0
- package/dist/components/ui/load-more-button.d.ts.map +1 -0
- package/dist/components/ui/load-more-button.js +12 -0
- package/dist/components/ui/load-more-button.js.map +1 -0
- package/dist/components/ui/menu-dropdown.d.ts.map +1 -1
- package/dist/components/ui/menu-dropdown.js +2 -6
- package/dist/components/ui/menu-dropdown.js.map +1 -1
- package/dist/components/ui/spinner.d.ts +9 -0
- package/dist/components/ui/spinner.d.ts.map +1 -0
- package/dist/components/ui/spinner.js +57 -0
- package/dist/components/ui/spinner.js.map +1 -0
- package/dist/components/workflow-trace-view.d.ts +3 -1
- package/dist/components/workflow-trace-view.d.ts.map +1 -1
- package/dist/components/workflow-trace-view.js +4 -3
- package/dist/components/workflow-trace-view.js.map +1 -1
- package/package.json +2 -2
- package/src/components/event-list-view.tsx +153 -113
- package/src/components/index.ts +3 -0
- package/src/components/sidebar/entity-detail-panel.tsx +9 -25
- package/src/components/stream-viewer.tsx +52 -63
- package/src/components/ui/decrypt-button.tsx +69 -0
- package/src/components/ui/load-more-button.tsx +38 -0
- package/src/components/ui/menu-dropdown.tsx +3 -6
- package/src/components/ui/spinner.tsx +76 -0
- package/src/components/workflow-trace-view.tsx +6 -19
|
@@ -6,12 +6,15 @@ import { Check, ChevronRight, Copy } from 'lucide-react';
|
|
|
6
6
|
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
|
|
7
7
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
8
8
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
|
9
|
+
import { isEncryptedMarker } from '../lib/hydration';
|
|
10
|
+
import { DecryptButton } from './ui/decrypt-button';
|
|
9
11
|
import { formatDuration } from '../lib/utils';
|
|
10
12
|
import { DataInspector } from './ui/data-inspector';
|
|
11
13
|
import {
|
|
12
14
|
ErrorStackBlock,
|
|
13
15
|
isStructuredErrorWithStack,
|
|
14
16
|
} from './ui/error-stack-block';
|
|
17
|
+
import { LoadMoreButton } from './ui/load-more-button';
|
|
15
18
|
import { MenuDropdown } from './ui/menu-dropdown';
|
|
16
19
|
import { Skeleton } from './ui/skeleton';
|
|
17
20
|
|
|
@@ -596,6 +599,30 @@ const SORT_OPTIONS = [
|
|
|
596
599
|
{ value: 'asc' as const, label: 'Oldest' },
|
|
597
600
|
];
|
|
598
601
|
|
|
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
|
+
|
|
599
626
|
// ──────────────────────────────────────────────────────────────────────────
|
|
600
627
|
// Event row
|
|
601
628
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -616,6 +643,10 @@ interface EventsListProps {
|
|
|
616
643
|
/** Called when the user changes sort order. When provided, the sort dropdown is shown
|
|
617
644
|
* and the parent is expected to refetch from the API with the new order. */
|
|
618
645
|
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;
|
|
619
650
|
}
|
|
620
651
|
|
|
621
652
|
function EventRow({
|
|
@@ -1020,6 +1051,8 @@ export function EventListView({
|
|
|
1020
1051
|
isLoading = false,
|
|
1021
1052
|
sortOrder: sortOrderProp,
|
|
1022
1053
|
onSortOrderChange,
|
|
1054
|
+
onDecrypt,
|
|
1055
|
+
isDecrypting = false,
|
|
1023
1056
|
}: EventsListProps) {
|
|
1024
1057
|
const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>(
|
|
1025
1058
|
'asc'
|
|
@@ -1046,6 +1079,22 @@ export function EventListView({
|
|
|
1046
1079
|
);
|
|
1047
1080
|
}, [events, effectiveSortOrder]);
|
|
1048
1081
|
|
|
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
|
+
|
|
1049
1098
|
const { correlationNameMap, workflowName } = useMemo(
|
|
1050
1099
|
() => buildNameMaps(events ?? null, run ?? null),
|
|
1051
1100
|
[events, run]
|
|
@@ -1201,52 +1250,43 @@ export function EventListView({
|
|
|
1201
1250
|
}
|
|
1202
1251
|
}, [searchQuery, searchIndex]);
|
|
1203
1252
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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 }} />
|
|
1247
1268
|
</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)) {
|
|
1250
1290
|
return (
|
|
1251
1291
|
<div
|
|
1252
1292
|
className="flex items-center justify-center h-full text-sm"
|
|
@@ -1339,6 +1379,13 @@ export function EventListView({
|
|
|
1339
1379
|
value={effectiveSortOrder}
|
|
1340
1380
|
onChange={handleSortOrderChange}
|
|
1341
1381
|
/>
|
|
1382
|
+
{(hasEncryptedData || encryptionKey) && onDecrypt && (
|
|
1383
|
+
<DecryptButton
|
|
1384
|
+
decrypted={!!encryptionKey}
|
|
1385
|
+
loading={isDecrypting}
|
|
1386
|
+
onClick={onDecrypt}
|
|
1387
|
+
/>
|
|
1388
|
+
)}
|
|
1342
1389
|
</div>
|
|
1343
1390
|
|
|
1344
1391
|
{/* Header */}
|
|
@@ -1369,82 +1416,75 @@ export function EventListView({
|
|
|
1369
1416
|
</div>
|
|
1370
1417
|
</div>
|
|
1371
1418
|
|
|
1372
|
-
{/* Virtualized event rows */}
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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
|
+
)}
|
|
1436
1464
|
|
|
1437
|
-
{/* Fixed footer —
|
|
1465
|
+
{/* Fixed footer — count + load more */}
|
|
1438
1466
|
<div
|
|
1439
|
-
className="flex-shrink-0 border-t
|
|
1467
|
+
className="relative flex-shrink-0 flex items-center h-10 border-t px-4 text-xs"
|
|
1440
1468
|
style={{
|
|
1441
1469
|
borderColor: 'var(--ds-gray-alpha-200)',
|
|
1442
1470
|
color: 'var(--ds-gray-900)',
|
|
1443
1471
|
backgroundColor: 'var(--ds-background-100)',
|
|
1444
1472
|
}}
|
|
1445
1473
|
>
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
+
)}
|
|
1448
1488
|
</div>
|
|
1449
1489
|
</div>
|
|
1450
1490
|
);
|
package/src/components/index.ts
CHANGED
|
@@ -22,5 +22,8 @@ export type {
|
|
|
22
22
|
export { type StreamChunk, StreamViewer } from './stream-viewer';
|
|
23
23
|
export type { Span, SpanEvent } from './trace-viewer/types';
|
|
24
24
|
export { DataInspector, type DataInspectorProps } from './ui/data-inspector';
|
|
25
|
+
export { DecryptButton } from './ui/decrypt-button';
|
|
26
|
+
export { LoadMoreButton } from './ui/load-more-button';
|
|
25
27
|
export { MenuDropdown, type MenuDropdownOption } from './ui/menu-dropdown';
|
|
28
|
+
export { Spinner } from './ui/spinner';
|
|
26
29
|
export { WorkflowTraceViewer } from './workflow-trace-view';
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
|
-
import {
|
|
5
|
+
import { Send, Zap } from 'lucide-react';
|
|
6
6
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
7
7
|
import { toast } from 'sonner';
|
|
8
8
|
import { isEncryptedMarker } from '../../lib/hydration';
|
|
9
|
+
import { DecryptButton } from '../ui/decrypt-button';
|
|
9
10
|
import { AttributePanel } from './attribute-panel';
|
|
10
11
|
import { EventsList } from './events-list';
|
|
11
12
|
import { ResolveHookModal } from './resolve-hook-modal';
|
|
@@ -64,6 +65,7 @@ export function EntityDetailPanel({
|
|
|
64
65
|
onResolveHook,
|
|
65
66
|
encryptionKey,
|
|
66
67
|
onDecrypt,
|
|
68
|
+
isDecrypting = false,
|
|
67
69
|
selectedSpan,
|
|
68
70
|
}: {
|
|
69
71
|
run: WorkflowRun;
|
|
@@ -97,6 +99,8 @@ export function EntityDetailPanel({
|
|
|
97
99
|
encryptionKey?: Uint8Array;
|
|
98
100
|
/** Callback to initiate decryption of encrypted run data */
|
|
99
101
|
onDecrypt?: () => void;
|
|
102
|
+
/** Whether the encryption key is currently being fetched */
|
|
103
|
+
isDecrypting?: boolean;
|
|
100
104
|
/** Info about the currently selected span from the trace viewer */
|
|
101
105
|
selectedSpan: SelectedSpanInfo | null;
|
|
102
106
|
}): React.JSX.Element | null {
|
|
@@ -381,31 +385,11 @@ export function EntityDetailPanel({
|
|
|
381
385
|
</p>
|
|
382
386
|
</div>
|
|
383
387
|
{(hasEncryptedFields || encryptionKey) && onDecrypt && (
|
|
384
|
-
<
|
|
385
|
-
|
|
388
|
+
<DecryptButton
|
|
389
|
+
decrypted={!!encryptionKey}
|
|
390
|
+
loading={isDecrypting}
|
|
386
391
|
onClick={onDecrypt}
|
|
387
|
-
|
|
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>
|
|
392
|
+
/>
|
|
409
393
|
)}
|
|
410
394
|
</div>
|
|
411
395
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, {
|
|
3
|
+
import React, { useEffect, useRef } from 'react';
|
|
4
|
+
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
|
4
5
|
import { DataInspector } from './ui/data-inspector';
|
|
5
6
|
import { Skeleton } from './ui/skeleton';
|
|
6
7
|
|
|
@@ -46,6 +47,8 @@ interface StreamViewerProps {
|
|
|
46
47
|
error?: string | null;
|
|
47
48
|
/** True while the initial stream connection is being established */
|
|
48
49
|
isLoading?: boolean;
|
|
50
|
+
/** Called when the user scrolls near the bottom, for triggering pagination */
|
|
51
|
+
onScrollEnd?: () => void;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -114,45 +117,41 @@ function StreamSkeleton() {
|
|
|
114
117
|
* of complex types (Map, Set, Date, custom classes, etc.).
|
|
115
118
|
*/
|
|
116
119
|
export function StreamViewer({
|
|
117
|
-
streamId,
|
|
120
|
+
streamId: _streamId,
|
|
118
121
|
chunks,
|
|
119
122
|
isLive,
|
|
120
123
|
error,
|
|
121
124
|
isLoading,
|
|
125
|
+
onScrollEnd,
|
|
122
126
|
}: StreamViewerProps) {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
const checkScrollPosition = useCallback(() => {
|
|
127
|
-
if (scrollRef.current) {
|
|
128
|
-
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
|
129
|
-
const isAtBottom = scrollHeight - scrollTop - clientHeight < 10;
|
|
130
|
-
setHasMoreBelow(!isAtBottom && scrollHeight > clientHeight);
|
|
131
|
-
}
|
|
132
|
-
}, []);
|
|
127
|
+
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
|
128
|
+
const prevChunkCountRef = useRef(0);
|
|
133
129
|
|
|
134
|
-
//
|
|
130
|
+
// Auto-scroll to bottom when new chunks arrive (live streaming)
|
|
135
131
|
useEffect(() => {
|
|
136
|
-
if (
|
|
137
|
-
|
|
132
|
+
if (chunks.length > prevChunkCountRef.current && chunks.length > 0) {
|
|
133
|
+
virtuosoRef.current?.scrollToIndex({
|
|
134
|
+
index: chunks.length - 1,
|
|
135
|
+
align: 'end',
|
|
136
|
+
});
|
|
138
137
|
}
|
|
139
|
-
|
|
140
|
-
}, [chunks.length
|
|
138
|
+
prevChunkCountRef.current = chunks.length;
|
|
139
|
+
}, [chunks.length]);
|
|
141
140
|
|
|
142
141
|
// Show skeleton when loading and no chunks have arrived yet
|
|
143
142
|
if (isLoading && chunks.length === 0) {
|
|
144
143
|
return (
|
|
145
|
-
<div className="flex flex-col h-full
|
|
144
|
+
<div className="flex flex-col h-full">
|
|
146
145
|
<StreamSkeleton />
|
|
147
146
|
</div>
|
|
148
147
|
);
|
|
149
148
|
}
|
|
150
149
|
|
|
151
150
|
return (
|
|
152
|
-
<div className="flex flex-col h-full
|
|
151
|
+
<div className="flex flex-col h-full">
|
|
153
152
|
{/* Live indicator */}
|
|
154
153
|
{isLive && (
|
|
155
|
-
<div className="flex items-center gap-1.5 mb-
|
|
154
|
+
<div className="flex items-center gap-1.5 mb-2 px-1">
|
|
156
155
|
<span
|
|
157
156
|
className="inline-block w-2 h-2 rounded-full"
|
|
158
157
|
style={{ backgroundColor: 'var(--ds-green-600)' }}
|
|
@@ -182,52 +181,42 @@ export function StreamViewer({
|
|
|
182
181
|
)}
|
|
183
182
|
|
|
184
183
|
{/* Content */}
|
|
185
|
-
<div className="
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
>
|
|
200
|
-
<div>Error reading stream:</div>
|
|
201
|
-
<div>{error}</div>
|
|
202
|
-
</div>
|
|
203
|
-
) : chunks.length === 0 ? (
|
|
204
|
-
<div
|
|
205
|
-
className="text-[11px] rounded-md border p-3"
|
|
206
|
-
style={{
|
|
207
|
-
borderColor: 'var(--ds-gray-300)',
|
|
208
|
-
backgroundColor: 'var(--ds-gray-100)',
|
|
209
|
-
color: 'var(--ds-gray-600)',
|
|
210
|
-
}}
|
|
211
|
-
>
|
|
212
|
-
{isLive ? 'Waiting for stream data...' : 'Stream is empty'}
|
|
213
|
-
</div>
|
|
214
|
-
) : (
|
|
215
|
-
chunks.map((chunk, index) => (
|
|
216
|
-
<ChunkRow
|
|
217
|
-
key={`${streamId}-chunk-${chunk.id}`}
|
|
218
|
-
chunk={chunk}
|
|
219
|
-
index={index}
|
|
220
|
-
/>
|
|
221
|
-
))
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
224
|
-
{hasMoreBelow && (
|
|
184
|
+
<div className="flex-1 min-h-0">
|
|
185
|
+
{error ? (
|
|
186
|
+
<div
|
|
187
|
+
className="text-[11px] rounded-md border p-3"
|
|
188
|
+
style={{
|
|
189
|
+
borderColor: 'var(--ds-red-300)',
|
|
190
|
+
backgroundColor: 'var(--ds-red-100)',
|
|
191
|
+
color: 'var(--ds-red-700)',
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<div>Error reading stream:</div>
|
|
195
|
+
<div>{error}</div>
|
|
196
|
+
</div>
|
|
197
|
+
) : chunks.length === 0 ? (
|
|
225
198
|
<div
|
|
226
|
-
className="
|
|
199
|
+
className="text-[11px] rounded-md border p-3"
|
|
227
200
|
style={{
|
|
228
|
-
|
|
229
|
-
|
|
201
|
+
borderColor: 'var(--ds-gray-300)',
|
|
202
|
+
backgroundColor: 'var(--ds-gray-100)',
|
|
203
|
+
color: 'var(--ds-gray-600)',
|
|
230
204
|
}}
|
|
205
|
+
>
|
|
206
|
+
{isLive ? 'Waiting for stream data...' : 'Stream is empty'}
|
|
207
|
+
</div>
|
|
208
|
+
) : (
|
|
209
|
+
<Virtuoso
|
|
210
|
+
ref={virtuosoRef}
|
|
211
|
+
totalCount={chunks.length}
|
|
212
|
+
overscan={10}
|
|
213
|
+
endReached={() => onScrollEnd?.()}
|
|
214
|
+
itemContent={(index) => (
|
|
215
|
+
<div style={{ paddingBottom: 8 }}>
|
|
216
|
+
<ChunkRow chunk={chunks[index]} index={index} />
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
style={{ flex: 1, minHeight: 0 }}
|
|
231
220
|
/>
|
|
232
221
|
)}
|
|
233
222
|
</div>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Spinner } from './spinner';
|
|
4
|
+
|
|
5
|
+
const STYLES = `.wf-decrypt-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-decrypt-idle{color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400)}.wf-decrypt-idle:hover{background:var(--ds-gray-alpha-200)}.wf-decrypt-done{color:var(--ds-green-900);background:var(--ds-green-100);box-shadow:0 0 0 1px var(--ds-green-400);cursor:default}`;
|
|
6
|
+
|
|
7
|
+
interface DecryptButtonProps {
|
|
8
|
+
/** Whether an encryption key has been obtained (decryption is active). */
|
|
9
|
+
decrypted?: boolean;
|
|
10
|
+
/** Whether the key is currently being fetched. */
|
|
11
|
+
loading?: boolean;
|
|
12
|
+
/** Called when the user clicks to initiate decryption. */
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Decrypt/Decrypted button using Geist secondary style.
|
|
18
|
+
* Three states: idle (secondary gray), decrypting (spinner), decrypted (green success).
|
|
19
|
+
*/
|
|
20
|
+
export function DecryptButton({
|
|
21
|
+
decrypted = false,
|
|
22
|
+
loading = false,
|
|
23
|
+
onClick,
|
|
24
|
+
}: DecryptButtonProps) {
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<style dangerouslySetInnerHTML={{ __html: STYLES }} />
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={decrypted ? undefined : onClick}
|
|
31
|
+
disabled={decrypted || loading}
|
|
32
|
+
className={`wf-decrypt-btn ${decrypted ? 'wf-decrypt-done' : 'wf-decrypt-idle'}`}
|
|
33
|
+
>
|
|
34
|
+
{loading ? (
|
|
35
|
+
<Spinner size={14} />
|
|
36
|
+
) : decrypted ? (
|
|
37
|
+
<svg
|
|
38
|
+
width={14}
|
|
39
|
+
height={14}
|
|
40
|
+
viewBox="0 0 24 24"
|
|
41
|
+
fill="none"
|
|
42
|
+
stroke="currentColor"
|
|
43
|
+
strokeWidth={2}
|
|
44
|
+
strokeLinecap="round"
|
|
45
|
+
strokeLinejoin="round"
|
|
46
|
+
>
|
|
47
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
48
|
+
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
|
49
|
+
</svg>
|
|
50
|
+
) : (
|
|
51
|
+
<svg
|
|
52
|
+
width={14}
|
|
53
|
+
height={14}
|
|
54
|
+
viewBox="0 0 24 24"
|
|
55
|
+
fill="none"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
strokeWidth={2}
|
|
58
|
+
strokeLinecap="round"
|
|
59
|
+
strokeLinejoin="round"
|
|
60
|
+
>
|
|
61
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
62
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
63
|
+
</svg>
|
|
64
|
+
)}
|
|
65
|
+
{loading ? 'Decrypting…' : decrypted ? 'Decrypted' : 'Decrypt'}
|
|
66
|
+
</button>
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
}
|