@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
@@ -0,0 +1,193 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import {
6
+ CONSOLE_BASEPATH_META,
7
+ CONSOLE_SERVER_URL_META,
8
+ CONSOLE_TOKEN_META,
9
+ } from '../runtime-config';
10
+ import { createConsoleStaticResponder } from '../static-server';
11
+
12
+ const tempDirs: string[] = [];
13
+
14
+ function countMatches(value: string, pattern: RegExp): number {
15
+ return value.match(pattern)?.length ?? 0;
16
+ }
17
+
18
+ async function createStaticFixture(): Promise<string> {
19
+ const staticDir = await mkdtemp(path.join(tmpdir(), 'syncular-console-'));
20
+ tempDirs.push(staticDir);
21
+
22
+ await mkdir(path.join(staticDir, 'assets'), { recursive: true });
23
+ await writeFile(
24
+ path.join(staticDir, 'index.html'),
25
+ `<!doctype html>
26
+ <html>
27
+ <head>
28
+ <meta name="${CONSOLE_BASEPATH_META}" content="/old-basepath" />
29
+ <meta name="${CONSOLE_SERVER_URL_META}" content="https://old.example/api" />
30
+ <meta name="${CONSOLE_TOKEN_META}" content="old-token" />
31
+ <link rel="stylesheet" href="/assets/console.css" />
32
+ </head>
33
+ <body>
34
+ <script type="module" src="/assets/main.js"></script>
35
+ </body>
36
+ </html>`
37
+ );
38
+ await writeFile(
39
+ path.join(staticDir, 'assets', 'main.js'),
40
+ 'console.log(1);\n'
41
+ );
42
+ await writeFile(path.join(staticDir, 'assets', 'console.css'), 'body{}\n');
43
+
44
+ return staticDir;
45
+ }
46
+
47
+ afterEach(async () => {
48
+ await Promise.all(
49
+ tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
50
+ );
51
+ });
52
+
53
+ describe('createConsoleStaticResponder', () => {
54
+ it('serves index with prefilled meta tags and rewrites rooted asset paths', async () => {
55
+ const staticDir = await createStaticFixture();
56
+ const responder = createConsoleStaticResponder({
57
+ mountPath: '/console',
58
+ staticDir,
59
+ defaultPrefill: {
60
+ serverUrl: 'https://api.example.com',
61
+ token: 'default-token',
62
+ },
63
+ });
64
+
65
+ const response = await responder(new Request('http://localhost/console'));
66
+ if (!response) {
67
+ throw new Error('Expected console index response.');
68
+ }
69
+
70
+ const html = await response.text();
71
+ expect(response.status).toBe(200);
72
+ expect(response.headers.get('cache-control')).toBe('no-store');
73
+ expect(html).toContain(
74
+ `<meta name="${CONSOLE_BASEPATH_META}" content="/console" />`
75
+ );
76
+ expect(html).toContain(
77
+ `<meta name="${CONSOLE_SERVER_URL_META}" content="https://api.example.com" />`
78
+ );
79
+ expect(html).toContain(
80
+ `<meta name="${CONSOLE_TOKEN_META}" content="default-token" />`
81
+ );
82
+ expect(html).toContain('href="/console/assets/console.css"');
83
+ expect(html).toContain('src="/console/assets/main.js"');
84
+
85
+ expect(
86
+ countMatches(html, new RegExp(`name="${CONSOLE_BASEPATH_META}"`, 'g'))
87
+ ).toBe(1);
88
+ expect(
89
+ countMatches(html, new RegExp(`name="${CONSOLE_SERVER_URL_META}"`, 'g'))
90
+ ).toBe(1);
91
+ expect(
92
+ countMatches(html, new RegExp(`name="${CONSOLE_TOKEN_META}"`, 'g'))
93
+ ).toBe(1);
94
+ });
95
+
96
+ it('applies request-level prefill overrides when serving index', async () => {
97
+ const staticDir = await createStaticFixture();
98
+ const responder = createConsoleStaticResponder({
99
+ mountPath: '/console',
100
+ staticDir,
101
+ defaultPrefill: {
102
+ basePath: '/console',
103
+ serverUrl: 'https://default.example/api',
104
+ token: 'default-token',
105
+ },
106
+ });
107
+
108
+ const response = await responder(
109
+ new Request('http://localhost/console/app'),
110
+ {
111
+ prefill: {
112
+ basePath: '/ops',
113
+ serverUrl: 'https://ops.example/api',
114
+ token: 'request-token',
115
+ },
116
+ }
117
+ );
118
+ if (!response) {
119
+ throw new Error('Expected console index response.');
120
+ }
121
+
122
+ const html = await response.text();
123
+ expect(response.status).toBe(200);
124
+ expect(html).toContain(
125
+ `<meta name="${CONSOLE_BASEPATH_META}" content="/ops" />`
126
+ );
127
+ expect(html).toContain(
128
+ `<meta name="${CONSOLE_SERVER_URL_META}" content="https://ops.example/api" />`
129
+ );
130
+ expect(html).toContain(
131
+ `<meta name="${CONSOLE_TOKEN_META}" content="request-token" />`
132
+ );
133
+ expect(html).toContain('href="/ops/assets/console.css"');
134
+ expect(html).toContain('src="/ops/assets/main.js"');
135
+ });
136
+
137
+ it('serves static assets, blocks traversal, and handles missing assets', async () => {
138
+ const staticDir = await createStaticFixture();
139
+ const responder = createConsoleStaticResponder({
140
+ mountPath: '/console',
141
+ staticDir,
142
+ });
143
+
144
+ const assetResponse = await responder(
145
+ new Request('http://localhost/console/assets/main.js')
146
+ );
147
+ if (!assetResponse) {
148
+ throw new Error('Expected static asset response.');
149
+ }
150
+ expect(assetResponse.status).toBe(200);
151
+ expect(assetResponse.headers.get('content-type')).toBe(
152
+ 'text/javascript; charset=utf-8'
153
+ );
154
+ expect(assetResponse.headers.get('cache-control')).toBe(
155
+ 'public, max-age=31536000, immutable'
156
+ );
157
+ expect(await assetResponse.text()).toContain('console.log(1);');
158
+
159
+ const forbidden = await responder(
160
+ new Request('http://localhost/console/%2e%2e%2fsecret.js')
161
+ );
162
+ if (!forbidden) {
163
+ throw new Error('Expected forbidden response.');
164
+ }
165
+ expect(forbidden.status).toBe(403);
166
+
167
+ const missing = await responder(
168
+ new Request('http://localhost/console/assets/missing.js')
169
+ );
170
+ if (!missing) {
171
+ throw new Error('Expected missing response.');
172
+ }
173
+ expect(missing.status).toBe(404);
174
+ });
175
+
176
+ it('returns null for unsupported methods and non-matching mount paths', async () => {
177
+ const staticDir = await createStaticFixture();
178
+ const responder = createConsoleStaticResponder({
179
+ mountPath: '/console',
180
+ staticDir,
181
+ });
182
+
183
+ const postResponse = await responder(
184
+ new Request('http://localhost/console', { method: 'POST' })
185
+ );
186
+ const otherPathResponse = await responder(
187
+ new Request('http://localhost/admin')
188
+ );
189
+
190
+ expect(postResponse).toBeNull();
191
+ expect(otherPathResponse).toBeNull();
192
+ });
193
+ });
@@ -10,6 +10,7 @@ import {
10
10
  useContext,
11
11
  useEffect,
12
12
  useMemo,
13
+ useRef,
13
14
  useState,
14
15
  } from 'react';
15
16
  import {
@@ -17,7 +18,58 @@ import {
17
18
  createConsoleClient,
18
19
  testConnection,
19
20
  } from '../lib/api';
20
- import { useLocalStorage } from './useLocalStorage';
21
+
22
+ export type ConnectionStorageMode = 'memory' | 'session' | 'local';
23
+
24
+ const CONNECTION_STORAGE_KEY = 'sync-console-connection';
25
+
26
+ function normalizeConfig(
27
+ config: ConnectionConfig | null | undefined
28
+ ): ConnectionConfig | null {
29
+ if (!config) return null;
30
+ const serverUrl = config.serverUrl?.trim() ?? '';
31
+ const token = config.token?.trim() ?? '';
32
+ if (!serverUrl || !token) return null;
33
+ return { serverUrl, token };
34
+ }
35
+
36
+ function getStorageForMode(mode: ConnectionStorageMode): Storage | null {
37
+ if (typeof window === 'undefined') return null;
38
+ if (mode === 'local') return window.localStorage;
39
+ if (mode === 'session') return window.sessionStorage;
40
+ return null;
41
+ }
42
+
43
+ function readStoredConfig(
44
+ mode: ConnectionStorageMode
45
+ ): ConnectionConfig | null {
46
+ const storage = getStorageForMode(mode);
47
+ if (!storage) return null;
48
+ try {
49
+ const raw = storage.getItem(CONNECTION_STORAGE_KEY);
50
+ if (!raw) return null;
51
+ return normalizeConfig(JSON.parse(raw) as ConnectionConfig);
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function writeStoredConfig(
58
+ mode: ConnectionStorageMode,
59
+ config: ConnectionConfig | null
60
+ ): void {
61
+ const storage = getStorageForMode(mode);
62
+ if (!storage) return;
63
+ try {
64
+ if (!config) {
65
+ storage.removeItem(CONNECTION_STORAGE_KEY);
66
+ return;
67
+ }
68
+ storage.setItem(CONNECTION_STORAGE_KEY, JSON.stringify(config));
69
+ } catch {
70
+ // Ignore storage write errors.
71
+ }
72
+ }
21
73
 
22
74
  interface ConnectionState {
23
75
  isConnected: boolean;
@@ -44,6 +96,8 @@ interface ConnectionContextValue {
44
96
  interface ConnectionProviderProps {
45
97
  children: ReactNode;
46
98
  defaultConfig?: ConnectionConfig | null;
99
+ autoConnect?: boolean;
100
+ storageMode?: ConnectionStorageMode;
47
101
  }
48
102
 
49
103
  const ConnectionContext = createContext<ConnectionContextValue | null>(null);
@@ -51,10 +105,35 @@ const ConnectionContext = createContext<ConnectionContextValue | null>(null);
51
105
  export function ConnectionProvider({
52
106
  children,
53
107
  defaultConfig = null,
108
+ autoConnect = false,
109
+ storageMode = 'session',
54
110
  }: ConnectionProviderProps) {
55
- const [config, setConfigStorage] = useLocalStorage<ConnectionConfig | null>(
56
- 'sync-console-connection',
57
- null
111
+ const [config, setConfigState] = useState<ConnectionConfig | null>(() =>
112
+ readStoredConfig(storageMode)
113
+ );
114
+
115
+ useEffect(() => {
116
+ const storedConfig = readStoredConfig(storageMode);
117
+ setConfigState(storedConfig);
118
+ }, [storageMode]);
119
+
120
+ useEffect(() => {
121
+ if (typeof window === 'undefined') return;
122
+ if (storageMode !== 'local') {
123
+ window.localStorage.removeItem(CONNECTION_STORAGE_KEY);
124
+ }
125
+ if (storageMode === 'memory') {
126
+ window.sessionStorage.removeItem(CONNECTION_STORAGE_KEY);
127
+ }
128
+ }, [storageMode]);
129
+
130
+ const setConfigStorage = useCallback(
131
+ (nextConfig: ConnectionConfig | null) => {
132
+ const normalized = normalizeConfig(nextConfig);
133
+ setConfigState(normalized);
134
+ writeStoredConfig(storageMode, normalized);
135
+ },
136
+ [storageMode]
58
137
  );
59
138
 
60
139
  const [state, setState] = useState<ConnectionState>({
@@ -63,6 +142,7 @@ export function ConnectionProvider({
63
142
  error: null,
64
143
  client: null,
65
144
  });
145
+ const lastAutoConnectConfigKeyRef = useRef<string | null>(null);
66
146
 
67
147
  // Resolve initial config: saved config -> provided defaults
68
148
  useEffect(() => {
@@ -90,18 +170,16 @@ export function ConnectionProvider({
90
170
  return false;
91
171
  }
92
172
 
93
- const normalizedConfig: ConnectionConfig = {
94
- serverUrl: effectiveConfig.serverUrl?.trim() ?? '',
95
- token: effectiveConfig.token?.trim() ?? '',
96
- };
173
+ const normalizedConfig = normalizeConfig(effectiveConfig);
97
174
 
98
175
  // Validate config has required fields
99
- if (!normalizedConfig.serverUrl) {
100
- setState((s) => ({ ...s, error: 'Server URL is required' }));
101
- return false;
102
- }
103
- if (!normalizedConfig.token) {
104
- setState((s) => ({ ...s, error: 'Token is required' }));
176
+ if (!normalizedConfig) {
177
+ const hasServerUrl =
178
+ (effectiveConfig.serverUrl?.trim() ?? '').length > 0;
179
+ setState((s) => ({
180
+ ...s,
181
+ error: hasServerUrl ? 'Token is required' : 'Server URL is required',
182
+ }));
105
183
  return false;
106
184
  }
107
185
 
@@ -113,24 +191,14 @@ export function ConnectionProvider({
113
191
 
114
192
  try {
115
193
  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
- }
194
+ await testConnection(client);
127
195
  setState({
128
- isConnected: false,
196
+ isConnected: true,
129
197
  isConnecting: false,
130
- client: null,
131
- error: 'Failed to connect',
198
+ client,
199
+ error: null,
132
200
  });
133
- return false;
201
+ return true;
134
202
  } catch (err) {
135
203
  setState({
136
204
  isConnected: false,
@@ -192,6 +260,32 @@ export function ConnectionProvider({
192
260
  [config, setConfig, state, connect, disconnect, clearError]
193
261
  );
194
262
 
263
+ useEffect(() => {
264
+ if (!autoConnect || state.isConnected || state.isConnecting) {
265
+ return;
266
+ }
267
+
268
+ const candidate = config ?? defaultConfig;
269
+ const key = normalizeConfigKey(candidate);
270
+ if (!candidate || !key) {
271
+ return;
272
+ }
273
+
274
+ if (lastAutoConnectConfigKeyRef.current === key) {
275
+ return;
276
+ }
277
+
278
+ lastAutoConnectConfigKeyRef.current = key;
279
+ void connect(candidate, { persistOverride: true });
280
+ }, [
281
+ autoConnect,
282
+ config,
283
+ defaultConfig,
284
+ state.isConnected,
285
+ state.isConnecting,
286
+ connect,
287
+ ]);
288
+
195
289
  return (
196
290
  <ConnectionContext.Provider value={value}>
197
291
  {children}
@@ -199,6 +293,18 @@ export function ConnectionProvider({
199
293
  );
200
294
  }
201
295
 
296
+ function normalizeConfigKey(config: ConnectionConfig | null): string | null {
297
+ if (!config) {
298
+ return null;
299
+ }
300
+ const serverUrl = config.serverUrl?.trim() ?? '';
301
+ const token = config.token?.trim() ?? '';
302
+ if (!serverUrl || !token) {
303
+ return null;
304
+ }
305
+ return `${serverUrl}\u0000${token}`;
306
+ }
307
+
202
308
  export function useConnection(): ConnectionContextValue {
203
309
  const context = useContext(ConnectionContext);
204
310
  if (!context) {
@@ -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
+ }