@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/src/layout.tsx ADDED
@@ -0,0 +1,240 @@
1
+ import {
2
+ BottomBar,
3
+ Button,
4
+ ConnectionStatusBadge,
5
+ Input,
6
+ NavPill,
7
+ NavPillGroup,
8
+ SyncularBrand,
9
+ TopNavigation,
10
+ } from '@syncular/ui';
11
+ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
12
+ import { Settings } from 'lucide-react';
13
+ import { useMemo } from 'react';
14
+ import { useConnection } from './hooks/ConnectionContext';
15
+ import { useStats } from './hooks/useConsoleApi';
16
+ import { useInstanceContext } from './hooks/useInstanceContext';
17
+ import { usePartitionContext } from './hooks/usePartitionContext';
18
+ import { usePreferences } from './hooks/usePreferences';
19
+
20
+ interface ConsoleLayoutProps {
21
+ basePath?: string;
22
+ }
23
+
24
+ type ConsoleNavSuffix = '' | '/stream' | '/fleet' | '/ops' | '/config';
25
+
26
+ interface ConsoleNavItem {
27
+ suffix: ConsoleNavSuffix;
28
+ label: string;
29
+ }
30
+
31
+ const NAV_ITEMS: ConsoleNavItem[] = [
32
+ { suffix: '', label: 'Command' },
33
+ { suffix: '/stream', label: 'Stream' },
34
+ { suffix: '/fleet', label: 'Fleet' },
35
+ { suffix: '/ops', label: 'Ops' },
36
+ { suffix: '/config', label: 'Config' },
37
+ ];
38
+
39
+ function normalizeBasePath(basePath?: string): string {
40
+ const value = basePath?.trim() ?? '';
41
+ if (!value || value === '/') return '';
42
+ const withSlash = value.startsWith('/') ? value : `/${value}`;
43
+ return withSlash.replace(/\/+$/g, '');
44
+ }
45
+
46
+ function resolvePath(basePath: string, suffix: ConsoleNavSuffix): string {
47
+ if (!basePath) return suffix || '/';
48
+ return suffix ? `${basePath}${suffix}` : basePath;
49
+ }
50
+
51
+ export function ConsoleLayout({ basePath }: ConsoleLayoutProps) {
52
+ const { connect, config, isConnected, isConnecting } = useConnection();
53
+ const { preferences } = usePreferences();
54
+ const { instanceId, rawInstanceId, setInstanceId, clearInstanceId } =
55
+ useInstanceContext();
56
+ const { partitionId, rawPartitionId, setPartitionId, clearPartitionId } =
57
+ usePartitionContext();
58
+ const pathname = useRouterState({
59
+ select: (state) => state.location.pathname,
60
+ });
61
+ const { data: stats } = useStats({
62
+ refetchIntervalMs: preferences.refreshInterval * 1000,
63
+ partitionId,
64
+ instanceId,
65
+ });
66
+
67
+ const normalizedBasePath = normalizeBasePath(basePath);
68
+ const resolvedNavItems = useMemo(
69
+ () =>
70
+ NAV_ITEMS.map((item) => ({
71
+ ...item,
72
+ id: resolvePath(normalizedBasePath, item.suffix),
73
+ })),
74
+ [normalizedBasePath]
75
+ );
76
+ const commandPath = resolvePath(normalizedBasePath, '');
77
+ const configPath = resolvePath(normalizedBasePath, '/config');
78
+
79
+ const connectionState = isConnecting
80
+ ? 'connecting'
81
+ : isConnected
82
+ ? 'connected'
83
+ : config
84
+ ? 'disconnected'
85
+ : 'not-configured';
86
+
87
+ const activeId =
88
+ resolvedNavItems.find((item) =>
89
+ item.suffix === ''
90
+ ? pathname === commandPath || pathname === `${commandPath}/`
91
+ : pathname.startsWith(item.id)
92
+ )?.id ?? commandPath;
93
+
94
+ const bottomMetrics = stats
95
+ ? [
96
+ { label: 'HEAD', value: `#${stats.maxCommitSeq}` },
97
+ { label: 'COMMITS', value: `${stats.commitCount}` },
98
+ { label: 'CHANGES', value: `${stats.changeCount}` },
99
+ {
100
+ label: 'CLIENTS',
101
+ value: `${stats.activeClientCount}/${stats.clientCount}`,
102
+ },
103
+ ]
104
+ : [];
105
+
106
+ return (
107
+ <div className="h-screen bg-background text-foreground flex flex-col">
108
+ <TopNavigation
109
+ brand={
110
+ <Link to={commandPath}>
111
+ <SyncularBrand label="console" />
112
+ </Link>
113
+ }
114
+ center={
115
+ <NavPillGroup
116
+ items={resolvedNavItems}
117
+ activeId={activeId}
118
+ renderItem={(item, { active }) => (
119
+ <Link key={item.id} to={item.id}>
120
+ <NavPill active={active}>{item.label}</NavPill>
121
+ </Link>
122
+ )}
123
+ />
124
+ }
125
+ right={
126
+ <div className="flex items-center gap-2">
127
+ <div className="flex items-center gap-1">
128
+ <span className="font-mono text-[9px] text-neutral-500 uppercase tracking-wide">
129
+ Instance
130
+ </span>
131
+ <Input
132
+ variant="mono"
133
+ value={rawInstanceId}
134
+ onChange={(event) => setInstanceId(event.target.value)}
135
+ onBlur={(event) => setInstanceId(event.target.value.trim())}
136
+ placeholder="all"
137
+ className="h-7 w-[110px] px-2 py-1"
138
+ />
139
+ {instanceId ? (
140
+ <Button
141
+ variant="ghost"
142
+ size="sm"
143
+ className="h-7 px-2 text-[10px]"
144
+ onClick={clearInstanceId}
145
+ >
146
+ All
147
+ </Button>
148
+ ) : null}
149
+ </div>
150
+ <div className="flex items-center gap-1">
151
+ <span className="font-mono text-[9px] text-neutral-500 uppercase tracking-wide">
152
+ Partition
153
+ </span>
154
+ <Input
155
+ variant="mono"
156
+ value={rawPartitionId}
157
+ onChange={(event) => setPartitionId(event.target.value)}
158
+ onBlur={(event) => setPartitionId(event.target.value.trim())}
159
+ placeholder="all"
160
+ className="h-7 w-[110px] px-2 py-1"
161
+ />
162
+ {partitionId ? (
163
+ <Button
164
+ variant="ghost"
165
+ size="sm"
166
+ className="h-7 px-2 text-[10px]"
167
+ onClick={clearPartitionId}
168
+ >
169
+ All
170
+ </Button>
171
+ ) : null}
172
+ </div>
173
+ <ConnectionStatusBadge state={connectionState} />
174
+ <Link to={configPath}>
175
+ <Button
176
+ variant={pathname === configPath ? 'secondary' : 'ghost'}
177
+ size="icon"
178
+ >
179
+ <Settings />
180
+ </Button>
181
+ </Link>
182
+ </div>
183
+ }
184
+ />
185
+
186
+ <main className="flex-1 overflow-auto pt-[42px] pb-[32px]">
187
+ <div className="min-h-full">
188
+ {isConnected || pathname === configPath ? (
189
+ <div key={pathname} style={{ animation: 'pageIn 0.3s ease-out' }}>
190
+ <Outlet />
191
+ </div>
192
+ ) : (
193
+ <NotConnectedFallback
194
+ configPath={configPath}
195
+ hasSavedConfig={Boolean(config)}
196
+ isConnecting={isConnecting}
197
+ onConnect={() => {
198
+ void connect();
199
+ }}
200
+ />
201
+ )}
202
+ </div>
203
+ </main>
204
+
205
+ {isConnected && (
206
+ <BottomBar isLive={isConnected} metrics={bottomMetrics} uptime="--" />
207
+ )}
208
+ </div>
209
+ );
210
+ }
211
+
212
+ function NotConnectedFallback({
213
+ configPath,
214
+ hasSavedConfig,
215
+ isConnecting,
216
+ onConnect,
217
+ }: {
218
+ configPath: string;
219
+ hasSavedConfig: boolean;
220
+ isConnecting: boolean;
221
+ onConnect: () => void;
222
+ }) {
223
+ return (
224
+ <div className="flex flex-col items-center justify-center py-16">
225
+ <p className="mb-4 text-foreground-muted">
226
+ Not connected to a @syncular server
227
+ </p>
228
+ <div className="flex items-center gap-2">
229
+ {hasSavedConfig && (
230
+ <Button variant="default" onClick={onConnect} disabled={isConnecting}>
231
+ {isConnecting ? 'Connecting...' : 'Connect'}
232
+ </Button>
233
+ )}
234
+ <Link to={configPath}>
235
+ <Button variant="link">Configure connection</Button>
236
+ </Link>
237
+ </div>
238
+ </div>
239
+ );
240
+ }
package/src/lib/api.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Console API client - uses generated types from @syncular/transport-http
3
+ */
4
+
5
+ import { createApiClient, type SyncClient } from '@syncular/transport-http';
6
+
7
+ export interface ConnectionConfig {
8
+ serverUrl: string;
9
+ token: string;
10
+ }
11
+
12
+ export function createConsoleClient(config: ConnectionConfig): SyncClient {
13
+ return createApiClient({
14
+ baseUrl: config.serverUrl,
15
+ getHeaders: () => ({ Authorization: `Bearer ${config.token}` }),
16
+ });
17
+ }
18
+
19
+ export async function testConnection(client: SyncClient): Promise<boolean> {
20
+ try {
21
+ const { error } = await client.GET('/console/stats');
22
+ return !error;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
@@ -0,0 +1,102 @@
1
+ import type { SyncClient } from '@syncular/ui';
2
+ import type { ConsoleClient, SyncStats } from './types';
3
+
4
+ interface TopologyAdapterOptions {
5
+ maxNodes?: number;
6
+ }
7
+
8
+ const TYPE_HINTS: Array<{ hint: string; type: string }> = [
9
+ { hint: 'ios', type: 'mobile' },
10
+ { hint: 'android', type: 'mobile' },
11
+ { hint: 'mobile', type: 'mobile' },
12
+ { hint: 'tablet', type: 'tablet' },
13
+ { hint: 'desktop', type: 'desktop' },
14
+ { hint: 'mac', type: 'desktop' },
15
+ { hint: 'windows', type: 'desktop' },
16
+ { hint: 'linux', type: 'desktop' },
17
+ { hint: 'browser', type: 'browser' },
18
+ { hint: 'web', type: 'browser' },
19
+ { hint: 'server', type: 'server' },
20
+ { hint: 'api', type: 'server' },
21
+ { hint: 'iot', type: 'iot' },
22
+ { hint: 'sensor', type: 'iot' },
23
+ ];
24
+
25
+ function inferType(clientId: string): string {
26
+ const lowerId = clientId.toLowerCase();
27
+
28
+ for (const hint of TYPE_HINTS) {
29
+ if (lowerId.includes(hint.hint)) {
30
+ return hint.type;
31
+ }
32
+ }
33
+
34
+ return 'client';
35
+ }
36
+
37
+ function inferDialect(clientId: string): string {
38
+ const lower = clientId.toLowerCase();
39
+ if (lower.includes('pglite')) return 'PGlite';
40
+ if (lower.includes('sqlite') || lower.includes('wa-sqlite')) return 'SQLite';
41
+ if (lower.includes('postgres') || lower.includes('pg')) return 'PostgreSQL';
42
+ return 'unknown';
43
+ }
44
+
45
+ function inferLagCommitCount(
46
+ client: ConsoleClient,
47
+ stats: SyncStats | undefined
48
+ ): number {
49
+ if (typeof client.lagCommitCount === 'number') {
50
+ return Math.max(0, client.lagCommitCount);
51
+ }
52
+ return stats ? Math.max(0, stats.maxCommitSeq - client.cursor) : 0;
53
+ }
54
+
55
+ function inferStatus(
56
+ client: ConsoleClient,
57
+ lagCommitCount: number
58
+ ): SyncClient['status'] {
59
+ if (client.activityState === 'stale') {
60
+ return 'offline';
61
+ }
62
+
63
+ if (lagCommitCount > 0) {
64
+ return 'syncing';
65
+ }
66
+
67
+ return 'online';
68
+ }
69
+
70
+ function createDisplayId(clientId: string, index: number): string {
71
+ if (clientId.length <= 16) {
72
+ return clientId;
73
+ }
74
+
75
+ const prefix = clientId.slice(0, 12);
76
+ return `${prefix}-${index + 1}`;
77
+ }
78
+
79
+ export function adaptConsoleClientsToTopology(
80
+ clients: ConsoleClient[],
81
+ stats?: SyncStats,
82
+ options: TopologyAdapterOptions = {}
83
+ ): SyncClient[] {
84
+ const maxNodes = options.maxNodes ?? 10;
85
+
86
+ return clients.slice(0, maxNodes).map((client, index) => {
87
+ const lagCommitCount = inferLagCommitCount(client, stats);
88
+ const status = inferStatus(client, lagCommitCount);
89
+
90
+ return {
91
+ id: createDisplayId(client.clientId, index),
92
+ type: inferType(client.clientId),
93
+ status,
94
+ cursor: Math.max(0, client.cursor),
95
+ actor: client.actorId,
96
+ mode: client.connectionMode,
97
+ dialect: inferDialect(client.clientId),
98
+ scopes: Object.keys(client.effectiveScopes || {}),
99
+ lastSeen: client.updatedAt,
100
+ };
101
+ });
102
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Console API types - derived from OpenAPI spec
3
+ */
4
+
5
+ export type ApiKeyType = 'relay' | 'proxy' | 'admin';
6
+
7
+ export interface ConsoleApiKey {
8
+ keyId: string;
9
+ keyPrefix: string;
10
+ name: string;
11
+ keyType: ApiKeyType;
12
+ scopeKeys: string[];
13
+ actorId: string | null;
14
+ createdAt: string;
15
+ expiresAt: string | null;
16
+ lastUsedAt: string | null;
17
+ revokedAt: string | null;
18
+ }
19
+
20
+ export interface ConsoleApiKeyBulkRevokeResponse {
21
+ requestedCount: number;
22
+ revokedCount: number;
23
+ alreadyRevokedCount: number;
24
+ notFoundCount: number;
25
+ revokedKeyIds: string[];
26
+ alreadyRevokedKeyIds: string[];
27
+ notFoundKeyIds: string[];
28
+ }
29
+
30
+ export interface ConsoleCommitListItem {
31
+ commitSeq: number;
32
+ actorId: string;
33
+ clientId: string;
34
+ clientCommitId: string;
35
+ createdAt: string;
36
+ changeCount: number;
37
+ affectedTables: string[];
38
+ instanceId?: string;
39
+ federatedCommitId?: string;
40
+ localCommitSeq?: number;
41
+ }
42
+
43
+ export interface ConsoleChange {
44
+ changeId: number;
45
+ table: string;
46
+ rowId: string;
47
+ op: 'upsert' | 'delete';
48
+ rowJson: unknown | null;
49
+ rowVersion: number | null;
50
+ scopes: Record<string, unknown>;
51
+ }
52
+
53
+ export interface ConsoleCommitDetail extends ConsoleCommitListItem {
54
+ changes: ConsoleChange[];
55
+ }
56
+
57
+ export interface ConsoleClient {
58
+ clientId: string;
59
+ actorId: string;
60
+ cursor: number;
61
+ lagCommitCount: number;
62
+ connectionPath: 'direct' | 'relay';
63
+ connectionMode: 'polling' | 'realtime';
64
+ realtimeConnectionCount: number;
65
+ isRealtimeConnected: boolean;
66
+ activityState: 'active' | 'idle' | 'stale';
67
+ lastRequestAt: string | null;
68
+ lastRequestType: 'push' | 'pull' | null;
69
+ lastRequestOutcome: string | null;
70
+ effectiveScopes: Record<string, unknown>;
71
+ updatedAt: string;
72
+ instanceId?: string;
73
+ federatedClientId?: string;
74
+ }
75
+
76
+ export interface ConsoleHandler {
77
+ table: string;
78
+ dependsOn?: string[];
79
+ snapshotChunkTtlMs?: number;
80
+ }
81
+
82
+ export interface ConsoleRequestEvent {
83
+ eventId: number;
84
+ partitionId: string;
85
+ requestId: string;
86
+ traceId: string | null;
87
+ spanId: string | null;
88
+ eventType: 'push' | 'pull';
89
+ syncPath: 'http-combined' | 'ws-push';
90
+ transportPath: 'direct' | 'relay';
91
+ actorId: string;
92
+ clientId: string;
93
+ statusCode: number;
94
+ outcome: string;
95
+ responseStatus: string;
96
+ errorCode: string | null;
97
+ durationMs: number;
98
+ commitSeq: number | null;
99
+ operationCount: number | null;
100
+ rowCount: number | null;
101
+ subscriptionCount: number | null;
102
+ scopesSummary: Record<string, string | string[]> | null;
103
+ tables: string[];
104
+ errorMessage: string | null;
105
+ payloadRef: string | null;
106
+ createdAt: string;
107
+ instanceId?: string;
108
+ federatedEventId?: string;
109
+ localEventId?: number;
110
+ }
111
+
112
+ export interface ConsoleRequestPayload {
113
+ payloadRef: string;
114
+ partitionId: string;
115
+ requestPayload: unknown;
116
+ responsePayload: unknown | null;
117
+ createdAt: string;
118
+ instanceId?: string;
119
+ federatedEventId?: string;
120
+ localEventId?: number;
121
+ }
122
+
123
+ export interface ConsoleTimelineItem {
124
+ type: 'commit' | 'event';
125
+ timestamp: string;
126
+ commit: ConsoleCommitListItem | null;
127
+ event: ConsoleRequestEvent | null;
128
+ instanceId?: string;
129
+ federatedTimelineId?: string;
130
+ localCommitSeq?: number | null;
131
+ localEventId?: number | null;
132
+ }
133
+
134
+ export type ConsoleOperationType =
135
+ | 'prune'
136
+ | 'compact'
137
+ | 'notify_data_change'
138
+ | 'evict_client';
139
+
140
+ export interface ConsoleOperationEvent {
141
+ operationId: number;
142
+ operationType: ConsoleOperationType;
143
+ consoleUserId: string | null;
144
+ partitionId: string | null;
145
+ targetClientId: string | null;
146
+ requestPayload: unknown | null;
147
+ resultPayload: unknown | null;
148
+ createdAt: string;
149
+ instanceId?: string;
150
+ federatedOperationId?: string;
151
+ localOperationId?: number;
152
+ }
153
+
154
+ export interface ConsoleNotifyDataChangeResponse {
155
+ commitSeq: number;
156
+ tables: string[];
157
+ deletedChunks: number;
158
+ }
159
+
160
+ export interface SyncStats {
161
+ commitCount: number;
162
+ changeCount: number;
163
+ minCommitSeq: number;
164
+ maxCommitSeq: number;
165
+ clientCount: number;
166
+ activeClientCount: number;
167
+ minActiveClientCursor: number | null;
168
+ maxActiveClientCursor: number | null;
169
+ partial?: boolean;
170
+ failedInstances?: Array<{
171
+ instanceId: string;
172
+ reason: string;
173
+ status?: number;
174
+ }>;
175
+ minCommitSeqByInstance?: Record<string, number>;
176
+ maxCommitSeqByInstance?: Record<string, number>;
177
+ }
178
+
179
+ export interface PaginatedResponse<T> {
180
+ items: T[];
181
+ total: number;
182
+ offset: number;
183
+ limit: number;
184
+ }
185
+
186
+ // Time-series types
187
+ export type TimeseriesInterval = 'minute' | 'hour' | 'day';
188
+ export type TimeseriesRange = '1h' | '6h' | '24h' | '7d' | '30d';
189
+
190
+ export interface TimeseriesBucket {
191
+ timestamp: string;
192
+ pushCount: number;
193
+ pullCount: number;
194
+ errorCount: number;
195
+ avgLatencyMs: number;
196
+ }
197
+
198
+ export interface TimeseriesStatsResponse {
199
+ buckets: TimeseriesBucket[];
200
+ interval: TimeseriesInterval;
201
+ range: TimeseriesRange;
202
+ }
203
+
204
+ // Latency percentiles types
205
+ export interface LatencyPercentiles {
206
+ p50: number;
207
+ p90: number;
208
+ p99: number;
209
+ }
210
+
211
+ export interface LatencyStatsResponse {
212
+ push: LatencyPercentiles;
213
+ pull: LatencyPercentiles;
214
+ range: TimeseriesRange;
215
+ }
216
+
217
+ // Live events types
218
+ export interface LiveEvent {
219
+ type:
220
+ | 'push'
221
+ | 'pull'
222
+ | 'commit'
223
+ | 'client_update'
224
+ | 'instance_error'
225
+ | 'error';
226
+ timestamp: string;
227
+ data: Record<string, unknown>;
228
+ }
package/src/mount.tsx ADDED
@@ -0,0 +1,39 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot, type Root } from 'react-dom/client';
3
+ import { App } from './App';
4
+ import './styles/globals.css';
5
+
6
+ interface MountSyncularConsoleOptions {
7
+ strictMode?: boolean;
8
+ }
9
+
10
+ function resolveContainer(containerOrSelector: Element | string): Element {
11
+ if (typeof containerOrSelector !== 'string') {
12
+ return containerOrSelector;
13
+ }
14
+
15
+ const container = document.querySelector(containerOrSelector);
16
+ if (!container) {
17
+ throw new Error(
18
+ `Unable to mount console: ${containerOrSelector} not found`
19
+ );
20
+ }
21
+
22
+ return container;
23
+ }
24
+
25
+ export function mountSyncularConsoleApp(
26
+ containerOrSelector: Element | string,
27
+ options: MountSyncularConsoleOptions = {}
28
+ ): Root {
29
+ const root = createRoot(resolveContainer(containerOrSelector));
30
+ const app = <App />;
31
+
32
+ if (options.strictMode === false) {
33
+ root.render(app);
34
+ return root;
35
+ }
36
+
37
+ root.render(<StrictMode>{app}</StrictMode>);
38
+ return root;
39
+ }