@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,382 @@
1
+ import type {
2
+ ActivityBarData,
3
+ ActivityTimeRange,
4
+ FeedEntry,
5
+ LatencyBucket,
6
+ MetricItem,
7
+ } from '@syncular/ui';
8
+ import {
9
+ ActivityBars,
10
+ Alert,
11
+ AlertDescription,
12
+ AlertTitle,
13
+ CommitTable,
14
+ KpiStrip,
15
+ LatencyPercentilesBar,
16
+ LiveActivityFeed,
17
+ Spinner,
18
+ TopologyHero,
19
+ } from '@syncular/ui';
20
+ import { useNavigate } from '@tanstack/react-router';
21
+ import { useMemo, useState } from 'react';
22
+ import {
23
+ TimeRangeContext,
24
+ useClients,
25
+ useCommits,
26
+ useLatencyStats,
27
+ useLiveEvents,
28
+ useLocalStorage,
29
+ usePartitionContext,
30
+ usePreferences,
31
+ useStats,
32
+ useTimeRangeState,
33
+ useTimeseriesStats,
34
+ } from '../hooks';
35
+ import { adaptConsoleClientsToTopology } from '../lib/topology';
36
+
37
+ interface AlertConfig {
38
+ enabled: boolean;
39
+ thresholds: {
40
+ p90Latency: number;
41
+ errorRate: number;
42
+ clientLag: number;
43
+ };
44
+ }
45
+
46
+ const DEFAULT_ALERT_CONFIG: AlertConfig = {
47
+ enabled: false,
48
+ thresholds: {
49
+ p90Latency: 500,
50
+ errorRate: 5,
51
+ clientLag: 100,
52
+ },
53
+ };
54
+
55
+ function formatTime(
56
+ iso: string,
57
+ timeFormat: 'relative' | 'absolute' = 'relative'
58
+ ): string {
59
+ if (timeFormat === 'absolute') {
60
+ return new Date(iso).toLocaleString();
61
+ }
62
+
63
+ const date = new Date(iso);
64
+ const now = new Date();
65
+ const diffMs = now.getTime() - date.getTime();
66
+ const diffS = Math.floor(diffMs / 1000);
67
+
68
+ if (diffS < 60) return `${diffS}s ago`;
69
+ if (diffS < 3600) return `${Math.floor(diffS / 60)}m ago`;
70
+ if (diffS < 86400) return `${Math.floor(diffS / 3600)}h ago`;
71
+ return `${Math.floor(diffS / 86400)}d ago`;
72
+ }
73
+
74
+ function resolveStreamHref(pathname: string): string {
75
+ const normalized =
76
+ pathname.length > 1 && pathname.endsWith('/')
77
+ ? pathname.slice(0, -1)
78
+ : pathname;
79
+ return normalized === '/console' || normalized.startsWith('/console/')
80
+ ? '/console/stream'
81
+ : '/stream';
82
+ }
83
+
84
+ function CommandInner() {
85
+ const navigate = useNavigate();
86
+ const streamHref = useMemo(() => {
87
+ if (typeof window === 'undefined') return '/stream';
88
+ return resolveStreamHref(window.location.pathname);
89
+ }, []);
90
+ const timeRangeState = useTimeRangeState();
91
+ const { range } = timeRangeState;
92
+ const { preferences } = usePreferences();
93
+ const { partitionId } = usePartitionContext();
94
+ const refreshIntervalMs = preferences.refreshInterval * 1000;
95
+
96
+ const [alertConfig] = useLocalStorage<AlertConfig>(
97
+ 'console:alert-config',
98
+ DEFAULT_ALERT_CONFIG
99
+ );
100
+
101
+ const [activityRange, setActivityRange] = useState<ActivityTimeRange>('1h');
102
+
103
+ const { data: stats } = useStats({
104
+ refetchIntervalMs: refreshIntervalMs,
105
+ partitionId,
106
+ });
107
+ const { data: timeseriesData } = useTimeseriesStats(
108
+ { range, partitionId },
109
+ { refetchIntervalMs: refreshIntervalMs }
110
+ );
111
+ const { data: latencyData } = useLatencyStats(
112
+ { range, partitionId },
113
+ { refetchIntervalMs: refreshIntervalMs }
114
+ );
115
+ const { data: commitsData } = useCommits(
116
+ { limit: 5, partitionId },
117
+ { refetchIntervalMs: refreshIntervalMs }
118
+ );
119
+ const { data: clientsData } = useClients(
120
+ { limit: 12, offset: 0, partitionId },
121
+ { refetchIntervalMs: refreshIntervalMs }
122
+ );
123
+ const { events, isConnected } = useLiveEvents({
124
+ maxEvents: 8,
125
+ partitionId,
126
+ });
127
+
128
+ const eventSummary = useMemo(() => {
129
+ const buckets = timeseriesData?.buckets ?? [];
130
+
131
+ let totalEvents = 0;
132
+ let totalErrors = 0;
133
+
134
+ for (const bucket of buckets) {
135
+ totalEvents += bucket.pushCount + bucket.pullCount;
136
+ totalErrors += bucket.errorCount;
137
+ }
138
+
139
+ const errorRate = totalEvents > 0 ? (totalErrors / totalEvents) * 100 : 0;
140
+
141
+ return {
142
+ totalEvents,
143
+ errorRate,
144
+ };
145
+ }, [timeseriesData?.buckets]);
146
+
147
+ const topologyNodes = useMemo(() => {
148
+ if (!clientsData?.items) return [];
149
+ return adaptConsoleClientsToTopology(clientsData.items, stats, {
150
+ maxNodes: 12,
151
+ });
152
+ }, [clientsData?.items, stats]);
153
+
154
+ const onlineCount = topologyNodes.filter(
155
+ (c) => c.status !== 'offline'
156
+ ).length;
157
+ const offlineCount = topologyNodes.filter(
158
+ (c) => c.status === 'offline'
159
+ ).length;
160
+
161
+ const kpiItems = useMemo((): MetricItem[] => {
162
+ if (!stats) return [];
163
+ return [
164
+ {
165
+ label: 'Ops (Range)',
166
+ value: eventSummary.totalEvents,
167
+ color: 'flow',
168
+ },
169
+ {
170
+ label: 'P50 Latency',
171
+ value: latencyData?.push?.p50 ?? 0,
172
+ unit: 'ms',
173
+ color: 'healthy',
174
+ },
175
+ {
176
+ label: 'Error Rate',
177
+ value: `${eventSummary.errorRate.toFixed(1)}%`,
178
+ color: eventSummary.errorRate > 0 ? 'offline' : 'muted',
179
+ },
180
+ {
181
+ label: 'Active Clients',
182
+ value: stats.activeClientCount,
183
+ color: 'syncing',
184
+ },
185
+ {
186
+ label: 'Pending',
187
+ value:
188
+ stats.maxActiveClientCursor !== null && stats.maxCommitSeq > 0
189
+ ? stats.maxCommitSeq - (stats.minActiveClientCursor ?? 0)
190
+ : 0,
191
+ color: 'relay',
192
+ },
193
+ ];
194
+ }, [stats, latencyData, eventSummary.errorRate, eventSummary.totalEvents]);
195
+
196
+ const feedEntries = useMemo(
197
+ (): FeedEntry[] =>
198
+ events.map((e) => ({
199
+ type: e.type.toUpperCase(),
200
+ actor: (e.data?.actorId as string) ?? '',
201
+ table: ((e.data?.tables as string[]) ?? [])[0] ?? '',
202
+ time: formatTime(e.timestamp, preferences.timeFormat),
203
+ })),
204
+ [events, preferences.timeFormat]
205
+ );
206
+
207
+ const activityBars = useMemo((): ActivityBarData[] => {
208
+ const buckets = timeseriesData?.buckets;
209
+ if (!buckets?.length) return [];
210
+ const maxVal = Math.max(
211
+ ...buckets.map((b) => Math.max(b.pushCount, b.pullCount)),
212
+ 1
213
+ );
214
+ return buckets.map((b) => ({
215
+ pushPercent: maxVal > 0 ? (b.pushCount / maxVal) * 100 : 0,
216
+ pullPercent: maxVal > 0 ? (b.pullCount / maxVal) * 100 : 0,
217
+ }));
218
+ }, [timeseriesData?.buckets]);
219
+
220
+ const latencyBuckets = useMemo((): LatencyBucket[] => {
221
+ if (!latencyData) return [];
222
+ const maxMs = Math.max(
223
+ latencyData.push.p50,
224
+ latencyData.push.p90,
225
+ latencyData.push.p99,
226
+ latencyData.pull.p50,
227
+ latencyData.pull.p90,
228
+ latencyData.pull.p99,
229
+ 1
230
+ );
231
+ return [
232
+ {
233
+ label: 'P50',
234
+ pushMs: latencyData.push.p50,
235
+ pullMs: latencyData.pull.p50,
236
+ pushBarPercent: (latencyData.push.p50 / maxMs) * 100,
237
+ pullBarPercent: (latencyData.pull.p50 / maxMs) * 100,
238
+ },
239
+ {
240
+ label: 'P90',
241
+ pushMs: latencyData.push.p90,
242
+ pullMs: latencyData.pull.p90,
243
+ pushBarPercent: (latencyData.push.p90 / maxMs) * 100,
244
+ pullBarPercent: (latencyData.pull.p90 / maxMs) * 100,
245
+ },
246
+ {
247
+ label: 'P99',
248
+ pushMs: latencyData.push.p99,
249
+ pullMs: latencyData.pull.p99,
250
+ pushBarPercent: (latencyData.push.p99 / maxMs) * 100,
251
+ pullBarPercent: (latencyData.pull.p99 / maxMs) * 100,
252
+ },
253
+ ];
254
+ }, [latencyData]);
255
+
256
+ const commitEntries = useMemo(() => {
257
+ if (!commitsData?.items) return [];
258
+ return commitsData.items.map((c) => ({
259
+ seq: c.commitSeq,
260
+ actor: c.actorId,
261
+ changes: c.changeCount,
262
+ tables: (c.affectedTables ?? []).join(', '),
263
+ time: formatTime(c.createdAt, preferences.timeFormat),
264
+ }));
265
+ }, [commitsData?.items, preferences.timeFormat]);
266
+
267
+ // Compute success rate from event window
268
+ const successRate = useMemo(() => {
269
+ return Math.max(0, 100 - eventSummary.errorRate);
270
+ }, [eventSummary.errorRate]);
271
+
272
+ // Alert evaluation
273
+ const alertMessages = useMemo(() => {
274
+ if (!alertConfig.enabled || !stats) return [];
275
+ const messages: string[] = [];
276
+ if (
277
+ latencyData?.push?.p90 &&
278
+ latencyData.push.p90 > alertConfig.thresholds.p90Latency
279
+ ) {
280
+ messages.push(
281
+ `P90 push latency (${latencyData.push.p90}ms) exceeds threshold (${alertConfig.thresholds.p90Latency}ms)`
282
+ );
283
+ }
284
+ if (
285
+ stats.minActiveClientCursor !== null &&
286
+ stats.maxCommitSeq - stats.minActiveClientCursor >
287
+ alertConfig.thresholds.clientLag
288
+ ) {
289
+ messages.push(
290
+ `Client lag (${stats.maxCommitSeq - stats.minActiveClientCursor}) exceeds threshold (${alertConfig.thresholds.clientLag})`
291
+ );
292
+ }
293
+ if (eventSummary.errorRate > alertConfig.thresholds.errorRate) {
294
+ messages.push(
295
+ `Error rate (${eventSummary.errorRate.toFixed(1)}%) exceeds threshold (${alertConfig.thresholds.errorRate}%)`
296
+ );
297
+ }
298
+ return messages;
299
+ }, [alertConfig, stats, latencyData, eventSummary.errorRate]);
300
+
301
+ if (!stats) {
302
+ return (
303
+ <div className="flex items-center justify-center py-24">
304
+ <Spinner size="lg" />
305
+ </div>
306
+ );
307
+ }
308
+
309
+ return (
310
+ <TimeRangeContext.Provider value={timeRangeState}>
311
+ <div className="flex flex-col">
312
+ {/* Alerts */}
313
+ {alertMessages.length > 0 && (
314
+ <div className="px-6 pb-4">
315
+ <Alert variant="destructive">
316
+ <AlertTitle>Threshold Exceeded</AlertTitle>
317
+ <AlertDescription>
318
+ {alertMessages.map((msg, i) => (
319
+ <span key={i} className="block">
320
+ {msg}
321
+ </span>
322
+ ))}
323
+ </AlertDescription>
324
+ </Alert>
325
+ </div>
326
+ )}
327
+
328
+ {/* Topology Hero */}
329
+ <TopologyHero
330
+ clients={topologyNodes}
331
+ totalNodes={topologyNodes.length + 2}
332
+ onlineCount={onlineCount}
333
+ offlineCount={offlineCount}
334
+ relayClientIds={[]}
335
+ />
336
+
337
+ {/* KPI Strip */}
338
+ <KpiStrip items={kpiItems} />
339
+
340
+ {/* Two-column grid */}
341
+ <div className="flex">
342
+ {/* Left column */}
343
+ <div className="flex-1 min-w-0 flex flex-col">
344
+ <div className="border-b border-border border-r border-border">
345
+ <ActivityBars
346
+ bars={activityBars}
347
+ activeRange={activityRange}
348
+ onRangeChange={setActivityRange}
349
+ />
350
+ </div>
351
+
352
+ <div className="border-b border-border border-r border-border">
353
+ <LatencyPercentilesBar
354
+ buckets={latencyBuckets}
355
+ successRate={successRate}
356
+ />
357
+ </div>
358
+
359
+ <div className="border-r border-border">
360
+ <CommitTable
361
+ commits={commitEntries}
362
+ onViewAll={() => navigate({ href: streamHref })}
363
+ />
364
+ </div>
365
+ </div>
366
+
367
+ {/* Right column */}
368
+ <LiveActivityFeed
369
+ entries={feedEntries}
370
+ isConnected={isConnected}
371
+ maxVisible={20}
372
+ maxHeight="calc(100vh - 200px)"
373
+ />
374
+ </div>
375
+ </div>
376
+ </TimeRangeContext.Provider>
377
+ );
378
+ }
379
+
380
+ export function Command() {
381
+ return <CommandInner />;
382
+ }