@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
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
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -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
|
+
}
|