@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,242 @@
1
+ import type { SyncClientNode } from '@syncular/ui';
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ DialogContent,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ EmptyState,
10
+ FleetCard,
11
+ Pagination,
12
+ PanelShell,
13
+ Spinner,
14
+ SyncHorizon,
15
+ } from '@syncular/ui';
16
+ import { useEffect, useState } from 'react';
17
+ import {
18
+ useClients,
19
+ useEvictClientMutation,
20
+ usePartitionContext,
21
+ usePreferences,
22
+ useStats,
23
+ } from '../hooks';
24
+
25
+ function inferClientType(clientId: string): string {
26
+ const lower = clientId.toLowerCase();
27
+ if (
28
+ lower.includes('mobile') ||
29
+ lower.includes('ios') ||
30
+ lower.includes('android')
31
+ )
32
+ return 'mobile';
33
+ if (lower.includes('tablet')) return 'tablet';
34
+ if (lower.includes('desktop') || lower.includes('laptop')) return 'desktop';
35
+ if (lower.includes('edge')) return 'edge';
36
+ if (lower.includes('iot')) return 'iot';
37
+ return 'desktop';
38
+ }
39
+
40
+ function inferDialect(clientId: string): string {
41
+ const lower = clientId.toLowerCase();
42
+ if (lower.includes('pglite')) return 'PGlite';
43
+ if (lower.includes('wa-sqlite') || lower.includes('sqlite')) return 'SQLite';
44
+ if (lower.includes('postgres') || lower.includes('pg')) return 'PostgreSQL';
45
+ return 'unknown';
46
+ }
47
+
48
+ function formatTime(isoString: string, timeFormat: 'relative' | 'absolute') {
49
+ if (timeFormat === 'absolute') {
50
+ return new Date(isoString).toLocaleString();
51
+ }
52
+
53
+ try {
54
+ const date = new Date(isoString);
55
+ const now = new Date();
56
+ const diffMs = now.getTime() - date.getTime();
57
+ const diffSecs = Math.floor(diffMs / 1000);
58
+ const diffMins = Math.floor(diffSecs / 60);
59
+ const diffHours = Math.floor(diffMins / 60);
60
+ const diffDays = Math.floor(diffHours / 24);
61
+
62
+ if (diffSecs < 60) return 'just now';
63
+ if (diffMins < 60) return `${diffMins}m ago`;
64
+ if (diffHours < 24) return `${diffHours}h ago`;
65
+ return `${diffDays}d ago`;
66
+ } catch {
67
+ return isoString;
68
+ }
69
+ }
70
+
71
+ function mapToSyncNode(
72
+ client: {
73
+ clientId: string;
74
+ cursor: number;
75
+ actorId: string;
76
+ connectionMode: 'polling' | 'realtime';
77
+ activityState: 'active' | 'idle' | 'stale';
78
+ effectiveScopes: Record<string, unknown>;
79
+ updatedAt: string;
80
+ },
81
+ _headSeq: number,
82
+ timeFormat: 'relative' | 'absolute'
83
+ ): SyncClientNode {
84
+ return {
85
+ id: client.clientId,
86
+ type: inferClientType(client.clientId),
87
+ status:
88
+ client.activityState === 'stale'
89
+ ? 'offline'
90
+ : client.activityState === 'idle'
91
+ ? 'syncing'
92
+ : 'online',
93
+ cursor: client.cursor,
94
+ actor: client.actorId,
95
+ mode: client.connectionMode === 'realtime' ? 'realtime' : 'polling',
96
+ dialect: inferDialect(client.clientId),
97
+ scopes: Object.keys(client.effectiveScopes ?? {}),
98
+ lastSeen: formatTime(client.updatedAt, timeFormat),
99
+ };
100
+ }
101
+
102
+ export function Fleet() {
103
+ const [page, setPage] = useState(1);
104
+ const [evictingClientId, setEvictingClientId] = useState<string | null>(null);
105
+ const { preferences } = usePreferences();
106
+ const { partitionId } = usePartitionContext();
107
+ const pageSize = preferences.pageSize;
108
+ const refreshIntervalMs = preferences.refreshInterval * 1000;
109
+
110
+ const { data: stats, isLoading: statsLoading } = useStats({
111
+ refetchIntervalMs: refreshIntervalMs,
112
+ partitionId,
113
+ });
114
+ const { data, isLoading, error } = useClients(
115
+ {
116
+ limit: pageSize,
117
+ offset: (page - 1) * pageSize,
118
+ partitionId,
119
+ },
120
+ { refetchIntervalMs: refreshIntervalMs }
121
+ );
122
+ const evictMutation = useEvictClientMutation();
123
+
124
+ const totalPages = Math.ceil((data?.total ?? 0) / pageSize);
125
+ const headSeq = stats?.maxCommitSeq ?? 0;
126
+
127
+ useEffect(() => {
128
+ setPage(1);
129
+ }, []);
130
+
131
+ const handleEvict = async () => {
132
+ if (!evictingClientId) return;
133
+ try {
134
+ await evictMutation.mutateAsync({
135
+ clientId: evictingClientId,
136
+ partitionId,
137
+ });
138
+ } finally {
139
+ setEvictingClientId(null);
140
+ }
141
+ };
142
+
143
+ if (isLoading || statsLoading) {
144
+ return (
145
+ <div className="flex items-center justify-center h-[200px]">
146
+ <Spinner size="lg" />
147
+ </div>
148
+ );
149
+ }
150
+
151
+ if (error) {
152
+ return (
153
+ <div className="flex items-center justify-center h-[200px]">
154
+ <p className="text-danger">Failed to load clients: {error.message}</p>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ const syncNodes = (data?.items ?? []).map((c) =>
160
+ mapToSyncNode(c, headSeq, preferences.timeFormat)
161
+ );
162
+
163
+ return (
164
+ <div className="flex flex-col gap-5 px-5 py-5">
165
+ {syncNodes.length > 0 && (
166
+ <SyncHorizon clients={syncNodes} headSeq={headSeq} />
167
+ )}
168
+
169
+ {syncNodes.length === 0 ? (
170
+ <PanelShell>
171
+ <EmptyState message="No clients yet" />
172
+ </PanelShell>
173
+ ) : (
174
+ <div className="grid grid-cols-3 gap-3">
175
+ {syncNodes.map((node, i) => (
176
+ <FleetCard
177
+ key={node.id}
178
+ client={node}
179
+ headSeq={headSeq}
180
+ onEvict={() =>
181
+ setEvictingClientId(data?.items[i]?.clientId ?? node.id)
182
+ }
183
+ />
184
+ ))}
185
+ </div>
186
+ )}
187
+
188
+ {totalPages > 1 && (
189
+ <Pagination
190
+ page={page}
191
+ totalPages={totalPages}
192
+ totalItems={data?.total ?? 0}
193
+ onPageChange={setPage}
194
+ />
195
+ )}
196
+
197
+ <Dialog
198
+ open={evictingClientId !== null}
199
+ onOpenChange={() => setEvictingClientId(null)}
200
+ >
201
+ <DialogContent>
202
+ <DialogHeader>
203
+ <DialogTitle>Evict Client</DialogTitle>
204
+ </DialogHeader>
205
+
206
+ <div className="px-5 py-4 flex flex-col gap-4">
207
+ <p className="font-mono text-[11px] text-neutral-300">
208
+ Are you sure you want to evict client{' '}
209
+ <span className="font-mono text-white">
210
+ {evictingClientId?.slice(0, 12)}...
211
+ </span>
212
+ ?
213
+ </p>
214
+ <p className="font-mono text-[10px] text-neutral-500">
215
+ This will force the client to re-bootstrap on their next sync.
216
+ </p>
217
+ </div>
218
+
219
+ <DialogFooter>
220
+ <Button variant="default" onClick={() => setEvictingClientId(null)}>
221
+ Cancel
222
+ </Button>
223
+ <Button
224
+ variant="destructive"
225
+ onClick={handleEvict}
226
+ disabled={evictMutation.isPending}
227
+ >
228
+ {evictMutation.isPending ? (
229
+ <>
230
+ <Spinner size="sm" />
231
+ Evicting...
232
+ </>
233
+ ) : (
234
+ 'Evict'
235
+ )}
236
+ </Button>
237
+ </DialogFooter>
238
+ </DialogContent>
239
+ </Dialog>
240
+ </div>
241
+ );
242
+ }