@syncular/console 0.0.0

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.
@@ -0,0 +1,854 @@
1
+ import type { FilterGroup, StreamOperation } from '@syncular/ui';
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ DialogContent,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ FilterBar,
10
+ Pagination,
11
+ Spinner,
12
+ StreamLog,
13
+ } from '@syncular/ui';
14
+ import { useEffect, useMemo, useRef, useState } from 'react';
15
+ import {
16
+ useClearEventsMutation,
17
+ useCommitDetail,
18
+ usePartitionContext,
19
+ usePreferences,
20
+ useRequestEventDetail,
21
+ useRequestEventPayload,
22
+ useTimeline,
23
+ useTimeRangeState,
24
+ } from '../hooks';
25
+ import type { ConsoleTimelineItem, TimeseriesRange } from '../lib/types';
26
+
27
+ type ViewMode = 'all' | 'commits' | 'events';
28
+ type EventTypeFilter = 'all' | 'push' | 'pull';
29
+ type OutcomeFilter = 'all' | 'applied' | 'error' | 'rejected';
30
+
31
+ function formatTime(iso: string, timeFormat: 'relative' | 'absolute'): string {
32
+ if (timeFormat === 'absolute') {
33
+ return new Date(iso).toLocaleString();
34
+ }
35
+
36
+ const date = new Date(iso);
37
+ const now = new Date();
38
+ const diffMs = now.getTime() - date.getTime();
39
+ const diffS = Math.floor(diffMs / 1000);
40
+
41
+ if (diffS < 60) return `${diffS}s`;
42
+ if (diffS < 3600) return `${Math.floor(diffS / 60)}m`;
43
+ if (diffS < 86400) return `${Math.floor(diffS / 3600)}h`;
44
+ return `${Math.floor(diffS / 86400)}d`;
45
+ }
46
+
47
+ interface StreamSearchTokens {
48
+ actorId?: string;
49
+ clientId?: string;
50
+ table?: string;
51
+ requestId?: string;
52
+ traceId?: string;
53
+ search?: string;
54
+ }
55
+
56
+ function parseStreamSearchTokens(value: string): StreamSearchTokens {
57
+ const tokens = value
58
+ .split(/\s+/)
59
+ .map((token) => token.trim())
60
+ .filter((token) => token.length > 0);
61
+
62
+ const parsed: StreamSearchTokens = {};
63
+ const freeTextTokens: string[] = [];
64
+
65
+ for (const token of tokens) {
66
+ const [rawPrefix = '', ...rest] = token.split(':');
67
+ const tokenValue = rest.join(':').trim();
68
+ const normalizedPrefix = rawPrefix.toLowerCase();
69
+
70
+ if (!tokenValue) {
71
+ freeTextTokens.push(token);
72
+ continue;
73
+ }
74
+
75
+ if (normalizedPrefix === 'actor') {
76
+ parsed.actorId = tokenValue;
77
+ continue;
78
+ }
79
+ if (normalizedPrefix === 'client') {
80
+ parsed.clientId = tokenValue;
81
+ continue;
82
+ }
83
+ if (normalizedPrefix === 'table') {
84
+ parsed.table = tokenValue;
85
+ continue;
86
+ }
87
+ if (normalizedPrefix === 'request') {
88
+ parsed.requestId = tokenValue;
89
+ continue;
90
+ }
91
+ if (normalizedPrefix === 'trace') {
92
+ parsed.traceId = tokenValue;
93
+ continue;
94
+ }
95
+
96
+ freeTextTokens.push(token);
97
+ }
98
+
99
+ if (freeTextTokens.length > 0) {
100
+ parsed.search = freeTextTokens.join(' ');
101
+ }
102
+
103
+ return parsed;
104
+ }
105
+
106
+ function rangeToWindowMs(range: TimeseriesRange): number {
107
+ if (range === '1h') return 60 * 60 * 1000;
108
+ if (range === '6h') return 6 * 60 * 60 * 1000;
109
+ if (range === '24h') return 24 * 60 * 60 * 1000;
110
+ if (range === '7d') return 7 * 24 * 60 * 60 * 1000;
111
+ return 30 * 24 * 60 * 60 * 1000;
112
+ }
113
+
114
+ function formatJson(value: unknown): string {
115
+ try {
116
+ return JSON.stringify(value, null, 2);
117
+ } catch {
118
+ return String(value);
119
+ }
120
+ }
121
+
122
+ function resolveCommitEntryId(
123
+ commit: ConsoleTimelineItem['commit'],
124
+ sourceInstanceId: string | undefined
125
+ ): string {
126
+ if (!commit) return '#?';
127
+ const token =
128
+ commit.federatedCommitId ??
129
+ (sourceInstanceId
130
+ ? `${sourceInstanceId}:${commit.commitSeq}`
131
+ : String(commit.commitSeq));
132
+ return `#${token}`;
133
+ }
134
+
135
+ function resolveEventEntryId(
136
+ event: ConsoleTimelineItem['event'],
137
+ sourceInstanceId: string | undefined
138
+ ): string {
139
+ if (!event) return 'E?';
140
+ const token =
141
+ event.federatedEventId ??
142
+ (sourceInstanceId ? `${sourceInstanceId}:${event.eventId}` : event.eventId);
143
+ return `E${token}`;
144
+ }
145
+
146
+ function buildTraceUrl(
147
+ template: string | undefined,
148
+ traceId: string | null,
149
+ spanId: string | null
150
+ ): string | null {
151
+ if (!template || !traceId) return null;
152
+ return template
153
+ .replaceAll('{traceId}', encodeURIComponent(traceId))
154
+ .replaceAll('{spanId}', encodeURIComponent(spanId ?? ''));
155
+ }
156
+
157
+ interface StreamProps {
158
+ initialSelectedEntryId?: string;
159
+ }
160
+
161
+ export function Stream({ initialSelectedEntryId }: StreamProps = {}) {
162
+ const { preferences } = usePreferences();
163
+ const { partitionId } = usePartitionContext();
164
+ const { range, setRange } = useTimeRangeState();
165
+ const pageSize = preferences.pageSize;
166
+ const refreshIntervalMs = preferences.refreshInterval * 1000;
167
+ const traceUrlTemplate: string | undefined = import.meta.env
168
+ ?.VITE_CONSOLE_TRACE_URL_TEMPLATE;
169
+
170
+ const [viewMode, setViewMode] = useState<ViewMode>(() => {
171
+ if (initialSelectedEntryId?.startsWith('#')) return 'commits';
172
+ if (initialSelectedEntryId?.startsWith('E')) return 'events';
173
+ return 'all';
174
+ });
175
+ const [eventTypeFilter, setEventTypeFilter] =
176
+ useState<EventTypeFilter>('all');
177
+ const [outcomeFilter, setOutcomeFilter] = useState<OutcomeFilter>('all');
178
+ const [searchValue, setSearchValue] = useState('');
179
+ const [page, setPage] = useState(1);
180
+
181
+ const [showClearConfirm, setShowClearConfirm] = useState(false);
182
+ const [selectedEntryId, setSelectedEntryId] = useState<string | null>(
183
+ initialSelectedEntryId ?? null
184
+ );
185
+ const hasHandledInitialSelectionReset = useRef(false);
186
+
187
+ const parsedSearch = useMemo(
188
+ () => parseStreamSearchTokens(searchValue),
189
+ [searchValue]
190
+ );
191
+ const from = useMemo(
192
+ () => new Date(Date.now() - rangeToWindowMs(range)).toISOString(),
193
+ [range]
194
+ );
195
+
196
+ const {
197
+ data: timelineData,
198
+ isLoading: timelineLoading,
199
+ refetch: refetchTimeline,
200
+ } = useTimeline(
201
+ {
202
+ limit: pageSize,
203
+ offset: (page - 1) * pageSize,
204
+ ...(partitionId ? { partitionId } : {}),
205
+ view: viewMode,
206
+ ...(viewMode !== 'commits' && eventTypeFilter !== 'all'
207
+ ? { eventType: eventTypeFilter }
208
+ : {}),
209
+ ...(viewMode !== 'commits' && outcomeFilter !== 'all'
210
+ ? { outcome: outcomeFilter }
211
+ : {}),
212
+ ...(parsedSearch.actorId ? { actorId: parsedSearch.actorId } : {}),
213
+ ...(parsedSearch.clientId ? { clientId: parsedSearch.clientId } : {}),
214
+ ...(parsedSearch.requestId ? { requestId: parsedSearch.requestId } : {}),
215
+ ...(parsedSearch.traceId ? { traceId: parsedSearch.traceId } : {}),
216
+ ...(parsedSearch.table ? { table: parsedSearch.table } : {}),
217
+ ...(parsedSearch.search ? { search: parsedSearch.search } : {}),
218
+ from,
219
+ },
220
+ { refetchIntervalMs: refreshIntervalMs }
221
+ );
222
+
223
+ const clearEvents = useClearEventsMutation();
224
+
225
+ const selectedCommitRef = selectedEntryId?.startsWith('#')
226
+ ? selectedEntryId.slice(1)
227
+ : undefined;
228
+ const selectedEventRef = selectedEntryId?.startsWith('E')
229
+ ? selectedEntryId.slice(1)
230
+ : undefined;
231
+ const normalizedSelectedCommitRef =
232
+ selectedCommitRef && selectedCommitRef !== '?'
233
+ ? selectedCommitRef
234
+ : undefined;
235
+ const normalizedSelectedEventRef =
236
+ selectedEventRef && selectedEventRef !== '?' ? selectedEventRef : undefined;
237
+
238
+ const {
239
+ data: selectedCommit,
240
+ isLoading: selectedCommitLoading,
241
+ error: selectedCommitError,
242
+ } = useCommitDetail(normalizedSelectedCommitRef, {
243
+ enabled: normalizedSelectedCommitRef !== undefined,
244
+ partitionId,
245
+ });
246
+ const {
247
+ data: selectedEvent,
248
+ isLoading: selectedEventLoading,
249
+ error: selectedEventError,
250
+ } = useRequestEventDetail(normalizedSelectedEventRef, {
251
+ enabled: normalizedSelectedEventRef !== undefined,
252
+ partitionId,
253
+ });
254
+ const {
255
+ data: selectedPayload,
256
+ isLoading: selectedPayloadLoading,
257
+ error: selectedPayloadError,
258
+ } = useRequestEventPayload(normalizedSelectedEventRef, {
259
+ enabled:
260
+ normalizedSelectedEventRef !== undefined &&
261
+ Boolean(selectedEvent?.payloadRef),
262
+ partitionId,
263
+ });
264
+ const selectedTraceUrl = useMemo(
265
+ () =>
266
+ buildTraceUrl(
267
+ traceUrlTemplate,
268
+ selectedEvent?.traceId ?? null,
269
+ selectedEvent?.spanId ?? null
270
+ ),
271
+ [selectedEvent?.spanId, selectedEvent?.traceId]
272
+ );
273
+
274
+ useEffect(() => {
275
+ setPage(1);
276
+ }, []);
277
+
278
+ useEffect(() => {
279
+ setPage(1);
280
+ }, []);
281
+
282
+ useEffect(() => {
283
+ if (initialSelectedEntryId) {
284
+ setSelectedEntryId(initialSelectedEntryId);
285
+ }
286
+ }, [initialSelectedEntryId]);
287
+
288
+ useEffect(() => {
289
+ if (!hasHandledInitialSelectionReset.current) {
290
+ hasHandledInitialSelectionReset.current = true;
291
+ return;
292
+ }
293
+ setSelectedEntryId(null);
294
+ }, []);
295
+
296
+ useEffect(() => {
297
+ setPage(1);
298
+ setSelectedEntryId(null);
299
+ }, []);
300
+
301
+ const baseEntries = useMemo((): StreamOperation[] => {
302
+ const items = timelineData?.items ?? [];
303
+ return items.map((item: ConsoleTimelineItem) => {
304
+ const sourceInstanceId =
305
+ item.instanceId ?? item.commit?.instanceId ?? item.event?.instanceId;
306
+ const sourcePrefix = sourceInstanceId ? `[${sourceInstanceId}] ` : '';
307
+
308
+ if (item.type === 'commit' && item.commit) {
309
+ const commit = item.commit;
310
+ return {
311
+ type: 'commit',
312
+ id: resolveCommitEntryId(commit, sourceInstanceId),
313
+ outcome: '--',
314
+ duration: '--',
315
+ actor: commit.actorId,
316
+ client: commit.clientId,
317
+ detail: `${sourcePrefix}${commit.changeCount} chg | ${(commit.affectedTables ?? []).join(', ')}`,
318
+ time: formatTime(item.timestamp, preferences.timeFormat),
319
+ };
320
+ }
321
+
322
+ const event = item.event;
323
+ if (!event) {
324
+ return {
325
+ type: 'pull',
326
+ id: 'E?',
327
+ outcome: 'unknown',
328
+ duration: '--',
329
+ actor: '',
330
+ client: '',
331
+ detail: '--',
332
+ time: formatTime(item.timestamp, preferences.timeFormat),
333
+ };
334
+ }
335
+
336
+ return {
337
+ type: event.eventType as 'push' | 'pull',
338
+ id: resolveEventEntryId(event, sourceInstanceId),
339
+ outcome: event.outcome,
340
+ duration: `${event.durationMs}ms`,
341
+ actor: event.actorId,
342
+ client: event.clientId,
343
+ detail: `${sourcePrefix}${(event.tables ?? []).join(', ') || '--'}`,
344
+ time: formatTime(item.timestamp, preferences.timeFormat),
345
+ };
346
+ });
347
+ }, [preferences.timeFormat, timelineData?.items]);
348
+
349
+ const streamEntries = baseEntries;
350
+
351
+ const totalItems = timelineData?.total ?? 0;
352
+
353
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
354
+
355
+ const isLoading = timelineLoading;
356
+
357
+ const filterGroups: FilterGroup[] = [
358
+ {
359
+ label: '',
360
+ options: [
361
+ { id: 'all', label: 'All' },
362
+ { id: 'commits', label: 'Commits' },
363
+ { id: 'events', label: 'Events' },
364
+ ],
365
+ activeId: viewMode,
366
+ onActiveChange: (id) => {
367
+ setViewMode(id as ViewMode);
368
+ if (id === 'commits') {
369
+ setEventTypeFilter('all');
370
+ setOutcomeFilter('all');
371
+ }
372
+ setPage(1);
373
+ },
374
+ },
375
+ {
376
+ label: 'Time',
377
+ options: [
378
+ { id: '1h', label: '1h' },
379
+ { id: '6h', label: '6h' },
380
+ { id: '24h', label: '24h' },
381
+ { id: '7d', label: '7d' },
382
+ { id: '30d', label: '30d' },
383
+ ],
384
+ activeId: range,
385
+ onActiveChange: (id) => {
386
+ setRange(id as TimeseriesRange);
387
+ setPage(1);
388
+ },
389
+ },
390
+ {
391
+ label: 'Type',
392
+ options: [
393
+ { id: 'all', label: 'All' },
394
+ { id: 'push', label: 'Push' },
395
+ { id: 'pull', label: 'Pull' },
396
+ ],
397
+ activeId: eventTypeFilter,
398
+ onActiveChange: (id) => {
399
+ setEventTypeFilter(id as EventTypeFilter);
400
+ setPage(1);
401
+ },
402
+ },
403
+ {
404
+ label: 'Outcome',
405
+ options: [
406
+ { id: 'all', label: 'All' },
407
+ { id: 'applied', label: 'Applied' },
408
+ { id: 'error', label: 'Error' },
409
+ { id: 'rejected', label: 'Rejected' },
410
+ ],
411
+ activeId: outcomeFilter,
412
+ onActiveChange: (id) => {
413
+ setOutcomeFilter(id as OutcomeFilter);
414
+ setPage(1);
415
+ },
416
+ },
417
+ ];
418
+
419
+ function handleClearEvents() {
420
+ clearEvents.mutate(undefined, {
421
+ onSuccess: () => {
422
+ setShowClearConfirm(false);
423
+ void refetchTimeline();
424
+ },
425
+ });
426
+ }
427
+
428
+ return (
429
+ <div className="flex flex-col h-full">
430
+ {isLoading && streamEntries.length === 0 ? (
431
+ <div className="flex items-center justify-center py-24">
432
+ <Spinner size="lg" />
433
+ </div>
434
+ ) : (
435
+ <StreamLog
436
+ entries={streamEntries}
437
+ selectedEntryId={selectedEntryId}
438
+ onEntryClick={(entry) => setSelectedEntryId(entry.id)}
439
+ filterBar={
440
+ <FilterBar
441
+ groups={filterGroups}
442
+ searchValue={searchValue}
443
+ searchPlaceholder="Use actor:, client:, table:, request:, trace: or free text..."
444
+ onSearchChange={setSearchValue}
445
+ actions={
446
+ <>
447
+ <Button
448
+ size="sm"
449
+ variant="ghost"
450
+ onClick={() => void refetchTimeline()}
451
+ >
452
+ Refresh
453
+ </Button>
454
+ <Button size="sm" variant="ghost">
455
+ Export
456
+ </Button>
457
+ <Button
458
+ size="sm"
459
+ variant="destructive"
460
+ onClick={() => setShowClearConfirm(true)}
461
+ >
462
+ Clear
463
+ </Button>
464
+ </>
465
+ }
466
+ />
467
+ }
468
+ pagination={
469
+ <Pagination
470
+ page={page}
471
+ totalPages={totalPages}
472
+ totalItems={totalItems}
473
+ onPageChange={setPage}
474
+ />
475
+ }
476
+ />
477
+ )}
478
+
479
+ <Dialog
480
+ open={selectedEntryId !== null}
481
+ onOpenChange={(open) => {
482
+ if (!open) setSelectedEntryId(null);
483
+ }}
484
+ >
485
+ <DialogContent>
486
+ <DialogHeader>
487
+ <DialogTitle>
488
+ {normalizedSelectedCommitRef !== undefined
489
+ ? `Commit #${normalizedSelectedCommitRef}`
490
+ : normalizedSelectedEventRef !== undefined
491
+ ? `Event E${normalizedSelectedEventRef}`
492
+ : 'Entry details'}
493
+ </DialogTitle>
494
+ </DialogHeader>
495
+
496
+ <div className="px-5 py-4 space-y-3 max-h-[70vh] overflow-y-auto">
497
+ {selectedCommitLoading || selectedEventLoading ? (
498
+ <div className="flex items-center justify-center py-8">
499
+ <Spinner size="sm" />
500
+ </div>
501
+ ) : selectedCommitError || selectedEventError ? (
502
+ <p className="font-mono text-[11px] text-offline">
503
+ Failed to load details.
504
+ </p>
505
+ ) : selectedCommit ? (
506
+ <>
507
+ <div className="grid grid-cols-2 gap-3 font-mono text-[11px]">
508
+ <div>
509
+ <span className="text-neutral-500">Actor</span>
510
+ <div className="text-neutral-100">
511
+ {selectedCommit.actorId}
512
+ </div>
513
+ </div>
514
+ <div>
515
+ <span className="text-neutral-500">Client</span>
516
+ <div className="text-neutral-100">
517
+ {selectedCommit.clientId}
518
+ </div>
519
+ </div>
520
+ <div>
521
+ <span className="text-neutral-500">Instance</span>
522
+ <div className="text-neutral-100">
523
+ {selectedCommit.instanceId ?? '--'}
524
+ </div>
525
+ </div>
526
+ <div>
527
+ <span className="text-neutral-500">Created</span>
528
+ <div className="text-neutral-100">
529
+ {formatTime(
530
+ selectedCommit.createdAt,
531
+ preferences.timeFormat
532
+ )}
533
+ </div>
534
+ </div>
535
+ <div>
536
+ <span className="text-neutral-500">Changes</span>
537
+ <div className="text-neutral-100">
538
+ {selectedCommit.changeCount}
539
+ </div>
540
+ </div>
541
+ </div>
542
+
543
+ <div>
544
+ <p className="font-mono text-[10px] uppercase tracking-wider text-neutral-500 mb-2">
545
+ Affected Tables
546
+ </p>
547
+ <p className="font-mono text-[11px] text-neutral-200">
548
+ {selectedCommit.affectedTables.join(', ') || '--'}
549
+ </p>
550
+ </div>
551
+
552
+ <div className="space-y-2">
553
+ <p className="font-mono text-[10px] uppercase tracking-wider text-neutral-500">
554
+ Changes
555
+ </p>
556
+ {selectedCommit.changes.length === 0 ? (
557
+ <p className="font-mono text-[11px] text-neutral-500">
558
+ No changes recorded.
559
+ </p>
560
+ ) : (
561
+ selectedCommit.changes.map((change) => (
562
+ <div
563
+ key={change.changeId}
564
+ className="rounded-md border border-border p-3 space-y-2"
565
+ >
566
+ <div className="flex items-center justify-between font-mono text-[11px]">
567
+ <span className="text-neutral-300">
568
+ {change.table} | {change.op}
569
+ </span>
570
+ <span className="text-neutral-500">
571
+ #{change.changeId}
572
+ </span>
573
+ </div>
574
+ <div className="font-mono text-[11px] text-neutral-400">
575
+ rowId: {change.rowId}
576
+ {change.rowVersion !== null
577
+ ? ` | version: ${change.rowVersion}`
578
+ : ''}
579
+ </div>
580
+ <pre className="font-mono text-[10px] rounded bg-surface p-2 overflow-x-auto text-neutral-200">
581
+ {formatJson(change.rowJson)}
582
+ </pre>
583
+ </div>
584
+ ))
585
+ )}
586
+ </div>
587
+ </>
588
+ ) : selectedEvent ? (
589
+ <>
590
+ <div className="grid grid-cols-2 gap-3 font-mono text-[11px]">
591
+ <div>
592
+ <span className="text-neutral-500">Type</span>
593
+ <div className="text-neutral-100">
594
+ {selectedEvent.eventType}
595
+ </div>
596
+ </div>
597
+ <div>
598
+ <span className="text-neutral-500">Path</span>
599
+ <div className="text-neutral-100">
600
+ {selectedEvent.syncPath}
601
+ </div>
602
+ </div>
603
+ <div>
604
+ <span className="text-neutral-500">Instance</span>
605
+ <div className="text-neutral-100">
606
+ {selectedEvent.instanceId ?? '--'}
607
+ </div>
608
+ </div>
609
+ <div>
610
+ <span className="text-neutral-500">Outcome</span>
611
+ <div className="text-neutral-100">
612
+ {selectedEvent.outcome}
613
+ </div>
614
+ </div>
615
+ <div>
616
+ <span className="text-neutral-500">Response Status</span>
617
+ <div className="text-neutral-100">
618
+ {selectedEvent.responseStatus}
619
+ </div>
620
+ </div>
621
+ <div>
622
+ <span className="text-neutral-500">Actor</span>
623
+ <div className="text-neutral-100">
624
+ {selectedEvent.actorId}
625
+ </div>
626
+ </div>
627
+ <div>
628
+ <span className="text-neutral-500">Client</span>
629
+ <div className="text-neutral-100">
630
+ {selectedEvent.clientId}
631
+ </div>
632
+ </div>
633
+ <div>
634
+ <span className="text-neutral-500">Status</span>
635
+ <div className="text-neutral-100">
636
+ {selectedEvent.statusCode}
637
+ </div>
638
+ </div>
639
+ <div>
640
+ <span className="text-neutral-500">Transport</span>
641
+ <div className="text-neutral-100">
642
+ {selectedEvent.transportPath}
643
+ </div>
644
+ </div>
645
+ <div>
646
+ <span className="text-neutral-500">Duration</span>
647
+ <div className="text-neutral-100">
648
+ {selectedEvent.durationMs}ms
649
+ </div>
650
+ </div>
651
+ <div>
652
+ <span className="text-neutral-500">Request ID</span>
653
+ <div className="text-neutral-100">
654
+ {selectedEvent.requestId}
655
+ </div>
656
+ </div>
657
+ <div>
658
+ <span className="text-neutral-500">Trace ID</span>
659
+ <div className="text-neutral-100">
660
+ {selectedEvent.traceId ?? '--'}
661
+ </div>
662
+ {selectedTraceUrl && (
663
+ <a
664
+ href={selectedTraceUrl}
665
+ target="_blank"
666
+ rel="noreferrer"
667
+ className="font-mono text-[10px] text-flow underline underline-offset-4"
668
+ >
669
+ Open external trace
670
+ </a>
671
+ )}
672
+ </div>
673
+ <div>
674
+ <span className="text-neutral-500">Span ID</span>
675
+ <div className="text-neutral-100">
676
+ {selectedEvent.spanId ?? '--'}
677
+ </div>
678
+ </div>
679
+ <div>
680
+ <span className="text-neutral-500">Commit Seq</span>
681
+ <div className="text-neutral-100">
682
+ {selectedEvent.commitSeq ?? '--'}
683
+ </div>
684
+ {selectedEvent.commitSeq !== null && (
685
+ <Button
686
+ variant="ghost"
687
+ size="sm"
688
+ onClick={() =>
689
+ setSelectedEntryId(
690
+ `#${selectedEvent.instanceId ? `${selectedEvent.instanceId}:` : ''}${selectedEvent.commitSeq}`
691
+ )
692
+ }
693
+ >
694
+ Open linked commit
695
+ </Button>
696
+ )}
697
+ </div>
698
+ <div>
699
+ <span className="text-neutral-500">Subscription Count</span>
700
+ <div className="text-neutral-100">
701
+ {selectedEvent.subscriptionCount ?? '--'}
702
+ </div>
703
+ </div>
704
+ <div>
705
+ <span className="text-neutral-500">Error Code</span>
706
+ <div className="text-neutral-100">
707
+ {selectedEvent.errorCode ?? '--'}
708
+ </div>
709
+ </div>
710
+ <div>
711
+ <span className="text-neutral-500">Payload Ref</span>
712
+ <div className="text-neutral-100">
713
+ {selectedEvent.payloadRef ?? '--'}
714
+ </div>
715
+ </div>
716
+ <div>
717
+ <span className="text-neutral-500">Created</span>
718
+ <div className="text-neutral-100">
719
+ {formatTime(
720
+ selectedEvent.createdAt,
721
+ preferences.timeFormat
722
+ )}
723
+ </div>
724
+ </div>
725
+ </div>
726
+
727
+ <div>
728
+ <p className="font-mono text-[10px] uppercase tracking-wider text-neutral-500 mb-2">
729
+ Tables
730
+ </p>
731
+ <p className="font-mono text-[11px] text-neutral-200">
732
+ {selectedEvent.tables.join(', ') || '--'}
733
+ </p>
734
+ </div>
735
+
736
+ {selectedEvent.scopesSummary && (
737
+ <div>
738
+ <p className="font-mono text-[10px] uppercase tracking-wider text-neutral-500 mb-2">
739
+ Scopes Summary
740
+ </p>
741
+ <pre className="font-mono text-[10px] rounded bg-surface p-2 overflow-x-auto text-neutral-200">
742
+ {formatJson(selectedEvent.scopesSummary)}
743
+ </pre>
744
+ </div>
745
+ )}
746
+
747
+ {selectedEvent.payloadRef && (
748
+ <div>
749
+ <p className="font-mono text-[10px] uppercase tracking-wider text-neutral-500 mb-2">
750
+ Payload Snapshot
751
+ </p>
752
+ {selectedPayloadLoading ? (
753
+ <div className="flex items-center gap-2">
754
+ <Spinner size="sm" />
755
+ <span className="font-mono text-[11px] text-neutral-400">
756
+ Loading payload snapshot...
757
+ </span>
758
+ </div>
759
+ ) : selectedPayloadError ? (
760
+ <p className="font-mono text-[11px] text-offline">
761
+ Failed to load payload snapshot.
762
+ </p>
763
+ ) : selectedPayload ? (
764
+ <div className="space-y-2">
765
+ <p className="font-mono text-[10px] uppercase tracking-wider text-neutral-500">
766
+ Request
767
+ </p>
768
+ <pre className="font-mono text-[10px] rounded bg-surface p-2 overflow-x-auto text-neutral-200">
769
+ {formatJson(selectedPayload.requestPayload)}
770
+ </pre>
771
+ <p className="font-mono text-[10px] uppercase tracking-wider text-neutral-500">
772
+ Response
773
+ </p>
774
+ <pre className="font-mono text-[10px] rounded bg-surface p-2 overflow-x-auto text-neutral-200">
775
+ {formatJson(selectedPayload.responsePayload)}
776
+ </pre>
777
+ </div>
778
+ ) : (
779
+ <p className="font-mono text-[11px] text-neutral-500">
780
+ No payload snapshot available.
781
+ </p>
782
+ )}
783
+ </div>
784
+ )}
785
+
786
+ {selectedEvent.errorMessage && (
787
+ <div>
788
+ <p className="font-mono text-[10px] uppercase tracking-wider text-neutral-500 mb-2">
789
+ Error
790
+ </p>
791
+ <p className="font-mono text-[11px] text-offline">
792
+ {selectedEvent.errorMessage}
793
+ </p>
794
+ </div>
795
+ )}
796
+ </>
797
+ ) : (
798
+ <p className="font-mono text-[11px] text-neutral-500">
799
+ No details available for this row.
800
+ </p>
801
+ )}
802
+ </div>
803
+
804
+ <DialogFooter>
805
+ <Button
806
+ variant="default"
807
+ size="sm"
808
+ onClick={() => setSelectedEntryId(null)}
809
+ >
810
+ Close
811
+ </Button>
812
+ </DialogFooter>
813
+ </DialogContent>
814
+ </Dialog>
815
+
816
+ {/* Clear confirmation dialog */}
817
+ <Dialog
818
+ open={showClearConfirm}
819
+ onOpenChange={(open) => {
820
+ if (!open) setShowClearConfirm(false);
821
+ }}
822
+ >
823
+ <DialogContent>
824
+ <DialogHeader>
825
+ <DialogTitle>Clear all events</DialogTitle>
826
+ </DialogHeader>
827
+ <div className="p-4">
828
+ <span className="font-mono text-[11px] text-neutral-400">
829
+ This will permanently delete all request events. Commits are not
830
+ affected. Are you sure?
831
+ </span>
832
+ </div>
833
+ <DialogFooter>
834
+ <Button
835
+ variant="default"
836
+ size="sm"
837
+ onClick={() => setShowClearConfirm(false)}
838
+ >
839
+ Cancel
840
+ </Button>
841
+ <Button
842
+ variant="destructive"
843
+ size="sm"
844
+ onClick={handleClearEvents}
845
+ disabled={clearEvents.isPending}
846
+ >
847
+ {clearEvents.isPending ? 'Clearing...' : 'Clear all'}
848
+ </Button>
849
+ </DialogFooter>
850
+ </DialogContent>
851
+ </Dialog>
852
+ </div>
853
+ );
854
+ }