@syncular/console 0.0.4-25 → 0.0.5-42

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.
Files changed (99) hide show
  1. package/dist/App.d.ts +1 -1
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +2 -1
  4. package/dist/App.js.map +1 -1
  5. package/dist/browser-main.js +1 -1
  6. package/dist/browser-main.js.map +1 -1
  7. package/dist/hooks/ConnectionContext.d.ts.map +1 -1
  8. package/dist/hooks/ConnectionContext.js +5 -14
  9. package/dist/hooks/ConnectionContext.js.map +1 -1
  10. package/dist/hooks/useConsoleApi.d.ts +11 -1
  11. package/dist/hooks/useConsoleApi.d.ts.map +1 -1
  12. package/dist/hooks/useConsoleApi.js +78 -0
  13. package/dist/hooks/useConsoleApi.js.map +1 -1
  14. package/dist/hooks/useLiveEvents.d.ts.map +1 -1
  15. package/dist/hooks/useLiveEvents.js +94 -0
  16. package/dist/hooks/useLiveEvents.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/layout.d.ts.map +1 -1
  22. package/dist/layout.js +3 -1
  23. package/dist/layout.js.map +1 -1
  24. package/dist/lib/api.d.ts +1 -1
  25. package/dist/lib/api.d.ts.map +1 -1
  26. package/dist/lib/api.js +36 -4
  27. package/dist/lib/api.js.map +1 -1
  28. package/dist/lib/types.d.ts +13 -0
  29. package/dist/lib/types.d.ts.map +1 -1
  30. package/dist/mount.d.ts.map +1 -1
  31. package/dist/mount.js +4 -1
  32. package/dist/mount.js.map +1 -1
  33. package/dist/pages/Config.d.ts +3 -1
  34. package/dist/pages/Config.d.ts.map +1 -1
  35. package/dist/pages/Config.js +2 -3
  36. package/dist/pages/Config.js.map +1 -1
  37. package/dist/pages/Fleet.d.ts +3 -1
  38. package/dist/pages/Fleet.d.ts.map +1 -1
  39. package/dist/pages/Fleet.js +6 -3
  40. package/dist/pages/Fleet.js.map +1 -1
  41. package/dist/pages/Ops.js +2 -2
  42. package/dist/pages/Ops.js.map +1 -1
  43. package/dist/pages/Storage.d.ts +2 -0
  44. package/dist/pages/Storage.d.ts.map +1 -0
  45. package/dist/pages/Storage.js +103 -0
  46. package/dist/pages/Storage.js.map +1 -0
  47. package/dist/pages/index.d.ts +1 -0
  48. package/dist/pages/index.d.ts.map +1 -1
  49. package/dist/pages/index.js +1 -0
  50. package/dist/pages/index.js.map +1 -1
  51. package/dist/routeTree.d.ts +1 -1
  52. package/dist/routeTree.d.ts.map +1 -1
  53. package/dist/routeTree.js +2 -0
  54. package/dist/routeTree.js.map +1 -1
  55. package/dist/routes/storage.d.ts +2 -0
  56. package/dist/routes/storage.d.ts.map +1 -0
  57. package/dist/routes/storage.js +9 -0
  58. package/dist/routes/storage.js.map +1 -0
  59. package/dist/server.d.ts +3 -0
  60. package/dist/server.d.ts.map +1 -0
  61. package/dist/server.js +3 -0
  62. package/dist/server.js.map +1 -0
  63. package/dist/static-server.d.ts +18 -0
  64. package/dist/static-server.d.ts.map +1 -0
  65. package/dist/static-server.js +137 -0
  66. package/dist/static-server.js.map +1 -0
  67. package/dist/styles.css +1 -0
  68. package/dist/theme-scope.d.ts +3 -0
  69. package/dist/theme-scope.d.ts.map +1 -0
  70. package/dist/theme-scope.js +5 -0
  71. package/dist/theme-scope.js.map +1 -0
  72. package/package.json +20 -9
  73. package/src/App.tsx +6 -1
  74. package/src/browser-main.tsx +1 -1
  75. package/src/hooks/ConnectionContext.tsx +5 -15
  76. package/src/hooks/useConsoleApi.ts +103 -0
  77. package/src/hooks/useLiveEvents.ts +115 -1
  78. package/src/index.ts +1 -0
  79. package/src/layout.tsx +12 -2
  80. package/src/lib/api.ts +38 -5
  81. package/src/lib/types.ts +17 -0
  82. package/src/mount.tsx +5 -1
  83. package/src/pages/Config.tsx +2 -1
  84. package/src/pages/Fleet.tsx +19 -17
  85. package/src/pages/Ops.tsx +4 -2
  86. package/src/pages/Storage.tsx +277 -0
  87. package/src/pages/index.ts +1 -0
  88. package/src/routeTree.ts +2 -0
  89. package/src/routes/storage.tsx +9 -0
  90. package/src/server.ts +2 -0
  91. package/src/static-server.ts +219 -0
  92. package/src/styles/globals.css +8 -1
  93. package/src/theme-scope.ts +5 -0
  94. package/web-dist/assets/index-BhPtRvK0.css +1 -0
  95. package/web-dist/assets/index-Fyq7dTrO.js +86 -0
  96. package/web-dist/console.css +1 -0
  97. package/web-dist/index.html +4 -4
  98. package/web-dist/chunk-7ayekhzx.css +0 -1
  99. package/web-dist/chunk-myppbvt5.js +0 -90
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/console",
3
- "version": "0.0.4-25",
3
+ "version": "0.0.5-42",
4
4
  "description": "Embeddable Syncular console UI",
5
5
  "license": "MIT",
6
6
  "author": "Benjamin Kniffler",
@@ -48,9 +48,18 @@
48
48
  "default": "./dist/browser-main.js"
49
49
  }
50
50
  },
51
+ "./server": {
52
+ "bun": "./src/server.ts",
53
+ "import": {
54
+ "types": "./dist/server.d.ts",
55
+ "default": "./dist/server.js"
56
+ }
57
+ },
51
58
  "./static": "./web-dist/index.html",
52
59
  "./static/*": "./web-dist/*",
53
- "./styles.css": "./src/styles/globals.css"
60
+ "./styles.css": "./dist/styles.css",
61
+ "./styles.source.css": "./src/styles/globals.css",
62
+ "./static/console.css": "./web-dist/console.css"
54
63
  },
55
64
  "scripts": {
56
65
  "tsgo": "tsgo --noEmit",
@@ -59,12 +68,12 @@
59
68
  "release": "bunx syncular-publish"
60
69
  },
61
70
  "dependencies": {
62
- "@syncular/observability-sentry": "0.0.4-25",
63
- "@syncular/transport-http": "0.0.4-25",
64
- "@syncular/ui": "0.0.4-25",
71
+ "@syncular/observability-sentry": "0.0.5-42",
72
+ "@syncular/transport-http": "0.0.5-42",
73
+ "@syncular/ui": "0.0.5-42",
65
74
  "@tanstack/react-query": "^5.90.21",
66
- "@tanstack/react-router": "^1.159.5",
67
- "lucide-react": "^0.563.0"
75
+ "@tanstack/react-router": "^1.161.3",
76
+ "lucide-react": "^0.575.0"
68
77
  },
69
78
  "peerDependencies": {
70
79
  "react": "^19.0.0",
@@ -72,12 +81,14 @@
72
81
  },
73
82
  "devDependencies": {
74
83
  "@syncular/config": "0.0.0",
84
+ "@tailwindcss/vite": "^4.2.0",
75
85
  "@types/react": "^19",
76
86
  "@types/react-dom": "^19",
77
- "bun-plugin-tailwind": "^0.1.2",
87
+ "@vitejs/plugin-react": "^5.1.4",
78
88
  "react": "^19.2.4",
79
89
  "react-dom": "^19.2.4",
80
- "tailwindcss": "^4.1.18"
90
+ "tailwindcss": "^4.2.0",
91
+ "vite": "^7.3.1"
81
92
  },
82
93
  "files": [
83
94
  "dist",
package/src/App.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  resolveConsoleBasePathFromMeta,
10
10
  resolveConsoleConnectionConfigFromMeta,
11
11
  } from './runtime-config';
12
+ import { SYNCULAR_CONSOLE_ROOT_CLASS } from './theme-scope';
12
13
 
13
14
  const routerForTypes = createRouter({ routeTree, basepath: '/' });
14
15
 
@@ -61,5 +62,9 @@ function SyncularConsole(props: SyncularConsoleProps) {
61
62
  }
62
63
 
63
64
  export function App(props: SyncularConsoleProps) {
64
- return <SyncularConsole {...props} />;
65
+ return (
66
+ <div className={SYNCULAR_CONSOLE_ROOT_CLASS}>
67
+ <SyncularConsole {...props} />
68
+ </div>
69
+ );
65
70
  }
@@ -1,4 +1,4 @@
1
- import { initAndConfigureBrowserSentry } from '@syncular/observability-sentry';
1
+ import { initAndConfigureBrowserSentry } from '@syncular/observability-sentry/browser';
2
2
  import { mountSyncularConsoleApp } from './mount';
3
3
  import { resolveConsoleBrowserSentryOptions } from './sentry';
4
4
 
@@ -113,24 +113,14 @@ export function ConnectionProvider({
113
113
 
114
114
  try {
115
115
  const client = createConsoleClient(normalizedConfig);
116
- const ok = await testConnection(client);
117
-
118
- if (ok) {
119
- setState({
120
- isConnected: true,
121
- isConnecting: false,
122
- client,
123
- error: null,
124
- });
125
- return true;
126
- }
116
+ await testConnection(client);
127
117
  setState({
128
- isConnected: false,
118
+ isConnected: true,
129
119
  isConnecting: false,
130
- client: null,
131
- error: 'Failed to connect',
120
+ client,
121
+ error: null,
132
122
  });
133
- return false;
123
+ return true;
134
124
  } catch (err) {
135
125
  setState({
136
126
  isConnected: false,
@@ -7,6 +7,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
7
7
  import type {
8
8
  ConsoleApiKey,
9
9
  ConsoleApiKeyBulkRevokeResponse,
10
+ ConsoleBlobListResponse,
10
11
  ConsoleClient,
11
12
  ConsoleCommitDetail,
12
13
  ConsoleCommitListItem,
@@ -104,6 +105,8 @@ const queryKeys = {
104
105
  expiresWithinDays?: number;
105
106
  instanceId?: string;
106
107
  }) => ['console', 'api-keys', params] as const,
108
+ storage: (params?: Record<string, unknown>) =>
109
+ ['console', 'storage', params] as const,
107
110
  };
108
111
 
109
112
  function resolveRefetchInterval(
@@ -924,3 +927,103 @@ export function useStageRotateApiKeyMutation() {
924
927
  },
925
928
  });
926
929
  }
930
+
931
+ // ---------------------------------------------------------------------------
932
+ // Blob storage hooks
933
+ // ---------------------------------------------------------------------------
934
+
935
+ export function useBlobs(
936
+ options: {
937
+ prefix?: string;
938
+ cursor?: string;
939
+ limit?: number;
940
+ refetchIntervalMs?: number;
941
+ } = {}
942
+ ) {
943
+ const { config: connectionConfig } = useConnection();
944
+ return useQuery<ConsoleBlobListResponse>({
945
+ queryKey: queryKeys.storage({
946
+ prefix: options.prefix,
947
+ cursor: options.cursor,
948
+ limit: options.limit,
949
+ }),
950
+ queryFn: async () => {
951
+ if (!connectionConfig) throw new Error('Not connected');
952
+ const queryString = new URLSearchParams();
953
+ if (options.prefix) queryString.set('prefix', options.prefix);
954
+ if (options.cursor) queryString.set('cursor', options.cursor);
955
+ if (options.limit) queryString.set('limit', String(options.limit));
956
+ const response = await fetch(
957
+ buildConsoleUrl(
958
+ connectionConfig.serverUrl,
959
+ '/console/storage',
960
+ queryString
961
+ ),
962
+ {
963
+ method: 'GET',
964
+ headers: { Authorization: `Bearer ${connectionConfig.token}` },
965
+ }
966
+ );
967
+ if (!response.ok) throw new Error('Failed to list blobs');
968
+ return response.json();
969
+ },
970
+ enabled: !!connectionConfig,
971
+ refetchInterval: resolveRefetchInterval(options.refetchIntervalMs, 30000),
972
+ });
973
+ }
974
+
975
+ export function useDeleteBlobMutation() {
976
+ const { config: connectionConfig } = useConnection();
977
+ const queryClient = useQueryClient();
978
+ return useMutation<{ deleted: boolean }, Error, string>({
979
+ mutationFn: async (key: string) => {
980
+ if (!connectionConfig) throw new Error('Not connected');
981
+ const encodedKey = encodeURIComponent(key);
982
+ const response = await fetch(
983
+ buildConsoleUrl(
984
+ connectionConfig.serverUrl,
985
+ `/console/storage/${encodedKey}`,
986
+ new URLSearchParams()
987
+ ),
988
+ {
989
+ method: 'DELETE',
990
+ headers: { Authorization: `Bearer ${connectionConfig.token}` },
991
+ }
992
+ );
993
+ if (!response.ok) throw new Error('Failed to delete blob');
994
+ return response.json();
995
+ },
996
+ onSuccess: () => {
997
+ queryClient.invalidateQueries({ queryKey: ['console', 'storage'] });
998
+ },
999
+ });
1000
+ }
1001
+
1002
+ export function useBlobDownload() {
1003
+ const { config: connectionConfig } = useConnection();
1004
+ return async (key: string) => {
1005
+ if (!connectionConfig) throw new Error('Not connected');
1006
+ const encodedKey = encodeURIComponent(key);
1007
+ const response = await fetch(
1008
+ buildConsoleUrl(
1009
+ connectionConfig.serverUrl,
1010
+ `/console/storage/${encodedKey}/download`,
1011
+ new URLSearchParams()
1012
+ ),
1013
+ {
1014
+ method: 'GET',
1015
+ headers: { Authorization: `Bearer ${connectionConfig.token}` },
1016
+ }
1017
+ );
1018
+ if (!response.ok) throw new Error('Failed to download blob');
1019
+ const blob = await response.blob();
1020
+ const url = URL.createObjectURL(blob);
1021
+ const a = document.createElement('a');
1022
+ a.href = url;
1023
+ a.download = key.split('/').pop() || key;
1024
+ document.body.appendChild(a);
1025
+ a.click();
1026
+ document.body.removeChild(a);
1027
+ URL.revokeObjectURL(url);
1028
+ };
1029
+ }
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { useCallback, useEffect, useRef, useState } from 'react';
6
- import type { LiveEvent } from '../lib/types';
6
+ import type { ConsoleRequestEvent, LiveEvent } from '../lib/types';
7
7
  import { useConnection } from './ConnectionContext';
8
8
  import { useInstanceContext } from './useInstanceContext';
9
9
 
@@ -35,6 +35,11 @@ interface UseLiveEventsResult {
35
35
  clearEvents: () => void;
36
36
  }
37
37
 
38
+ function isServiceWorkerServerMode(): boolean {
39
+ if (typeof window === 'undefined') return false;
40
+ return new URLSearchParams(window.location.search).get('swServer') === '1';
41
+ }
42
+
38
43
  export function useLiveEvents(
39
44
  options: UseLiveEventsOptions = {}
40
45
  ): UseLiveEventsResult {
@@ -65,10 +70,12 @@ export function useLiveEvents(
65
70
  const reconnectAttemptsRef = useRef(0);
66
71
  const lastActivityAtRef = useRef(0);
67
72
  const lastEventTimestampRef = useRef<string | null>(null);
73
+ const lastEventIdRef = useRef<number | null>(null);
68
74
 
69
75
  const clearEvents = useCallback(() => {
70
76
  setEvents([]);
71
77
  lastEventTimestampRef.current = null;
78
+ lastEventIdRef.current = null;
72
79
  }, []);
73
80
 
74
81
  useEffect(() => {
@@ -80,6 +87,113 @@ export function useLiveEvents(
80
87
  const normalizedReplayLimit = Number.isFinite(replayLimit)
81
88
  ? Math.max(1, Math.min(500, Math.floor(replayLimit)))
82
89
  : 100;
90
+ const usePollingFallback =
91
+ isServiceWorkerServerMode() || typeof WebSocket === 'undefined';
92
+
93
+ if (usePollingFallback) {
94
+ setConnectionState('connecting');
95
+ setIsConnected(false);
96
+
97
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
98
+ let isPolling = false;
99
+
100
+ const poll = async () => {
101
+ if (isCleanedUp || isPolling) return;
102
+ isPolling = true;
103
+
104
+ try {
105
+ const baseUrl = new URL(config.serverUrl, window.location.origin);
106
+ const normalizedPath = baseUrl.pathname.endsWith('/')
107
+ ? baseUrl.pathname.slice(0, -1)
108
+ : baseUrl.pathname;
109
+ baseUrl.pathname = `${normalizedPath}/console/events`;
110
+ baseUrl.search = '';
111
+ baseUrl.searchParams.set('limit', String(normalizedReplayLimit));
112
+ baseUrl.searchParams.set('offset', '0');
113
+ if (partitionId) {
114
+ baseUrl.searchParams.set('partitionId', partitionId);
115
+ }
116
+
117
+ const response = await fetch(baseUrl.toString(), {
118
+ headers: {
119
+ Authorization: `Bearer ${config.token}`,
120
+ },
121
+ });
122
+
123
+ if (!response.ok) {
124
+ throw new Error(`Live event polling failed (${response.status})`);
125
+ }
126
+
127
+ const payload = (await response.json()) as {
128
+ items?: ConsoleRequestEvent[];
129
+ };
130
+ const rows = Array.isArray(payload.items) ? payload.items : [];
131
+ const filtered = rows
132
+ .filter((row) =>
133
+ effectiveInstanceId
134
+ ? row.instanceId === effectiveInstanceId
135
+ : true
136
+ )
137
+ .sort((a, b) => a.eventId - b.eventId);
138
+
139
+ const previousLastId = lastEventIdRef.current ?? -1;
140
+ const newRows = filtered.filter(
141
+ (row) => row.eventId > previousLastId
142
+ );
143
+ if (newRows.length > 0) {
144
+ const mapped: LiveEvent[] = newRows
145
+ .map((row) => ({
146
+ type: row.eventType,
147
+ timestamp: row.createdAt,
148
+ data: row as unknown as Record<string, unknown>,
149
+ }))
150
+ .reverse();
151
+
152
+ setEvents((prev) => [...mapped, ...prev].slice(0, maxEvents));
153
+ const newest = newRows[newRows.length - 1]!;
154
+ lastEventIdRef.current = newest.eventId;
155
+ lastEventTimestampRef.current = newest.createdAt;
156
+ } else if (filtered.length > 0) {
157
+ const newest = filtered[filtered.length - 1]!;
158
+ lastEventIdRef.current = Math.max(
159
+ lastEventIdRef.current ?? -1,
160
+ newest.eventId
161
+ );
162
+ lastEventTimestampRef.current = newest.createdAt;
163
+ }
164
+
165
+ setError(null);
166
+ setIsConnected(true);
167
+ setConnectionState('connected');
168
+ } catch (err) {
169
+ if (!isCleanedUp) {
170
+ setIsConnected(false);
171
+ setConnectionState('disconnected');
172
+ setError(
173
+ err instanceof Error
174
+ ? err
175
+ : new Error('Live event polling failed')
176
+ );
177
+ }
178
+ } finally {
179
+ isPolling = false;
180
+ }
181
+ };
182
+
183
+ void poll();
184
+ pollTimer = setInterval(() => {
185
+ void poll();
186
+ }, 2_000);
187
+
188
+ return () => {
189
+ isCleanedUp = true;
190
+ if (pollTimer) {
191
+ clearInterval(pollTimer);
192
+ }
193
+ setIsConnected(false);
194
+ setConnectionState('disconnected');
195
+ };
196
+ }
83
197
 
84
198
  const clearReconnectTimeout = () => {
85
199
  if (!reconnectTimeoutRef.current) return;
package/src/index.ts CHANGED
@@ -7,3 +7,4 @@ export * from './pages';
7
7
  export * from './routeTree';
8
8
  export * from './runtime-config';
9
9
  export * from './sentry';
10
+ export * from './theme-scope';
package/src/layout.tsx CHANGED
@@ -16,12 +16,19 @@ import { useStats } from './hooks/useConsoleApi';
16
16
  import { useInstanceContext } from './hooks/useInstanceContext';
17
17
  import { usePartitionContext } from './hooks/usePartitionContext';
18
18
  import { usePreferences } from './hooks/usePreferences';
19
+ import { SYNCULAR_CONSOLE_ROOT_CLASS } from './theme-scope';
19
20
 
20
21
  interface ConsoleLayoutProps {
21
22
  basePath?: string;
22
23
  }
23
24
 
24
- type ConsoleNavSuffix = '' | '/stream' | '/fleet' | '/ops' | '/config';
25
+ type ConsoleNavSuffix =
26
+ | ''
27
+ | '/stream'
28
+ | '/fleet'
29
+ | '/ops'
30
+ | '/storage'
31
+ | '/config';
25
32
 
26
33
  interface ConsoleNavItem {
27
34
  suffix: ConsoleNavSuffix;
@@ -33,6 +40,7 @@ const NAV_ITEMS: ConsoleNavItem[] = [
33
40
  { suffix: '/stream', label: 'Stream' },
34
41
  { suffix: '/fleet', label: 'Fleet' },
35
42
  { suffix: '/ops', label: 'Ops' },
43
+ { suffix: '/storage', label: 'Storage' },
36
44
  { suffix: '/config', label: 'Config' },
37
45
  ];
38
46
 
@@ -104,7 +112,9 @@ export function ConsoleLayout({ basePath }: ConsoleLayoutProps) {
104
112
  : [];
105
113
 
106
114
  return (
107
- <div className="h-screen bg-background text-foreground flex flex-col">
115
+ <div
116
+ className={`${SYNCULAR_CONSOLE_ROOT_CLASS} h-screen bg-background text-foreground flex flex-col`}
117
+ >
108
118
  <TopNavigation
109
119
  brand={
110
120
  <Link to={commandPath}>
package/src/lib/api.ts CHANGED
@@ -16,11 +16,44 @@ export function createConsoleClient(config: ConnectionConfig): SyncClient {
16
16
  });
17
17
  }
18
18
 
19
- export async function testConnection(client: SyncClient): Promise<boolean> {
19
+ export async function testConnection(client: SyncClient): Promise<void> {
20
20
  try {
21
- const { error } = await client.GET('/console/stats');
22
- return !error;
23
- } catch {
24
- return false;
21
+ const { error, response } = await client.GET('/console/stats');
22
+ if (!error) return;
23
+
24
+ const statusCode = response.status;
25
+ let detail: string | null = null;
26
+ if (typeof error === 'string') {
27
+ detail = error;
28
+ } else if (error && typeof error === 'object') {
29
+ const errorRecord = error as Record<string, unknown>;
30
+ const nestedError = errorRecord.error;
31
+ const nestedMessage = errorRecord.message;
32
+ if (typeof nestedError === 'string' && nestedError.length > 0) {
33
+ detail = nestedError;
34
+ } else if (
35
+ typeof nestedMessage === 'string' &&
36
+ nestedMessage.length > 0
37
+ ) {
38
+ detail = nestedMessage;
39
+ } else {
40
+ try {
41
+ detail = JSON.stringify(errorRecord);
42
+ } catch {
43
+ detail = null;
44
+ }
45
+ }
46
+ }
47
+
48
+ throw new Error(
49
+ detail && detail.length > 0
50
+ ? `Console API /console/stats returned ${statusCode}: ${detail}`
51
+ : `Console API /console/stats returned ${statusCode}`
52
+ );
53
+ } catch (error) {
54
+ if (error instanceof Error) {
55
+ throw error;
56
+ }
57
+ throw new Error('Failed to connect to console API');
25
58
  }
26
59
  }
package/src/lib/types.ts CHANGED
@@ -226,3 +226,20 @@ export interface LiveEvent {
226
226
  timestamp: string;
227
227
  data: Record<string, unknown>;
228
228
  }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Blob storage
232
+ // ---------------------------------------------------------------------------
233
+
234
+ export interface ConsoleBlob {
235
+ key: string;
236
+ size: number;
237
+ uploaded: string;
238
+ httpMetadata?: { contentType?: string };
239
+ }
240
+
241
+ export interface ConsoleBlobListResponse {
242
+ items: ConsoleBlob[];
243
+ truncated: boolean;
244
+ cursor: string | null;
245
+ }
package/src/mount.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { StrictMode } from 'react';
2
2
  import { createRoot, type Root } from 'react-dom/client';
3
3
  import { App, type SyncularConsoleProps } from './App';
4
+ import { applyConsoleThemeScope } from './theme-scope';
4
5
 
5
6
  interface MountSyncularConsoleOptions {
6
7
  strictMode?: boolean;
@@ -27,7 +28,10 @@ export function mountSyncularConsoleApp(
27
28
  containerOrSelector: Element | string,
28
29
  options: MountSyncularConsoleOptions = {}
29
30
  ): Root {
30
- const root = createRoot(resolveContainer(containerOrSelector));
31
+ const container = resolveContainer(containerOrSelector);
32
+ applyConsoleThemeScope(container);
33
+
34
+ const root = createRoot(container);
31
35
  const app = (
32
36
  <App basePath={options.basePath} defaultConfig={options.defaultConfig} />
33
37
  );
@@ -48,12 +48,13 @@ import type {
48
48
  ConsoleApiKeyBulkRevokeResponse,
49
49
  } from '../lib/types';
50
50
 
51
- export function Config() {
51
+ export function Config({ children }: { children?: import('react').ReactNode }) {
52
52
  return (
53
53
  <div className="space-y-4 px-5 py-5">
54
54
  <ConnectionTab />
55
55
  <ApiKeysTab />
56
56
  <PreferencesTab />
57
+ {children}
57
58
  </div>
58
59
  );
59
60
  }
@@ -7,7 +7,7 @@ import {
7
7
  DialogHeader,
8
8
  DialogTitle,
9
9
  EmptyState,
10
- FleetCard,
10
+ FleetTable,
11
11
  Pagination,
12
12
  PanelShell,
13
13
  Spinner,
@@ -99,7 +99,11 @@ function mapToSyncNode(
99
99
  };
100
100
  }
101
101
 
102
- export function Fleet() {
102
+ export function Fleet({
103
+ emptyState,
104
+ }: {
105
+ emptyState?: import('react').ReactNode;
106
+ } = {}) {
103
107
  const [page, setPage] = useState(1);
104
108
  const [evictingClientId, setEvictingClientId] = useState<string | null>(null);
105
109
  const { preferences } = usePreferences();
@@ -167,22 +171,20 @@ export function Fleet() {
167
171
  )}
168
172
 
169
173
  {syncNodes.length === 0 ? (
170
- <PanelShell>
171
- <EmptyState message="No clients yet" />
172
- </PanelShell>
174
+ (emptyState ?? (
175
+ <PanelShell>
176
+ <EmptyState message="No clients yet" />
177
+ </PanelShell>
178
+ ))
173
179
  ) : (
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>
180
+ <FleetTable
181
+ clients={syncNodes}
182
+ headSeq={headSeq}
183
+ onEvict={(clientId) => {
184
+ const item = data?.items.find((c) => c.clientId === clientId);
185
+ setEvictingClientId(item?.clientId ?? clientId);
186
+ }}
187
+ />
186
188
  )}
187
189
 
188
190
  {totalPages > 1 && (
package/src/pages/Ops.tsx CHANGED
@@ -684,8 +684,10 @@ function OperationsAuditView({ partitionId }: { partitionId?: string }) {
684
684
  </TableRow>
685
685
  </TableHeader>
686
686
  <TableBody>
687
- {(data?.items ?? []).map((event) => (
688
- <TableRow key={event.operationId}>
687
+ {(data?.items ?? []).map((event, index) => (
688
+ <TableRow
689
+ key={`${event.operationId}:${event.createdAt}:${index}`}
690
+ >
689
691
  <TableCell className="whitespace-nowrap text-xs text-neutral-400">
690
692
  {formatDateTime(event.createdAt)}
691
693
  </TableCell>