@syncular/console 0.0.4-26 → 0.0.6-100

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 (103) hide show
  1. package/dist/App.d.ts +3 -7
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +3 -3
  4. package/dist/App.js.map +1 -1
  5. package/dist/hooks/ConnectionContext.d.ts +4 -1
  6. package/dist/hooks/ConnectionContext.d.ts.map +1 -1
  7. package/dist/hooks/ConnectionContext.js +116 -28
  8. package/dist/hooks/ConnectionContext.js.map +1 -1
  9. package/dist/hooks/useConsoleApi.d.ts +11 -1
  10. package/dist/hooks/useConsoleApi.d.ts.map +1 -1
  11. package/dist/hooks/useConsoleApi.js +78 -0
  12. package/dist/hooks/useConsoleApi.js.map +1 -1
  13. package/dist/hooks/useLiveEvents.d.ts.map +1 -1
  14. package/dist/hooks/useLiveEvents.js +116 -3
  15. package/dist/hooks/useLiveEvents.js.map +1 -1
  16. package/dist/layout.d.ts +4 -1
  17. package/dist/layout.d.ts.map +1 -1
  18. package/dist/layout.js +8 -7
  19. package/dist/layout.js.map +1 -1
  20. package/dist/lib/api.d.ts +1 -1
  21. package/dist/lib/api.d.ts.map +1 -1
  22. package/dist/lib/api.js +36 -4
  23. package/dist/lib/api.js.map +1 -1
  24. package/dist/lib/types.d.ts +13 -0
  25. package/dist/lib/types.d.ts.map +1 -1
  26. package/dist/mount.d.ts +1 -0
  27. package/dist/mount.d.ts.map +1 -1
  28. package/dist/mount.js +1 -1
  29. package/dist/mount.js.map +1 -1
  30. package/dist/pages/Config.d.ts +3 -1
  31. package/dist/pages/Config.d.ts.map +1 -1
  32. package/dist/pages/Config.js +24 -17
  33. package/dist/pages/Config.js.map +1 -1
  34. package/dist/pages/Fleet.d.ts +3 -1
  35. package/dist/pages/Fleet.d.ts.map +1 -1
  36. package/dist/pages/Fleet.js +6 -3
  37. package/dist/pages/Fleet.js.map +1 -1
  38. package/dist/pages/Ops.js.map +1 -1
  39. package/dist/pages/Storage.d.ts +2 -0
  40. package/dist/pages/Storage.d.ts.map +1 -0
  41. package/dist/pages/Storage.js +103 -0
  42. package/dist/pages/Storage.js.map +1 -0
  43. package/dist/pages/Stream.d.ts.map +1 -1
  44. package/dist/pages/Stream.js +2 -3
  45. package/dist/pages/Stream.js.map +1 -1
  46. package/dist/pages/index.d.ts +1 -0
  47. package/dist/pages/index.d.ts.map +1 -1
  48. package/dist/pages/index.js +1 -0
  49. package/dist/pages/index.js.map +1 -1
  50. package/dist/routeTree.d.ts +1 -1
  51. package/dist/routeTree.d.ts.map +1 -1
  52. package/dist/routeTree.js +2 -0
  53. package/dist/routeTree.js.map +1 -1
  54. package/dist/routes/__root.d.ts +1 -1
  55. package/dist/routes/__root.d.ts.map +1 -1
  56. package/dist/routes/config.d.ts +1 -1
  57. package/dist/routes/config.d.ts.map +1 -1
  58. package/dist/routes/fleet.d.ts +1 -1
  59. package/dist/routes/fleet.d.ts.map +1 -1
  60. package/dist/routes/index.d.ts +1 -1
  61. package/dist/routes/index.d.ts.map +1 -1
  62. package/dist/routes/investigate-commit.d.ts +1 -1
  63. package/dist/routes/investigate-commit.d.ts.map +1 -1
  64. package/dist/routes/investigate-event.d.ts +1 -1
  65. package/dist/routes/investigate-event.d.ts.map +1 -1
  66. package/dist/routes/ops.d.ts +1 -1
  67. package/dist/routes/ops.d.ts.map +1 -1
  68. package/dist/routes/storage.d.ts +2 -0
  69. package/dist/routes/storage.d.ts.map +1 -0
  70. package/dist/routes/storage.js +9 -0
  71. package/dist/routes/storage.js.map +1 -0
  72. package/dist/routes/stream.d.ts +1 -1
  73. package/dist/routes/stream.d.ts.map +1 -1
  74. package/dist/static-server.d.ts.map +1 -1
  75. package/dist/static-server.js +6 -1
  76. package/dist/static-server.js.map +1 -1
  77. package/dist/styles.css +1 -1
  78. package/package.json +9 -9
  79. package/src/App.tsx +12 -10
  80. package/src/__tests__/static-server.test.ts +193 -0
  81. package/src/hooks/ConnectionContext.tsx +135 -29
  82. package/src/hooks/useConsoleApi.ts +103 -0
  83. package/src/hooks/useLiveEvents.ts +142 -4
  84. package/src/layout.tsx +35 -5
  85. package/src/lib/api.ts +38 -5
  86. package/src/lib/types.ts +17 -0
  87. package/src/mount.tsx +6 -1
  88. package/src/pages/Config.tsx +57 -49
  89. package/src/pages/Fleet.tsx +19 -17
  90. package/src/pages/Storage.tsx +277 -0
  91. package/src/pages/Stream.tsx +6 -3
  92. package/src/pages/index.ts +1 -0
  93. package/src/routeTree.ts +2 -0
  94. package/src/routes/storage.tsx +9 -0
  95. package/src/static-server.ts +12 -1
  96. package/src/styles/globals.css +4 -1
  97. package/web-dist/assets/index-D8JLMM1I.js +86 -0
  98. package/web-dist/assets/index-D_fQabjS.css +1 -0
  99. package/web-dist/console.css +1 -1
  100. package/web-dist/index.html +2 -2
  101. package/web-dist/site.webmanifest +2 -2
  102. package/web-dist/assets/index-CTkQp6YC.js +0 -86
  103. package/web-dist/assets/index-j_U2SoXa.css +0 -1
@@ -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;
@@ -129,7 +243,6 @@ export function useLiveEvents(
129
243
  : baseUrl.pathname;
130
244
  baseUrl.pathname = `${normalizedPath}/console/events/live`;
131
245
  baseUrl.search = '';
132
- baseUrl.searchParams.set('token', config.token);
133
246
  if (lastEventTimestampRef.current) {
134
247
  baseUrl.searchParams.set('since', lastEventTimestampRef.current);
135
248
  }
@@ -152,8 +265,21 @@ export function useLiveEvents(
152
265
  return;
153
266
  }
154
267
  reconnectAttemptsRef.current = 0;
155
- markActivity();
156
268
  setError(null);
269
+ setConnectionState('connecting');
270
+ setIsConnected(false);
271
+
272
+ try {
273
+ ws.send(
274
+ JSON.stringify({
275
+ type: 'auth',
276
+ token: config.token,
277
+ })
278
+ );
279
+ } catch {
280
+ ws.close();
281
+ return;
282
+ }
157
283
 
158
284
  clearStaleInterval();
159
285
  staleCheckIntervalRef.current = setInterval(() => {
@@ -190,7 +316,19 @@ export function useLiveEvents(
190
316
  markActivity();
191
317
 
192
318
  // Skip control events
193
- if (eventType === 'connected' || eventType === 'heartbeat') {
319
+ if (
320
+ eventType === 'connected' ||
321
+ eventType === 'heartbeat' ||
322
+ eventType === 'auth_required'
323
+ ) {
324
+ return;
325
+ }
326
+ if (eventType === 'error') {
327
+ const message =
328
+ typeof data.message === 'string'
329
+ ? data.message
330
+ : 'Live events authentication failed';
331
+ setError(new Error(message));
194
332
  return;
195
333
  }
196
334
 
package/src/layout.tsx CHANGED
@@ -1,15 +1,18 @@
1
1
  import {
2
+ Badge,
2
3
  BottomBar,
3
4
  Button,
4
5
  ConnectionStatusBadge,
5
6
  Input,
6
7
  NavPill,
7
8
  NavPillGroup,
9
+ navActionLinkClassName,
8
10
  SyncularBrand,
9
11
  TopNavigation,
10
12
  } from '@syncular/ui';
11
13
  import { Link, Outlet, useRouterState } from '@tanstack/react-router';
12
- import { Settings } from 'lucide-react';
14
+ import { ArrowLeft, Settings } from 'lucide-react';
15
+ import type { ReactNode } from 'react';
13
16
  import { useMemo } from 'react';
14
17
  import { useConnection } from './hooks/ConnectionContext';
15
18
  import { useStats } from './hooks/useConsoleApi';
@@ -20,9 +23,17 @@ import { SYNCULAR_CONSOLE_ROOT_CLASS } from './theme-scope';
20
23
 
21
24
  interface ConsoleLayoutProps {
22
25
  basePath?: string;
26
+ appHref?: string;
27
+ modeBadge?: ReactNode;
23
28
  }
24
29
 
25
- type ConsoleNavSuffix = '' | '/stream' | '/fleet' | '/ops' | '/config';
30
+ type ConsoleNavSuffix =
31
+ | ''
32
+ | '/stream'
33
+ | '/fleet'
34
+ | '/ops'
35
+ | '/storage'
36
+ | '/config';
26
37
 
27
38
  interface ConsoleNavItem {
28
39
  suffix: ConsoleNavSuffix;
@@ -34,6 +45,7 @@ const NAV_ITEMS: ConsoleNavItem[] = [
34
45
  { suffix: '/stream', label: 'Stream' },
35
46
  { suffix: '/fleet', label: 'Fleet' },
36
47
  { suffix: '/ops', label: 'Ops' },
48
+ { suffix: '/storage', label: 'Storage' },
37
49
  { suffix: '/config', label: 'Config' },
38
50
  ];
39
51
 
@@ -49,7 +61,11 @@ function resolvePath(basePath: string, suffix: ConsoleNavSuffix): string {
49
61
  return suffix ? `${basePath}${suffix}` : basePath;
50
62
  }
51
63
 
52
- export function ConsoleLayout({ basePath }: ConsoleLayoutProps) {
64
+ export function ConsoleLayout({
65
+ basePath,
66
+ appHref,
67
+ modeBadge,
68
+ }: ConsoleLayoutProps) {
53
69
  const { connect, config, isConnected, isConnecting } = useConnection();
54
70
  const { preferences } = usePreferences();
55
71
  const { instanceId, rawInstanceId, setInstanceId, clearInstanceId } =
@@ -127,6 +143,14 @@ export function ConsoleLayout({ basePath }: ConsoleLayoutProps) {
127
143
  }
128
144
  right={
129
145
  <div className="flex items-center gap-2">
146
+ {modeBadge ? (
147
+ <Badge
148
+ variant="flow"
149
+ className="hidden md:inline-flex px-2 py-1 text-[10px]"
150
+ >
151
+ {modeBadge}
152
+ </Badge>
153
+ ) : null}
130
154
  <div className="flex items-center gap-1">
131
155
  <span className="font-mono text-[9px] text-neutral-500 uppercase tracking-wide">
132
156
  Instance
@@ -179,14 +203,20 @@ export function ConsoleLayout({ basePath }: ConsoleLayoutProps) {
179
203
  variant={pathname === configPath ? 'secondary' : 'ghost'}
180
204
  size="icon"
181
205
  >
182
- <Settings />
206
+ <Settings className="h-3 w-3" />
183
207
  </Button>
184
208
  </Link>
209
+ {appHref ? (
210
+ <a href={appHref} className={navActionLinkClassName}>
211
+ <ArrowLeft className="h-3 w-3" />
212
+ Go to app
213
+ </a>
214
+ ) : null}
185
215
  </div>
186
216
  }
187
217
  />
188
218
 
189
- <main className="flex-1 overflow-auto pt-[42px] pb-[32px]">
219
+ <main className="flex-1 overflow-auto pb-[32px]">
190
220
  <div className="min-h-full">
191
221
  {isConnected || pathname === configPath ? (
192
222
  <div key={pathname} style={{ animation: 'pageIn 0.3s ease-out' }}>
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
@@ -7,6 +7,7 @@ interface MountSyncularConsoleOptions {
7
7
  strictMode?: boolean;
8
8
  basePath?: SyncularConsoleProps['basePath'];
9
9
  defaultConfig?: SyncularConsoleProps['defaultConfig'];
10
+ autoConnect?: SyncularConsoleProps['autoConnect'];
10
11
  }
11
12
 
12
13
  function resolveContainer(containerOrSelector: Element | string): Element {
@@ -33,7 +34,11 @@ export function mountSyncularConsoleApp(
33
34
 
34
35
  const root = createRoot(container);
35
36
  const app = (
36
- <App basePath={options.basePath} defaultConfig={options.defaultConfig} />
37
+ <App
38
+ basePath={options.basePath}
39
+ defaultConfig={options.defaultConfig}
40
+ autoConnect={options.autoConnect}
41
+ />
37
42
  );
38
43
 
39
44
  if (options.strictMode === false) {
@@ -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
  }
@@ -79,15 +80,24 @@ function ConnectionTab() {
79
80
 
80
81
  useEffect(() => {
81
82
  const params = new URLSearchParams(window.location.search);
82
- const urlToken = params.get('token');
83
83
  const urlServer = params.get('server');
84
+ let shouldReplaceUrl = false;
84
85
 
85
- if (urlToken) {
86
- setToken(urlToken);
87
- window.history.replaceState({}, '', window.location.pathname);
88
- }
89
86
  if (urlServer) {
90
87
  setServerUrl(urlServer);
88
+ params.delete('server');
89
+ shouldReplaceUrl = true;
90
+ }
91
+ if (params.has('token')) {
92
+ params.delete('token');
93
+ shouldReplaceUrl = true;
94
+ }
95
+ if (shouldReplaceUrl) {
96
+ const nextQuery = params.toString();
97
+ const nextUrl = nextQuery
98
+ ? `${window.location.pathname}?${nextQuery}`
99
+ : window.location.pathname;
100
+ window.history.replaceState({}, '', nextUrl);
91
101
  }
92
102
  }, []);
93
103
 
@@ -554,7 +564,7 @@ function ApiKeysTab() {
554
564
  <Table>
555
565
  <TableHeader>
556
566
  <TableRow>
557
- <TableHead>
567
+ <TableHead className="w-[28px]">
558
568
  <Checkbox
559
569
  checked={allSelectableChecked}
560
570
  indeterminate={
@@ -572,16 +582,15 @@ function ApiKeysTab() {
572
582
  aria-label="Select all active keys"
573
583
  />
574
584
  </TableHead>
575
- <TableHead>NAME</TableHead>
576
- <TableHead>TYPE</TableHead>
577
- <TableHead>KEY PREFIX</TableHead>
578
- <TableHead>ACTOR</TableHead>
579
- <TableHead>SCOPES</TableHead>
580
- <TableHead>CREATED</TableHead>
581
- <TableHead>LAST USED</TableHead>
582
- <TableHead>EXPIRES</TableHead>
583
- <TableHead>STATUS</TableHead>
584
- <TableHead>ACTIONS</TableHead>
585
+ <TableHead className="w-[100px]">NAME</TableHead>
586
+ <TableHead className="w-[55px]">TYPE</TableHead>
587
+ <TableHead className="w-[90px]">KEY PREFIX</TableHead>
588
+ <TableHead className="w-[80px]">ACTOR</TableHead>
589
+ <TableHead className="w-[100px]">SCOPES</TableHead>
590
+ <TableHead className="w-[120px]">CREATED</TableHead>
591
+ <TableHead className="w-[120px]">LAST USED</TableHead>
592
+ <TableHead className="w-[120px]">EXPIRES</TableHead>
593
+ <TableHead className="flex-1">STATUS</TableHead>
585
594
  </TableRow>
586
595
  </TableHeader>
587
596
  <TableBody>
@@ -592,8 +601,8 @@ function ApiKeysTab() {
592
601
  );
593
602
 
594
603
  return (
595
- <TableRow key={apiKey.keyId}>
596
- <TableCell>
604
+ <TableRow key={apiKey.keyId} className="group relative">
605
+ <TableCell className="w-[28px]">
597
606
  <Checkbox
598
607
  checked={selectedKeyIds.includes(apiKey.keyId)}
599
608
  onCheckedChange={(checked) => {
@@ -609,8 +618,10 @@ function ApiKeysTab() {
609
618
  disabled={apiKey.revokedAt !== null}
610
619
  />
611
620
  </TableCell>
612
- <TableCell className="font-medium">{apiKey.name}</TableCell>
613
- <TableCell>
621
+ <TableCell className="w-[100px] font-medium">
622
+ {apiKey.name}
623
+ </TableCell>
624
+ <TableCell className="w-[55px]">
614
625
  <Badge
615
626
  variant={
616
627
  apiKey.keyType === 'admin'
@@ -623,63 +634,60 @@ function ApiKeysTab() {
623
634
  {apiKey.keyType}
624
635
  </Badge>
625
636
  </TableCell>
626
- <TableCell>
637
+ <TableCell className="w-[90px]">
627
638
  <code className="font-mono text-[11px]">
628
639
  {apiKey.keyPrefix}...
629
640
  </code>
630
641
  </TableCell>
631
- <TableCell className="text-neutral-500">
642
+ <TableCell className="w-[80px] text-neutral-500">
632
643
  {apiKey.actorId ?? '-'}
633
644
  </TableCell>
634
- <TableCell className="max-w-[220px] text-neutral-500">
645
+ <TableCell className="w-[100px] text-neutral-500">
635
646
  <code className="font-mono text-[10px]">
636
647
  {summarizeScopeKeys(apiKey.scopeKeys)}
637
648
  </code>
638
649
  </TableCell>
639
- <TableCell className="text-neutral-500">
650
+ <TableCell className="w-[120px] text-neutral-500">
640
651
  {formatOptionalDateTime(apiKey.createdAt)}
641
652
  </TableCell>
642
- <TableCell className="text-neutral-500">
653
+ <TableCell className="w-[120px] text-neutral-500">
643
654
  {formatOptionalDateTime(apiKey.lastUsedAt)}
644
655
  </TableCell>
645
- <TableCell className="text-neutral-500">
656
+ <TableCell className="w-[120px] text-neutral-500">
646
657
  {formatOptionalDateTime(apiKey.expiresAt)}
647
658
  </TableCell>
648
- <TableCell>
659
+ <TableCell className="flex-1">
649
660
  <Badge
650
661
  variant={getApiKeyStatusBadgeVariant(lifecycleStatus)}
651
662
  >
652
663
  {lifecycleStatus}
653
664
  </Badge>
654
665
  </TableCell>
655
- <TableCell>
656
- <div className="flex items-center gap-1">
657
- <Button
658
- variant="default"
659
- size="sm"
666
+ {apiKey.revokedAt === null && (
667
+ <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
668
+ <button
669
+ type="button"
660
670
  onClick={() => setStagingRotateKey(apiKey)}
661
- disabled={apiKey.revokedAt !== null}
671
+ className="px-1.5 py-0.5 rounded text-[9px] font-mono text-neutral-600 hover:text-white hover:bg-white/[0.05] cursor-pointer transition-colors"
662
672
  >
663
- Stage
664
- </Button>
665
- <Button
666
- variant="default"
667
- size="sm"
673
+ stage
674
+ </button>
675
+ <button
676
+ type="button"
668
677
  onClick={() => setRotatingKeyId(apiKey.keyId)}
669
- disabled={apiKey.revokedAt !== null}
678
+ className="px-1.5 py-0.5 rounded text-[9px] font-mono text-neutral-600 hover:text-white hover:bg-white/[0.05] cursor-pointer transition-colors"
670
679
  >
671
- Rotate
672
- </Button>
673
- <Button
674
- variant="destructive"
675
- size="sm"
680
+ rotate
681
+ </button>
682
+ <button
683
+ type="button"
676
684
  onClick={() => setRevokingKeyId(apiKey.keyId)}
677
- disabled={apiKey.revokedAt !== null}
685
+ className="px-1.5 py-0.5 rounded text-[9px] font-mono text-neutral-600 hover:text-offline hover:bg-offline/10 cursor-pointer transition-colors"
678
686
  >
679
- Revoke
680
- </Button>
687
+ revoke
688
+ </button>
681
689
  </div>
682
- </TableCell>
690
+ )}
683
691
  </TableRow>
684
692
  );
685
693
  })}
@@ -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 && (