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