@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,753 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AlertThresholds,
|
|
3
|
+
HandlerEntry,
|
|
4
|
+
MaintenanceStat,
|
|
5
|
+
} from '@syncular/ui';
|
|
6
|
+
import {
|
|
7
|
+
Alert,
|
|
8
|
+
AlertDescription,
|
|
9
|
+
AlertsConfig,
|
|
10
|
+
AlertTitle,
|
|
11
|
+
Badge,
|
|
12
|
+
Button,
|
|
13
|
+
Dialog,
|
|
14
|
+
DialogContent,
|
|
15
|
+
DialogFooter,
|
|
16
|
+
DialogHeader,
|
|
17
|
+
DialogTitle,
|
|
18
|
+
Field,
|
|
19
|
+
FieldDescription,
|
|
20
|
+
FieldLabel,
|
|
21
|
+
HandlersTable,
|
|
22
|
+
Input,
|
|
23
|
+
MaintenanceCard,
|
|
24
|
+
SectionCard,
|
|
25
|
+
Spinner,
|
|
26
|
+
Table,
|
|
27
|
+
TableBody,
|
|
28
|
+
TableCell,
|
|
29
|
+
TableHead,
|
|
30
|
+
TableHeader,
|
|
31
|
+
TableRow,
|
|
32
|
+
} from '@syncular/ui';
|
|
33
|
+
import { type ReactNode, useState } from 'react';
|
|
34
|
+
import {
|
|
35
|
+
useCompactMutation,
|
|
36
|
+
useHandlers,
|
|
37
|
+
useLocalStorage,
|
|
38
|
+
useNotifyDataChangeMutation,
|
|
39
|
+
useOperationEvents,
|
|
40
|
+
usePartitionContext,
|
|
41
|
+
usePruneMutation,
|
|
42
|
+
usePrunePreview,
|
|
43
|
+
useStats,
|
|
44
|
+
} from '../hooks';
|
|
45
|
+
import type {
|
|
46
|
+
ConsoleNotifyDataChangeResponse,
|
|
47
|
+
ConsoleOperationEvent,
|
|
48
|
+
ConsoleOperationType,
|
|
49
|
+
} from '../lib/types';
|
|
50
|
+
|
|
51
|
+
interface AlertConfig {
|
|
52
|
+
latencyThresholdMs: number;
|
|
53
|
+
errorRateThreshold: number;
|
|
54
|
+
clientLagThreshold: number;
|
|
55
|
+
enabled: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_ALERT_CONFIG: AlertConfig = {
|
|
59
|
+
latencyThresholdMs: 1000,
|
|
60
|
+
errorRateThreshold: 5,
|
|
61
|
+
clientLagThreshold: 50,
|
|
62
|
+
enabled: false,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function formatDuration(ms: number): string {
|
|
66
|
+
if (ms < 1000) return `${ms}ms`;
|
|
67
|
+
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
|
|
68
|
+
if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
|
|
69
|
+
return `${Math.round(ms / 3600000)}h`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseTableList(value: string): string[] {
|
|
73
|
+
const parts = value
|
|
74
|
+
.split(',')
|
|
75
|
+
.map((entry) => entry.trim())
|
|
76
|
+
.filter((entry) => entry.length > 0);
|
|
77
|
+
return Array.from(new Set(parts));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatOperationTypeLabel(type: ConsoleOperationType): string {
|
|
81
|
+
switch (type) {
|
|
82
|
+
case 'notify_data_change':
|
|
83
|
+
return 'Notify';
|
|
84
|
+
case 'evict_client':
|
|
85
|
+
return 'Evict';
|
|
86
|
+
case 'compact':
|
|
87
|
+
return 'Compact';
|
|
88
|
+
default:
|
|
89
|
+
return 'Prune';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function asObject(value: unknown): Record<string, unknown> | null {
|
|
94
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return value as Record<string, unknown>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function asStringArray(value: unknown): string[] {
|
|
101
|
+
if (!Array.isArray(value)) return [];
|
|
102
|
+
return value.filter((entry): entry is string => typeof entry === 'string');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function summarizeOperation(event: ConsoleOperationEvent): string {
|
|
106
|
+
const request = asObject(event.requestPayload);
|
|
107
|
+
const result = asObject(event.resultPayload);
|
|
108
|
+
|
|
109
|
+
if (event.operationType === 'notify_data_change') {
|
|
110
|
+
const tables = asStringArray(request?.tables);
|
|
111
|
+
const commitSeq = result?.commitSeq;
|
|
112
|
+
return `${tables.length} table${tables.length === 1 ? '' : 's'} -> commit #${typeof commitSeq === 'number' ? commitSeq : '?'}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (event.operationType === 'evict_client') {
|
|
116
|
+
const evicted = result?.evicted === true ? 'evicted' : 'not found';
|
|
117
|
+
return `client ${event.targetClientId ?? '?'} ${evicted}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (event.operationType === 'compact') {
|
|
121
|
+
const deletedChanges =
|
|
122
|
+
typeof result?.deletedChanges === 'number' ? result.deletedChanges : 0;
|
|
123
|
+
return `${deletedChanges} changes removed`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const deletedCommits =
|
|
127
|
+
typeof result?.deletedCommits === 'number' ? result.deletedCommits : 0;
|
|
128
|
+
const watermark =
|
|
129
|
+
typeof request?.watermarkCommitSeq === 'number'
|
|
130
|
+
? ` at #${request.watermarkCommitSeq}`
|
|
131
|
+
: '';
|
|
132
|
+
return `${deletedCommits} commits removed${watermark}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatDateTime(iso: string): string {
|
|
136
|
+
const parsed = Date.parse(iso);
|
|
137
|
+
if (!Number.isFinite(parsed)) return iso;
|
|
138
|
+
return new Date(parsed).toLocaleString();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function Ops() {
|
|
142
|
+
const { partitionId } = usePartitionContext();
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="flex flex-col gap-4 px-5 py-5">
|
|
146
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
147
|
+
<HandlersView />
|
|
148
|
+
<AlertsView />
|
|
149
|
+
</div>
|
|
150
|
+
<div className="grid gap-4 xl:grid-cols-3">
|
|
151
|
+
<PruneView partitionId={partitionId} />
|
|
152
|
+
<CompactView partitionId={partitionId} />
|
|
153
|
+
<NotifyDataChangeView partitionId={partitionId} />
|
|
154
|
+
</div>
|
|
155
|
+
<OperationsAuditView partitionId={partitionId} />
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function HandlersView() {
|
|
161
|
+
const { data, isLoading, error } = useHandlers();
|
|
162
|
+
|
|
163
|
+
if (isLoading) {
|
|
164
|
+
return (
|
|
165
|
+
<div className="flex items-center justify-center h-[200px]">
|
|
166
|
+
<Spinner size="lg" />
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (error) {
|
|
172
|
+
return (
|
|
173
|
+
<div className="flex items-center justify-center h-[200px]">
|
|
174
|
+
<p className="text-danger">Failed to load handlers: {error.message}</p>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const mapped: HandlerEntry[] = (data?.items ?? []).map((h) => ({
|
|
180
|
+
table: h.table,
|
|
181
|
+
dependsOn: h.dependsOn?.join(', ') ?? null,
|
|
182
|
+
chunkTtl: h.snapshotChunkTtlMs
|
|
183
|
+
? formatDuration(h.snapshotChunkTtlMs)
|
|
184
|
+
: 'default',
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<HandlersTable handlers={mapped} tableCount={data?.items.length ?? 0} />
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function PruneView({ partitionId }: { partitionId?: string }) {
|
|
193
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
194
|
+
const [lastResult, setLastResult] = useState<number | null>(null);
|
|
195
|
+
|
|
196
|
+
const { data: stats, isLoading: statsLoading } = useStats({ partitionId });
|
|
197
|
+
const {
|
|
198
|
+
data: prunePreview,
|
|
199
|
+
isLoading: previewLoading,
|
|
200
|
+
refetch: refetchPreview,
|
|
201
|
+
} = usePrunePreview({ enabled: false });
|
|
202
|
+
|
|
203
|
+
const pruneMutation = usePruneMutation();
|
|
204
|
+
|
|
205
|
+
const handleOpenModal = async () => {
|
|
206
|
+
setModalOpen(true);
|
|
207
|
+
setLastResult(null);
|
|
208
|
+
await refetchPreview();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const handlePrune = async () => {
|
|
212
|
+
const result = await pruneMutation.mutateAsync();
|
|
213
|
+
setLastResult(result.deletedCommits);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (statsLoading) {
|
|
217
|
+
return (
|
|
218
|
+
<div className="flex items-center justify-center h-[200px]">
|
|
219
|
+
<Spinner size="lg" />
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const pruneStats: {
|
|
225
|
+
label: string;
|
|
226
|
+
value: ReactNode;
|
|
227
|
+
tone?: 'default' | 'syncing';
|
|
228
|
+
}[] = [
|
|
229
|
+
{ label: 'Total commits', value: stats?.commitCount ?? 0 },
|
|
230
|
+
{
|
|
231
|
+
label: 'Commit range',
|
|
232
|
+
value: `${stats?.minCommitSeq ?? 0} - ${stats?.maxCommitSeq ?? 0}`,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
label: 'Min active cursor',
|
|
236
|
+
value: stats?.minActiveClientCursor ?? 'N/A',
|
|
237
|
+
tone: 'syncing',
|
|
238
|
+
},
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<>
|
|
243
|
+
<MaintenanceCard
|
|
244
|
+
title="Prune"
|
|
245
|
+
description="Delete commits that all clients have already synced. Pruning removes commits older than the oldest active client cursor."
|
|
246
|
+
dotColor="syncing"
|
|
247
|
+
stats={pruneStats}
|
|
248
|
+
actionLabel="Preview Prune"
|
|
249
|
+
actionVariant="destructive"
|
|
250
|
+
onAction={handleOpenModal}
|
|
251
|
+
/>
|
|
252
|
+
|
|
253
|
+
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
254
|
+
<DialogContent>
|
|
255
|
+
<DialogHeader>
|
|
256
|
+
<DialogTitle>Prune Old Commits</DialogTitle>
|
|
257
|
+
</DialogHeader>
|
|
258
|
+
|
|
259
|
+
<div className="px-5 py-4 flex flex-col gap-4">
|
|
260
|
+
{lastResult !== null ? (
|
|
261
|
+
<Alert variant="default">
|
|
262
|
+
<AlertTitle>Pruning Complete</AlertTitle>
|
|
263
|
+
<AlertDescription>
|
|
264
|
+
Successfully deleted <strong>{lastResult}</strong> commits.
|
|
265
|
+
</AlertDescription>
|
|
266
|
+
</Alert>
|
|
267
|
+
) : previewLoading ? (
|
|
268
|
+
<div className="flex items-center justify-center py-8">
|
|
269
|
+
<Spinner size="sm" />
|
|
270
|
+
</div>
|
|
271
|
+
) : prunePreview ? (
|
|
272
|
+
<>
|
|
273
|
+
<Alert
|
|
274
|
+
variant={
|
|
275
|
+
prunePreview.commitsToDelete > 0 ? 'destructive' : 'default'
|
|
276
|
+
}
|
|
277
|
+
>
|
|
278
|
+
<AlertDescription>
|
|
279
|
+
{prunePreview.commitsToDelete > 0 ? (
|
|
280
|
+
<>
|
|
281
|
+
This will delete{' '}
|
|
282
|
+
<strong>{prunePreview.commitsToDelete}</strong> commits
|
|
283
|
+
up to sequence{' '}
|
|
284
|
+
<code className="font-mono">
|
|
285
|
+
#{prunePreview.watermarkCommitSeq}
|
|
286
|
+
</code>
|
|
287
|
+
.
|
|
288
|
+
</>
|
|
289
|
+
) : (
|
|
290
|
+
'No commits are eligible for pruning.'
|
|
291
|
+
)}
|
|
292
|
+
</AlertDescription>
|
|
293
|
+
</Alert>
|
|
294
|
+
|
|
295
|
+
<div className="flex flex-col gap-2">
|
|
296
|
+
<div className="flex justify-between font-mono text-[11px]">
|
|
297
|
+
<span className="text-neutral-500">
|
|
298
|
+
Watermark commit seq:
|
|
299
|
+
</span>
|
|
300
|
+
<span className="text-white">
|
|
301
|
+
#{prunePreview.watermarkCommitSeq}
|
|
302
|
+
</span>
|
|
303
|
+
</div>
|
|
304
|
+
<div className="flex justify-between font-mono text-[11px] items-center">
|
|
305
|
+
<span className="text-neutral-500">Commits to delete:</span>
|
|
306
|
+
<Badge
|
|
307
|
+
variant={
|
|
308
|
+
prunePreview.commitsToDelete > 0 ? 'offline' : 'ghost'
|
|
309
|
+
}
|
|
310
|
+
>
|
|
311
|
+
{prunePreview.commitsToDelete}
|
|
312
|
+
</Badge>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
</>
|
|
316
|
+
) : null}
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<DialogFooter>
|
|
320
|
+
<Button variant="default" onClick={() => setModalOpen(false)}>
|
|
321
|
+
{lastResult !== null ? 'Close' : 'Cancel'}
|
|
322
|
+
</Button>
|
|
323
|
+
{lastResult === null && (
|
|
324
|
+
<Button
|
|
325
|
+
variant="destructive"
|
|
326
|
+
onClick={handlePrune}
|
|
327
|
+
disabled={
|
|
328
|
+
pruneMutation.isPending ||
|
|
329
|
+
previewLoading ||
|
|
330
|
+
(prunePreview?.commitsToDelete ?? 0) === 0
|
|
331
|
+
}
|
|
332
|
+
>
|
|
333
|
+
{pruneMutation.isPending ? (
|
|
334
|
+
<>
|
|
335
|
+
<Spinner size="sm" />
|
|
336
|
+
Pruning...
|
|
337
|
+
</>
|
|
338
|
+
) : (
|
|
339
|
+
'Prune Now'
|
|
340
|
+
)}
|
|
341
|
+
</Button>
|
|
342
|
+
)}
|
|
343
|
+
</DialogFooter>
|
|
344
|
+
</DialogContent>
|
|
345
|
+
</Dialog>
|
|
346
|
+
</>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function CompactView({ partitionId }: { partitionId?: string }) {
|
|
351
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
352
|
+
const [lastResult, setLastResult] = useState<number | null>(null);
|
|
353
|
+
|
|
354
|
+
const { data: stats, isLoading: statsLoading } = useStats({ partitionId });
|
|
355
|
+
const compactMutation = useCompactMutation();
|
|
356
|
+
|
|
357
|
+
const handleOpenModal = () => {
|
|
358
|
+
setModalOpen(true);
|
|
359
|
+
setLastResult(null);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const handleCompact = async () => {
|
|
363
|
+
const result = await compactMutation.mutateAsync();
|
|
364
|
+
setLastResult(result.deletedChanges);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
if (statsLoading) {
|
|
368
|
+
return (
|
|
369
|
+
<div className="flex items-center justify-center h-[200px]">
|
|
370
|
+
<Spinner size="lg" />
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const compactStats: {
|
|
376
|
+
label: string;
|
|
377
|
+
value: ReactNode;
|
|
378
|
+
tone?: 'default' | 'syncing';
|
|
379
|
+
}[] = [
|
|
380
|
+
{ label: 'Total changes', value: stats?.changeCount ?? 0 },
|
|
381
|
+
{ label: 'Total commits', value: stats?.commitCount ?? 0 },
|
|
382
|
+
];
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<>
|
|
386
|
+
<MaintenanceCard
|
|
387
|
+
title="Compact"
|
|
388
|
+
description="Merge old changes to reduce storage space. Compaction merges multiple changes to the same row into a single change."
|
|
389
|
+
dotColor="flow"
|
|
390
|
+
stats={compactStats}
|
|
391
|
+
actionLabel="Run Compaction"
|
|
392
|
+
actionVariant="primary"
|
|
393
|
+
onAction={handleOpenModal}
|
|
394
|
+
/>
|
|
395
|
+
|
|
396
|
+
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
397
|
+
<DialogContent>
|
|
398
|
+
<DialogHeader>
|
|
399
|
+
<DialogTitle>Compact Changes</DialogTitle>
|
|
400
|
+
</DialogHeader>
|
|
401
|
+
|
|
402
|
+
<div className="px-5 py-4 flex flex-col gap-4">
|
|
403
|
+
{lastResult !== null ? (
|
|
404
|
+
<Alert variant="default">
|
|
405
|
+
<AlertTitle>Compaction Complete</AlertTitle>
|
|
406
|
+
<AlertDescription>
|
|
407
|
+
Successfully removed <strong>{lastResult}</strong> redundant
|
|
408
|
+
changes.
|
|
409
|
+
</AlertDescription>
|
|
410
|
+
</Alert>
|
|
411
|
+
) : (
|
|
412
|
+
<Alert variant="default">
|
|
413
|
+
<AlertDescription>
|
|
414
|
+
Compaction will merge multiple changes to the same row,
|
|
415
|
+
keeping only the most recent version. This is safe and can be
|
|
416
|
+
run at any time.
|
|
417
|
+
</AlertDescription>
|
|
418
|
+
</Alert>
|
|
419
|
+
)}
|
|
420
|
+
</div>
|
|
421
|
+
|
|
422
|
+
<DialogFooter>
|
|
423
|
+
<Button variant="default" onClick={() => setModalOpen(false)}>
|
|
424
|
+
{lastResult !== null ? 'Close' : 'Cancel'}
|
|
425
|
+
</Button>
|
|
426
|
+
{lastResult === null && (
|
|
427
|
+
<Button
|
|
428
|
+
variant="primary"
|
|
429
|
+
onClick={handleCompact}
|
|
430
|
+
disabled={compactMutation.isPending}
|
|
431
|
+
>
|
|
432
|
+
{compactMutation.isPending ? (
|
|
433
|
+
<>
|
|
434
|
+
<Spinner size="sm" />
|
|
435
|
+
Compacting...
|
|
436
|
+
</>
|
|
437
|
+
) : (
|
|
438
|
+
'Compact Now'
|
|
439
|
+
)}
|
|
440
|
+
</Button>
|
|
441
|
+
)}
|
|
442
|
+
</DialogFooter>
|
|
443
|
+
</DialogContent>
|
|
444
|
+
</Dialog>
|
|
445
|
+
</>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function NotifyDataChangeView({ partitionId }: { partitionId?: string }) {
|
|
450
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
451
|
+
const [tablesInput, setTablesInput] = useState('tasks');
|
|
452
|
+
const [partitionIdInput, setPartitionIdInput] = useState(partitionId ?? '');
|
|
453
|
+
const [lastResult, setLastResult] =
|
|
454
|
+
useState<ConsoleNotifyDataChangeResponse | null>(null);
|
|
455
|
+
const [validationMessage, setValidationMessage] = useState<string | null>(
|
|
456
|
+
null
|
|
457
|
+
);
|
|
458
|
+
const notifyMutation = useNotifyDataChangeMutation();
|
|
459
|
+
|
|
460
|
+
const tables = parseTableList(tablesInput);
|
|
461
|
+
const notifyStats: MaintenanceStat[] = [
|
|
462
|
+
{
|
|
463
|
+
label: 'Next tables',
|
|
464
|
+
value: tables.length,
|
|
465
|
+
tone: 'syncing',
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
label: 'Last commit seq',
|
|
469
|
+
value: lastResult ? `#${lastResult.commitSeq}` : '—',
|
|
470
|
+
tone: 'syncing',
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
label: 'Last chunks cleared',
|
|
474
|
+
value: lastResult?.deletedChunks ?? '—',
|
|
475
|
+
},
|
|
476
|
+
];
|
|
477
|
+
|
|
478
|
+
const handleOpenModal = () => {
|
|
479
|
+
setModalOpen(true);
|
|
480
|
+
setValidationMessage(null);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const handleNotify = async () => {
|
|
484
|
+
if (tables.length === 0) {
|
|
485
|
+
setValidationMessage('Provide at least one table name.');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
setValidationMessage(null);
|
|
490
|
+
const result = await notifyMutation.mutateAsync({
|
|
491
|
+
tables,
|
|
492
|
+
partitionId: partitionIdInput.trim() || undefined,
|
|
493
|
+
});
|
|
494
|
+
setLastResult(result);
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
return (
|
|
498
|
+
<>
|
|
499
|
+
<MaintenanceCard
|
|
500
|
+
title="Notify Data Change"
|
|
501
|
+
description="Create a synthetic commit after external imports or direct DB writes so clients re-bootstrap for affected tables."
|
|
502
|
+
dotColor="healthy"
|
|
503
|
+
stats={notifyStats}
|
|
504
|
+
actionLabel="Notify Clients"
|
|
505
|
+
actionVariant="primary"
|
|
506
|
+
onAction={handleOpenModal}
|
|
507
|
+
/>
|
|
508
|
+
|
|
509
|
+
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
510
|
+
<DialogContent>
|
|
511
|
+
<DialogHeader>
|
|
512
|
+
<DialogTitle>Notify External Data Change</DialogTitle>
|
|
513
|
+
</DialogHeader>
|
|
514
|
+
|
|
515
|
+
<div className="flex flex-col gap-4 px-5 py-4">
|
|
516
|
+
{lastResult ? (
|
|
517
|
+
<Alert variant="default">
|
|
518
|
+
<AlertTitle>Notification Sent</AlertTitle>
|
|
519
|
+
<AlertDescription>
|
|
520
|
+
Created synthetic commit{' '}
|
|
521
|
+
<strong>#{lastResult.commitSeq}</strong> for{' '}
|
|
522
|
+
<strong>{lastResult.tables.length}</strong> table
|
|
523
|
+
{lastResult.tables.length === 1 ? '' : 's'} and cleared{' '}
|
|
524
|
+
<strong>{lastResult.deletedChunks}</strong> cached chunk
|
|
525
|
+
{lastResult.deletedChunks === 1 ? '' : 's'}.
|
|
526
|
+
</AlertDescription>
|
|
527
|
+
</Alert>
|
|
528
|
+
) : (
|
|
529
|
+
<Alert variant="default">
|
|
530
|
+
<AlertDescription>
|
|
531
|
+
Use this when data changed outside Syncular push flow. It
|
|
532
|
+
invalidates cached snapshot chunks and forces clients to pull
|
|
533
|
+
fresh data.
|
|
534
|
+
</AlertDescription>
|
|
535
|
+
</Alert>
|
|
536
|
+
)}
|
|
537
|
+
|
|
538
|
+
{validationMessage ? (
|
|
539
|
+
<Alert variant="destructive">
|
|
540
|
+
<AlertDescription>{validationMessage}</AlertDescription>
|
|
541
|
+
</Alert>
|
|
542
|
+
) : null}
|
|
543
|
+
|
|
544
|
+
<Field>
|
|
545
|
+
<FieldLabel>Tables (comma-separated)</FieldLabel>
|
|
546
|
+
<Input
|
|
547
|
+
value={tablesInput}
|
|
548
|
+
onChange={(event) => setTablesInput(event.target.value)}
|
|
549
|
+
placeholder="tasks, notes"
|
|
550
|
+
disabled={notifyMutation.isPending}
|
|
551
|
+
/>
|
|
552
|
+
<FieldDescription>
|
|
553
|
+
Enter one or more table names affected by the external change.
|
|
554
|
+
</FieldDescription>
|
|
555
|
+
</Field>
|
|
556
|
+
|
|
557
|
+
<Field>
|
|
558
|
+
<FieldLabel>Partition ID (optional)</FieldLabel>
|
|
559
|
+
<Input
|
|
560
|
+
value={partitionIdInput}
|
|
561
|
+
onChange={(event) => setPartitionIdInput(event.target.value)}
|
|
562
|
+
placeholder="default"
|
|
563
|
+
disabled={notifyMutation.isPending}
|
|
564
|
+
/>
|
|
565
|
+
</Field>
|
|
566
|
+
</div>
|
|
567
|
+
|
|
568
|
+
<DialogFooter>
|
|
569
|
+
<Button variant="default" onClick={() => setModalOpen(false)}>
|
|
570
|
+
{lastResult ? 'Close' : 'Cancel'}
|
|
571
|
+
</Button>
|
|
572
|
+
{!lastResult ? (
|
|
573
|
+
<Button
|
|
574
|
+
variant="primary"
|
|
575
|
+
onClick={handleNotify}
|
|
576
|
+
disabled={notifyMutation.isPending}
|
|
577
|
+
>
|
|
578
|
+
{notifyMutation.isPending ? (
|
|
579
|
+
<>
|
|
580
|
+
<Spinner size="sm" />
|
|
581
|
+
Notifying...
|
|
582
|
+
</>
|
|
583
|
+
) : (
|
|
584
|
+
'Notify Data Change'
|
|
585
|
+
)}
|
|
586
|
+
</Button>
|
|
587
|
+
) : null}
|
|
588
|
+
</DialogFooter>
|
|
589
|
+
</DialogContent>
|
|
590
|
+
</Dialog>
|
|
591
|
+
</>
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function OperationsAuditView({ partitionId }: { partitionId?: string }) {
|
|
596
|
+
const [operationTypeFilter, setOperationTypeFilter] = useState<
|
|
597
|
+
ConsoleOperationType | 'all'
|
|
598
|
+
>('all');
|
|
599
|
+
|
|
600
|
+
const { data, isLoading, error } = useOperationEvents(
|
|
601
|
+
{
|
|
602
|
+
limit: 20,
|
|
603
|
+
offset: 0,
|
|
604
|
+
operationType:
|
|
605
|
+
operationTypeFilter === 'all' ? undefined : operationTypeFilter,
|
|
606
|
+
partitionId,
|
|
607
|
+
},
|
|
608
|
+
{ refetchIntervalMs: 5000 }
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
return (
|
|
612
|
+
<SectionCard
|
|
613
|
+
title="Operation Audit"
|
|
614
|
+
description="Recent prune/compact/notify/evict actions with actor and result context."
|
|
615
|
+
actions={
|
|
616
|
+
<div className="flex items-center gap-2">
|
|
617
|
+
<Button
|
|
618
|
+
variant={operationTypeFilter === 'all' ? 'default' : 'ghost'}
|
|
619
|
+
size="sm"
|
|
620
|
+
onClick={() => setOperationTypeFilter('all')}
|
|
621
|
+
>
|
|
622
|
+
All
|
|
623
|
+
</Button>
|
|
624
|
+
<Button
|
|
625
|
+
variant={operationTypeFilter === 'prune' ? 'default' : 'ghost'}
|
|
626
|
+
size="sm"
|
|
627
|
+
onClick={() => setOperationTypeFilter('prune')}
|
|
628
|
+
>
|
|
629
|
+
Prune
|
|
630
|
+
</Button>
|
|
631
|
+
<Button
|
|
632
|
+
variant={operationTypeFilter === 'compact' ? 'default' : 'ghost'}
|
|
633
|
+
size="sm"
|
|
634
|
+
onClick={() => setOperationTypeFilter('compact')}
|
|
635
|
+
>
|
|
636
|
+
Compact
|
|
637
|
+
</Button>
|
|
638
|
+
<Button
|
|
639
|
+
variant={
|
|
640
|
+
operationTypeFilter === 'notify_data_change' ? 'default' : 'ghost'
|
|
641
|
+
}
|
|
642
|
+
size="sm"
|
|
643
|
+
onClick={() => setOperationTypeFilter('notify_data_change')}
|
|
644
|
+
>
|
|
645
|
+
Notify
|
|
646
|
+
</Button>
|
|
647
|
+
<Button
|
|
648
|
+
variant={
|
|
649
|
+
operationTypeFilter === 'evict_client' ? 'default' : 'ghost'
|
|
650
|
+
}
|
|
651
|
+
size="sm"
|
|
652
|
+
onClick={() => setOperationTypeFilter('evict_client')}
|
|
653
|
+
>
|
|
654
|
+
Evict
|
|
655
|
+
</Button>
|
|
656
|
+
</div>
|
|
657
|
+
}
|
|
658
|
+
contentClassName="pt-2"
|
|
659
|
+
>
|
|
660
|
+
{isLoading ? (
|
|
661
|
+
<div className="flex items-center justify-center py-10">
|
|
662
|
+
<Spinner size="lg" />
|
|
663
|
+
</div>
|
|
664
|
+
) : error ? (
|
|
665
|
+
<Alert variant="destructive">
|
|
666
|
+
<AlertDescription>
|
|
667
|
+
Failed to load operation audit events: {error.message}
|
|
668
|
+
</AlertDescription>
|
|
669
|
+
</Alert>
|
|
670
|
+
) : (data?.items.length ?? 0) === 0 ? (
|
|
671
|
+
<div className="px-2 py-8 text-sm text-neutral-500">
|
|
672
|
+
No operation events found for this filter.
|
|
673
|
+
</div>
|
|
674
|
+
) : (
|
|
675
|
+
<div className="overflow-x-auto">
|
|
676
|
+
<Table>
|
|
677
|
+
<TableHeader>
|
|
678
|
+
<TableRow>
|
|
679
|
+
<TableHead>Time</TableHead>
|
|
680
|
+
<TableHead>Type</TableHead>
|
|
681
|
+
<TableHead>User</TableHead>
|
|
682
|
+
<TableHead>Target</TableHead>
|
|
683
|
+
<TableHead>Result</TableHead>
|
|
684
|
+
</TableRow>
|
|
685
|
+
</TableHeader>
|
|
686
|
+
<TableBody>
|
|
687
|
+
{(data?.items ?? []).map((event) => (
|
|
688
|
+
<TableRow key={event.operationId}>
|
|
689
|
+
<TableCell className="whitespace-nowrap text-xs text-neutral-400">
|
|
690
|
+
{formatDateTime(event.createdAt)}
|
|
691
|
+
</TableCell>
|
|
692
|
+
<TableCell>
|
|
693
|
+
<Badge variant="ghost">
|
|
694
|
+
{formatOperationTypeLabel(event.operationType)}
|
|
695
|
+
</Badge>
|
|
696
|
+
</TableCell>
|
|
697
|
+
<TableCell className="font-mono text-xs">
|
|
698
|
+
{event.consoleUserId ?? 'system'}
|
|
699
|
+
</TableCell>
|
|
700
|
+
<TableCell className="font-mono text-xs">
|
|
701
|
+
{event.targetClientId ??
|
|
702
|
+
event.partitionId ??
|
|
703
|
+
(event.operationType === 'notify_data_change'
|
|
704
|
+
? 'partition default'
|
|
705
|
+
: 'global')}
|
|
706
|
+
</TableCell>
|
|
707
|
+
<TableCell className="font-mono text-xs text-neutral-300">
|
|
708
|
+
{summarizeOperation(event)}
|
|
709
|
+
</TableCell>
|
|
710
|
+
</TableRow>
|
|
711
|
+
))}
|
|
712
|
+
</TableBody>
|
|
713
|
+
</Table>
|
|
714
|
+
</div>
|
|
715
|
+
)}
|
|
716
|
+
</SectionCard>
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function AlertsView() {
|
|
721
|
+
const [config, setConfig] = useLocalStorage<AlertConfig>(
|
|
722
|
+
'console:alert-config',
|
|
723
|
+
DEFAULT_ALERT_CONFIG
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
const thresholds: AlertThresholds = {
|
|
727
|
+
p90Latency: config.latencyThresholdMs,
|
|
728
|
+
errorRate: config.errorRateThreshold,
|
|
729
|
+
clientLag: config.clientLagThreshold,
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const handleThresholdsChange = (next: AlertThresholds) => {
|
|
733
|
+
setConfig((prev) => ({
|
|
734
|
+
...prev,
|
|
735
|
+
latencyThresholdMs: next.p90Latency,
|
|
736
|
+
errorRateThreshold: next.errorRate,
|
|
737
|
+
clientLagThreshold: next.clientLag,
|
|
738
|
+
}));
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const handleEnabledChange = (enabled: boolean) => {
|
|
742
|
+
setConfig((prev) => ({ ...prev, enabled }));
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
return (
|
|
746
|
+
<AlertsConfig
|
|
747
|
+
enabled={config.enabled}
|
|
748
|
+
onEnabledChange={handleEnabledChange}
|
|
749
|
+
thresholds={thresholds}
|
|
750
|
+
onThresholdsChange={handleThresholdsChange}
|
|
751
|
+
/>
|
|
752
|
+
);
|
|
753
|
+
}
|