@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.
- package/package.json +67 -0
- package/src/App.tsx +44 -0
- package/src/hooks/ConnectionContext.tsx +213 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useConsoleApi.ts +926 -0
- package/src/hooks/useInstanceContext.ts +31 -0
- package/src/hooks/useLiveEvents.ts +267 -0
- package/src/hooks/useLocalStorage.ts +40 -0
- package/src/hooks/usePartitionContext.ts +31 -0
- package/src/hooks/usePreferences.ts +72 -0
- package/src/hooks/useRequestEvents.ts +35 -0
- package/src/hooks/useTimeRange.ts +34 -0
- package/src/index.ts +8 -0
- package/src/layout.tsx +240 -0
- package/src/lib/api.ts +26 -0
- package/src/lib/topology.ts +102 -0
- package/src/lib/types.ts +228 -0
- package/src/mount.tsx +39 -0
- package/src/pages/Command.tsx +382 -0
- package/src/pages/Config.tsx +1190 -0
- package/src/pages/Fleet.tsx +242 -0
- package/src/pages/Ops.tsx +753 -0
- package/src/pages/Stream.tsx +854 -0
- package/src/pages/index.ts +5 -0
- package/src/routeTree.ts +18 -0
- package/src/routes/__root.tsx +6 -0
- package/src/routes/config.tsx +9 -0
- package/src/routes/fleet.tsx +9 -0
- package/src/routes/index.tsx +9 -0
- package/src/routes/investigate-commit.tsx +14 -0
- package/src/routes/investigate-event.tsx +14 -0
- package/src/routes/ops.tsx +9 -0
- package/src/routes/stream.tsx +9 -0
- package/src/sentry.ts +70 -0
- package/src/styles/globals.css +1 -0
|
@@ -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
|
+
}
|