@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,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
+ }