hazo_admin 0.6.3 → 0.6.4
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/CHANGE_LOG.md +5 -0
- package/dist/api/env_migrate_handler.d.ts +14 -0
- package/dist/api/env_migrate_handler.d.ts.map +1 -1
- package/dist/api/env_migrate_handler.js +65 -0
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +73 -1
- package/dist/components/env_migration_panel.d.ts.map +1 -1
- package/dist/components/env_migration_panel.js +135 -1
- package/package.json +1 -1
package/CHANGE_LOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# hazo_admin Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.4] - 2026-06-26
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Dist rebuild to include 0.6.2 env panel changes that were missing from the 0.6.3 publish (source changes were present but `npm run build` was not run before publish).
|
|
7
|
+
|
|
3
8
|
## [0.6.2] - 2026-06-25
|
|
4
9
|
|
|
5
10
|
### Added
|
|
@@ -10,6 +10,20 @@ export declare function envMigrateJobHandler(job: {
|
|
|
10
10
|
}, ctx: {
|
|
11
11
|
log: (...args: any[]) => void;
|
|
12
12
|
}): Promise<unknown>;
|
|
13
|
+
export declare const ENV_FILES_SYNC_JOB_TYPE: "env.files-sync";
|
|
14
|
+
export declare function envFilesSyncJobHandler(job: {
|
|
15
|
+
id: string;
|
|
16
|
+
payload: unknown;
|
|
17
|
+
}, ctx: {
|
|
18
|
+
log: (...args: any[]) => void;
|
|
19
|
+
}): Promise<unknown>;
|
|
20
|
+
export declare const ENV_CLEAR_JOB_TYPE: "env.clear";
|
|
21
|
+
export declare function envClearJobHandler(job: {
|
|
22
|
+
id: string;
|
|
23
|
+
payload: unknown;
|
|
24
|
+
}, ctx: {
|
|
25
|
+
log: (...args: any[]) => void;
|
|
26
|
+
}): Promise<unknown>;
|
|
13
27
|
/**
|
|
14
28
|
* Register the env.migrate job type with a hazo_jobs worker.
|
|
15
29
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"env_migrate_handler.d.ts","sourceRoot":"","sources":["../../src/api/env_migrate_handler.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAGrB,eAAO,MAAM,oBAAoB,EAAG,aAAsB,CAAC;AAE3D;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,EACrC,GAAG,EAAE;IAAE,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;CAAE,GACrC,OAAO,CAAC,OAAO,CAAC,CAkClB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE;IACtC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC;CACrF,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"env_migrate_handler.d.ts","sourceRoot":"","sources":["../../src/api/env_migrate_handler.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAGrB,eAAO,MAAM,oBAAoB,EAAG,aAAsB,CAAC;AAE3D;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,EACrC,GAAG,EAAE;IAAE,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;CAAE,GACrC,OAAO,CAAC,OAAO,CAAC,CAkClB;AAED,eAAO,MAAM,uBAAuB,EAAG,gBAAyB,CAAC;AAEjE,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,EACrC,GAAG,EAAE;IAAE,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;CAAE,GACrC,OAAO,CAAC,OAAO,CAAC,CA4BlB;AAED,eAAO,MAAM,kBAAkB,EAAG,WAAoB,CAAC;AAEvD,wBAAsB,kBAAkB,CACtC,GAAG,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,EACrC,GAAG,EAAE;IAAE,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;CAAE,GACrC,OAAO,CAAC,OAAO,CAAC,CA6BlB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE;IACtC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC;CACrF,GAAG,IAAI,CAIP"}
|
|
@@ -36,9 +36,74 @@ export async function envMigrateJobHandler(job, ctx) {
|
|
|
36
36
|
throw err;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
export const ENV_FILES_SYNC_JOB_TYPE = 'env.files-sync';
|
|
40
|
+
export async function envFilesSyncJobHandler(job, ctx) {
|
|
41
|
+
let runMigration;
|
|
42
|
+
let resolveFilesConfig;
|
|
43
|
+
try {
|
|
44
|
+
const hazoEnv = await import('hazo_env');
|
|
45
|
+
runMigration = hazoEnv.runMigration;
|
|
46
|
+
resolveFilesConfig = hazoEnv.resolveFilesConfig;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
ctx.log('[env_files_sync_handler] hazo_env not installed:', err);
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
const progressDir = path.join(resolveFilesConfig().local.basePath, 'migrations', 'progress');
|
|
53
|
+
const raw = job.payload;
|
|
54
|
+
const payload = typeof raw === 'string' ? JSON.parse(raw) : (raw && typeof raw === 'object' ? raw : {});
|
|
55
|
+
try {
|
|
56
|
+
const result = await runMigration({
|
|
57
|
+
from: payload['from'],
|
|
58
|
+
to: payload['to'],
|
|
59
|
+
include: { db: false, files: true },
|
|
60
|
+
jobId: job.id,
|
|
61
|
+
progressDir,
|
|
62
|
+
});
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
ctx.log(`[env_files_sync_handler] job ${job.id} failed:`, err);
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export const ENV_CLEAR_JOB_TYPE = 'env.clear';
|
|
71
|
+
export async function envClearJobHandler(job, ctx) {
|
|
72
|
+
let clearEnv;
|
|
73
|
+
let resolveFilesConfig;
|
|
74
|
+
try {
|
|
75
|
+
const hazoEnv = await import('hazo_env');
|
|
76
|
+
clearEnv = hazoEnv.clearEnv;
|
|
77
|
+
resolveFilesConfig = hazoEnv.resolveFilesConfig;
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
ctx.log('[env_clear_handler] hazo_env not installed:', err);
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
const progressDir = path.join(resolveFilesConfig().local.basePath, 'migrations', 'progress');
|
|
84
|
+
const raw = job.payload;
|
|
85
|
+
const payload = typeof raw === 'string' ? JSON.parse(raw) : (raw && typeof raw === 'object' ? raw : {});
|
|
86
|
+
try {
|
|
87
|
+
const result = await clearEnv({
|
|
88
|
+
env: payload['env'],
|
|
89
|
+
include: payload['include'] ?? { db: true, files: true },
|
|
90
|
+
allowProdTarget: payload['allowProdTarget'],
|
|
91
|
+
confirmToken: payload['confirmToken'],
|
|
92
|
+
jobId: job.id,
|
|
93
|
+
progressDir,
|
|
94
|
+
});
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
ctx.log(`[env_clear_handler] job ${job.id} failed:`, err);
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
39
102
|
/**
|
|
40
103
|
* Register the env.migrate job type with a hazo_jobs worker.
|
|
41
104
|
*/
|
|
42
105
|
export function registerEnvJobs(worker) {
|
|
43
106
|
worker.register(ENV_MIGRATE_JOB_TYPE, envMigrateJobHandler);
|
|
107
|
+
worker.register(ENV_FILES_SYNC_JOB_TYPE, envFilesSyncJobHandler);
|
|
108
|
+
worker.register(ENV_CLEAR_JOB_TYPE, envClearJobHandler);
|
|
44
109
|
}
|
package/dist/api/index.d.ts
CHANGED
|
@@ -46,5 +46,5 @@ export declare function createAdminPresetRoutes(manifest: AdminManifest, cfg: Ad
|
|
|
46
46
|
}>;
|
|
47
47
|
}) => Promise<Response>;
|
|
48
48
|
};
|
|
49
|
-
export { envMigrateJobHandler, ENV_MIGRATE_JOB_TYPE, registerEnvJobs, } from './env_migrate_handler.js';
|
|
49
|
+
export { envMigrateJobHandler, ENV_MIGRATE_JOB_TYPE, envFilesSyncJobHandler, ENV_FILES_SYNC_JOB_TYPE, envClearJobHandler, ENV_CLEAR_JOB_TYPE, registerEnvJobs, } from './env_migrate_handler.js';
|
|
50
50
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/api/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAIrB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAEhE,MAAM,MAAM,uBAAuB,GAAG;IACpC;;;;OAIG;IACH,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE;QACL,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,wFAAwF;QACxF,OAAO,CAAC,EAAE,IAAI,GAAG,QAAQ,CAAC;KAC3B,CAAC;IACF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE;QACJ,gFAAgF;QAChF,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;IACF,MAAM,CAAC,EAAE;QACP,2DAA2D;QAC3D,kBAAkB,CAAC,EAAE,MAAM,CAAC;KAC7B,CAAC;CACH,CAAC;AAuBF,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,aAAa,EAAE,GAAG,EAAE,uBAAuB;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAIrB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAEhE,MAAM,MAAM,uBAAuB,GAAG;IACpC;;;;OAIG;IACH,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE;QACL,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,wFAAwF;QACxF,OAAO,CAAC,EAAE,IAAI,GAAG,QAAQ,CAAC;KAC3B,CAAC;IACF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE;QACJ,gFAAgF;QAChF,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;IACF,MAAM,CAAC,EAAE;QACP,2DAA2D;QAC3D,kBAAkB,CAAC,EAAE,MAAM,CAAC;KAC7B,CAAC;CACH,CAAC;AAuBF,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,aAAa,EAAE,GAAG,EAAE,uBAAuB;mBAmiBlE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;oBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;mBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;sBAAzE,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE,KAAG,OAAO,CAAC,QAAQ,CAAC;EAuGnG;AAGD,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,uBAAuB,EACvB,kBAAkB,EAClB,kBAAkB,EAClB,eAAe,GAChB,MAAM,0BAA0B,CAAC"}
|
package/dist/api/index.js
CHANGED
|
@@ -261,6 +261,78 @@ export function createAdminPresetRoutes(manifest, cfg) {
|
|
|
261
261
|
headers: { 'Content-Type': 'application/json' },
|
|
262
262
|
});
|
|
263
263
|
}
|
|
264
|
+
// POST /env/files-sync — enqueue a files-only migration job
|
|
265
|
+
if (method === 'POST' && s[0] === 'files-sync' && s.length === 1) {
|
|
266
|
+
const jobsMod = await import('hazo_jobs/server').catch(() => null);
|
|
267
|
+
if (!jobsMod)
|
|
268
|
+
return notInstalled('hazo_jobs');
|
|
269
|
+
const body = await request.json();
|
|
270
|
+
const connect = await cfg.getHazoConnect();
|
|
271
|
+
const jobsClient = jobsMod.createJobsClient({
|
|
272
|
+
connect: { adapter: connect },
|
|
273
|
+
dialect: cfg.jobs?.dialect ?? 'pg',
|
|
274
|
+
});
|
|
275
|
+
const { jobId } = await jobsClient.submit({
|
|
276
|
+
type: 'env.files-sync',
|
|
277
|
+
description: `env-files-sync:${body.from}→${body.to}`,
|
|
278
|
+
payload: body,
|
|
279
|
+
});
|
|
280
|
+
return new Response(JSON.stringify({ jobId }), {
|
|
281
|
+
status: 200,
|
|
282
|
+
headers: { 'Content-Type': 'application/json' },
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// GET /env/files-sync/:jobId
|
|
286
|
+
if (method === 'GET' && s[0] === 'files-sync' && s[1] && s.length === 2) {
|
|
287
|
+
const jobsMod = await import('hazo_jobs/server').catch(() => null);
|
|
288
|
+
if (!jobsMod)
|
|
289
|
+
return notInstalled('hazo_jobs');
|
|
290
|
+
const connect = await cfg.getHazoConnect();
|
|
291
|
+
const jobsClient = jobsMod.createJobsClient({ connect: { adapter: connect }, dialect: cfg.jobs?.dialect ?? 'pg' });
|
|
292
|
+
const job = await jobsClient.get(s[1]);
|
|
293
|
+
return new Response(JSON.stringify(job), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
294
|
+
}
|
|
295
|
+
// GET /env/files-sync/:jobId/progress
|
|
296
|
+
if (method === 'GET' && s[0] === 'files-sync' && s[1] && s[2] === 'progress' && s.length === 3) {
|
|
297
|
+
const progress = hazoEnv.readMigrationProgress(progressDir, s[1]);
|
|
298
|
+
return new Response(JSON.stringify(progress), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
299
|
+
}
|
|
300
|
+
// POST /env/clear — enqueue a clear job
|
|
301
|
+
if (method === 'POST' && s[0] === 'clear' && s.length === 1) {
|
|
302
|
+
const jobsMod = await import('hazo_jobs/server').catch(() => null);
|
|
303
|
+
if (!jobsMod)
|
|
304
|
+
return notInstalled('hazo_jobs');
|
|
305
|
+
const body = await request.json();
|
|
306
|
+
const connect = await cfg.getHazoConnect();
|
|
307
|
+
const jobsClient = jobsMod.createJobsClient({
|
|
308
|
+
connect: { adapter: connect },
|
|
309
|
+
dialect: cfg.jobs?.dialect ?? 'pg',
|
|
310
|
+
});
|
|
311
|
+
const { jobId } = await jobsClient.submit({
|
|
312
|
+
type: 'env.clear',
|
|
313
|
+
description: `env-clear:${body.env}`,
|
|
314
|
+
payload: body,
|
|
315
|
+
});
|
|
316
|
+
return new Response(JSON.stringify({ jobId }), {
|
|
317
|
+
status: 200,
|
|
318
|
+
headers: { 'Content-Type': 'application/json' },
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
// GET /env/clear/:jobId
|
|
322
|
+
if (method === 'GET' && s[0] === 'clear' && s[1] && s.length === 2) {
|
|
323
|
+
const jobsMod = await import('hazo_jobs/server').catch(() => null);
|
|
324
|
+
if (!jobsMod)
|
|
325
|
+
return notInstalled('hazo_jobs');
|
|
326
|
+
const connect = await cfg.getHazoConnect();
|
|
327
|
+
const jobsClient = jobsMod.createJobsClient({ connect: { adapter: connect }, dialect: cfg.jobs?.dialect ?? 'pg' });
|
|
328
|
+
const job = await jobsClient.get(s[1]);
|
|
329
|
+
return new Response(JSON.stringify(job), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
330
|
+
}
|
|
331
|
+
// GET /env/clear/:jobId/progress
|
|
332
|
+
if (method === 'GET' && s[0] === 'clear' && s[1] && s[2] === 'progress' && s.length === 3) {
|
|
333
|
+
const progress = hazoEnv.readMigrationProgress(progressDir, s[1]);
|
|
334
|
+
return new Response(JSON.stringify(progress), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
335
|
+
}
|
|
264
336
|
return notFound();
|
|
265
337
|
}
|
|
266
338
|
async function handleAudit(_request, _method, _segments) {
|
|
@@ -532,4 +604,4 @@ export function createAdminPresetRoutes(manifest, cfg) {
|
|
|
532
604
|
};
|
|
533
605
|
}
|
|
534
606
|
// Re-export env job handler utilities
|
|
535
|
-
export { envMigrateJobHandler, ENV_MIGRATE_JOB_TYPE, registerEnvJobs, } from './env_migrate_handler.js';
|
|
607
|
+
export { envMigrateJobHandler, ENV_MIGRATE_JOB_TYPE, envFilesSyncJobHandler, ENV_FILES_SYNC_JOB_TYPE, envClearJobHandler, ENV_CLEAR_JOB_TYPE, registerEnvJobs, } from './env_migrate_handler.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"env_migration_panel.d.ts","sourceRoot":"","sources":["../../src/components/env_migration_panel.tsx"],"names":[],"mappings":"AACA,OAAO,KAAsC,MAAM,OAAO,CAAC;AAE3D,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAkDD,wBAAgB,iBAAiB,CAAC,EAAE,QAAuB,EAAE,EAAE,sBAAsB,
|
|
1
|
+
{"version":3,"file":"env_migration_panel.d.ts","sourceRoot":"","sources":["../../src/components/env_migration_panel.tsx"],"names":[],"mappings":"AACA,OAAO,KAAsC,MAAM,OAAO,CAAC;AAE3D,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAkDD,wBAAgB,iBAAiB,CAAC,EAAE,QAAuB,EAAE,EAAE,sBAAsB,qBAgrBpF"}
|
|
@@ -42,6 +42,24 @@ export function EnvMigrationPanel({ basePath = '/api/admin' }) {
|
|
|
42
42
|
// Restore state
|
|
43
43
|
const [restoreMsg, setRestoreMsg] = useState(null);
|
|
44
44
|
const pollRef = useRef(null);
|
|
45
|
+
// File Sync state
|
|
46
|
+
const [fsFrom, setFsFrom] = useState('');
|
|
47
|
+
const [fsTo, setFsTo] = useState('');
|
|
48
|
+
const [fsRunning, setFsRunning] = useState(false);
|
|
49
|
+
const [fsProgress, setFsProgress] = useState(null);
|
|
50
|
+
const [fsJob, setFsJob] = useState(null);
|
|
51
|
+
const [fsError, setFsError] = useState(null);
|
|
52
|
+
const fsPollRef = useRef(null);
|
|
53
|
+
// Clear state
|
|
54
|
+
const [clrEnv, setClrEnv] = useState('');
|
|
55
|
+
const [clrIncludeDb, setClrIncludeDb] = useState(true);
|
|
56
|
+
const [clrIncludeFiles, setClrIncludeFiles] = useState(true);
|
|
57
|
+
const [clrConfirm, setClrConfirm] = useState('');
|
|
58
|
+
const [clrRunning, setClrRunning] = useState(false);
|
|
59
|
+
const [clrProgress, setClrProgress] = useState(null);
|
|
60
|
+
const [clrJob, setClrJob] = useState(null);
|
|
61
|
+
const [clrError, setClrError] = useState(null);
|
|
62
|
+
const clrPollRef = useRef(null);
|
|
45
63
|
// Load env list on mount
|
|
46
64
|
useEffect(() => {
|
|
47
65
|
fetch(`${basePath}/env/list`, { credentials: 'include' })
|
|
@@ -54,6 +72,9 @@ export function EnvMigrationPanel({ basePath = '/api/admin' }) {
|
|
|
54
72
|
setFrom(data[0].name);
|
|
55
73
|
if (data.length > 1)
|
|
56
74
|
setTo(data[1].name);
|
|
75
|
+
setFsFrom(data[0].name);
|
|
76
|
+
setFsTo(data.length > 1 ? data[1].name : data[0].name);
|
|
77
|
+
setClrEnv(data.length > 1 ? data[1].name : data[0].name);
|
|
57
78
|
}
|
|
58
79
|
else {
|
|
59
80
|
setEnvsError('Failed to parse env list');
|
|
@@ -80,6 +101,10 @@ export function EnvMigrationPanel({ basePath = '/api/admin' }) {
|
|
|
80
101
|
return () => {
|
|
81
102
|
if (pollRef.current !== null)
|
|
82
103
|
clearInterval(pollRef.current);
|
|
104
|
+
if (fsPollRef.current !== null)
|
|
105
|
+
clearInterval(fsPollRef.current);
|
|
106
|
+
if (clrPollRef.current !== null)
|
|
107
|
+
clearInterval(clrPollRef.current);
|
|
83
108
|
};
|
|
84
109
|
}, []);
|
|
85
110
|
function stopPolling() {
|
|
@@ -182,6 +207,115 @@ export function EnvMigrationPanel({ basePath = '/api/admin' }) {
|
|
|
182
207
|
const isProdTarget = envs.find(e => e.name === to)?.role === 'production';
|
|
183
208
|
const prodConfirmValid = !isProdTarget || prodConfirm === to;
|
|
184
209
|
const canRun = !running && prodConfirmValid && !!from && !!to && from !== to;
|
|
210
|
+
function startFsPolling(id) {
|
|
211
|
+
if (fsPollRef.current !== null)
|
|
212
|
+
clearInterval(fsPollRef.current);
|
|
213
|
+
fsPollRef.current = setInterval(async () => {
|
|
214
|
+
try {
|
|
215
|
+
const [progressRes, jobRes] = await Promise.all([
|
|
216
|
+
fetch(`${basePath}/env/files-sync/${id}/progress`, { credentials: 'include' }),
|
|
217
|
+
fetch(`${basePath}/env/files-sync/${id}`, { credentials: 'include' }),
|
|
218
|
+
]);
|
|
219
|
+
if (!progressRes.ok || !jobRes.ok)
|
|
220
|
+
return;
|
|
221
|
+
const progressData = await progressRes.json();
|
|
222
|
+
const jobData = await jobRes.json();
|
|
223
|
+
setFsProgress(progressData);
|
|
224
|
+
setFsJob(jobData);
|
|
225
|
+
if (jobData.status === 'completed' || jobData.status === 'failed') {
|
|
226
|
+
clearInterval(fsPollRef.current);
|
|
227
|
+
fsPollRef.current = null;
|
|
228
|
+
setFsRunning(false);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch { /* non-fatal */ }
|
|
232
|
+
}, 1500);
|
|
233
|
+
}
|
|
234
|
+
async function handleFsSubmit(e) {
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
setFsError(null);
|
|
237
|
+
setFsProgress(null);
|
|
238
|
+
setFsJob(null);
|
|
239
|
+
setFsRunning(true);
|
|
240
|
+
try {
|
|
241
|
+
const res = await fetch(`${basePath}/env/files-sync`, {
|
|
242
|
+
method: 'POST', credentials: 'include',
|
|
243
|
+
headers: { 'Content-Type': 'application/json' },
|
|
244
|
+
body: JSON.stringify({ from: fsFrom, to: fsTo }),
|
|
245
|
+
});
|
|
246
|
+
const data = await res.json();
|
|
247
|
+
if (!res.ok || !data.jobId) {
|
|
248
|
+
setFsError(typeof data.error === 'string' ? data.error : 'File sync failed to start');
|
|
249
|
+
setFsRunning(false);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
startFsPolling(data.jobId);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
setFsError(err instanceof Error ? err.message : 'Network error');
|
|
256
|
+
setFsRunning(false);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function startClrPolling(id) {
|
|
260
|
+
if (clrPollRef.current !== null)
|
|
261
|
+
clearInterval(clrPollRef.current);
|
|
262
|
+
clrPollRef.current = setInterval(async () => {
|
|
263
|
+
try {
|
|
264
|
+
const [progressRes, jobRes] = await Promise.all([
|
|
265
|
+
fetch(`${basePath}/env/clear/${id}/progress`, { credentials: 'include' }),
|
|
266
|
+
fetch(`${basePath}/env/clear/${id}`, { credentials: 'include' }),
|
|
267
|
+
]);
|
|
268
|
+
if (!progressRes.ok || !jobRes.ok)
|
|
269
|
+
return;
|
|
270
|
+
const progressData = await progressRes.json();
|
|
271
|
+
const jobData = await jobRes.json();
|
|
272
|
+
setClrProgress(progressData);
|
|
273
|
+
setClrJob(jobData);
|
|
274
|
+
if (jobData.status === 'completed' || jobData.status === 'failed') {
|
|
275
|
+
clearInterval(clrPollRef.current);
|
|
276
|
+
clrPollRef.current = null;
|
|
277
|
+
setClrRunning(false);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch { /* non-fatal */ }
|
|
281
|
+
}, 1500);
|
|
282
|
+
}
|
|
283
|
+
async function handleClrSubmit(e) {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
setClrError(null);
|
|
286
|
+
setClrProgress(null);
|
|
287
|
+
setClrJob(null);
|
|
288
|
+
const isProd = envs.find(e => e.name === clrEnv)?.role === 'production';
|
|
289
|
+
if (isProd && clrConfirm !== clrEnv)
|
|
290
|
+
return;
|
|
291
|
+
setClrRunning(true);
|
|
292
|
+
const payload = {
|
|
293
|
+
env: clrEnv,
|
|
294
|
+
include: { db: clrIncludeDb, files: clrIncludeFiles },
|
|
295
|
+
};
|
|
296
|
+
if (isProd) {
|
|
297
|
+
payload['allowProdTarget'] = true;
|
|
298
|
+
payload['confirmToken'] = clrConfirm;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const res = await fetch(`${basePath}/env/clear`, {
|
|
302
|
+
method: 'POST', credentials: 'include',
|
|
303
|
+
headers: { 'Content-Type': 'application/json' },
|
|
304
|
+
body: JSON.stringify(payload),
|
|
305
|
+
});
|
|
306
|
+
const data = await res.json();
|
|
307
|
+
if (!res.ok || !data.jobId) {
|
|
308
|
+
setClrError(typeof data.error === 'string' ? data.error : 'Clear failed to start');
|
|
309
|
+
setClrRunning(false);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
startClrPolling(data.jobId);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
setClrError(err instanceof Error ? err.message : 'Network error');
|
|
316
|
+
setClrRunning(false);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
185
319
|
return (_jsxs("div", { className: "p-6 max-w-2xl space-y-6", children: [_jsx("h2", { className: "text-base font-semibold text-gray-800", children: "Env Migration" }), envsError && (_jsx("div", { className: "text-sm text-red-500", children: envsError })), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsxs("div", { className: "flex gap-4", children: [_jsxs("div", { className: "flex-1", children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "From" }), _jsx("select", { value: from, onChange: e => setFrom(e.target.value), className: "w-full border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: running, children: envs.map(env => (_jsxs("option", { value: env.name, children: [env.name, " ", env.role !== 'production' ? `(${env.role})` : '(PRODUCTION)'] }, env.name))) })] }), _jsxs("div", { className: "flex-1", children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "To" }), _jsx("select", { value: to, onChange: e => { setTo(e.target.value); setProdConfirm(''); }, className: "w-full border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: running, children: envs.map(env => (_jsxs("option", { value: env.name, children: [env.name, " ", env.role !== 'production' ? `(${env.role})` : '(PRODUCTION)'] }, env.name))) })] })] }), _jsxs("div", { className: "flex gap-6", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm text-gray-700", children: [_jsx("input", { type: "checkbox", checked: includeDb, onChange: e => setIncludeDb(e.target.checked), disabled: running }), "Include DB"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm text-gray-700", children: [_jsx("input", { type: "checkbox", checked: includeFiles, onChange: e => setIncludeFiles(e.target.checked), disabled: running }), "Include Files"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm text-gray-700", children: [_jsx("input", { type: "checkbox", checked: dryRun, onChange: e => setDryRun(e.target.checked), disabled: running }), "Dry run"] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "Transport" }), _jsxs("select", { value: transport, onChange: e => setTransport(e.target.value), className: "border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: running, children: [_jsx("option", { value: "auto", children: "auto" }), _jsx("option", { value: "local", children: "local" }), _jsx("option", { value: "api", children: "api" }), _jsx("option", { value: "ssh", disabled: true, children: "ssh (coming soon)" })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "Scrub (PII masking)" }), _jsxs("select", { value: scrub, onChange: e => setScrub(e.target.value), className: "border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: running, children: [_jsx("option", { value: "none", children: "none" }), _jsx("option", { value: "auto", children: "auto" })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "Tables (glob, * = all)" }), _jsx("input", { type: "text", value: tables, onChange: e => setTables(e.target.value), className: "w-full border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: running })] }), isProdTarget && (_jsxs("div", { className: "border border-red-300 rounded p-3 space-y-2 bg-red-50", children: [_jsx("div", { className: "text-sm font-medium text-red-700", children: "Warning: you are targeting a production environment." }), _jsxs("label", { className: "block text-xs font-medium text-red-600 mb-1", children: ["Type ", _jsx("code", { className: "font-mono bg-red-100 px-1 rounded", children: to }), " to confirm"] }), _jsx("input", { type: "text", value: prodConfirm, onChange: e => setProdConfirm(e.target.value), placeholder: to, className: "w-full border border-red-300 rounded px-2 py-1.5 text-sm", disabled: running })] })), _jsx("div", { children: _jsx("button", { type: "submit", disabled: !canRun, className: "px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed", children: running ? 'Running…' : dryRun ? 'Run dry run' : 'Run migration' }) })] }), running && progress && (_jsxs("div", { className: "space-y-2", children: [_jsx("div", { className: "text-xs text-gray-500 uppercase tracking-wide font-medium", children: "Progress" }), _jsxs("div", { className: "text-sm text-gray-700", children: [progress.phase, ": ", progress.message] }), _jsx("div", { className: "w-full bg-gray-200 rounded h-2", children: _jsx("div", { className: "bg-blue-500 h-2 rounded transition-all", style: { width: `${Math.max(0, Math.min(100, progress.percent))}%` } }) }), _jsxs("div", { className: "text-xs text-gray-500", children: [progress.percent, "%"] })] })), submitError && (_jsx("div", { className: "text-sm text-red-600 border border-red-200 rounded p-3 bg-red-50", children: submitError })), job?.status === 'completed' && (() => {
|
|
186
320
|
const result = normalizeResult(job.result);
|
|
187
321
|
const db = result?.db;
|
|
@@ -212,5 +346,5 @@ export function EnvMigrationPanel({ basePath = '/api/admin' }) {
|
|
|
212
346
|
})(), job?.status === 'failed' && (() => {
|
|
213
347
|
const result = normalizeResult(job.result);
|
|
214
348
|
return (_jsxs("div", { className: "border border-red-200 rounded p-4 space-y-3 bg-red-50", children: [_jsx("div", { className: "text-sm font-medium text-red-700", children: "Migration failed" }), job.error && _jsx("div", { className: "text-sm text-red-600", children: job.error }), result?.warnings && result.warnings.length > 0 && (_jsxs("div", { className: "space-y-1", children: [_jsx("div", { className: "text-xs font-medium text-red-600", children: "Warnings" }), result.warnings.map((w, i) => (_jsx("div", { className: "text-xs text-red-600", children: w }, i)))] })), result?.snapshotId && (_jsx("button", { onClick: () => handleRestore(result.snapshotId), disabled: running, className: "px-3 py-1.5 bg-red-600 text-white text-xs font-medium rounded hover:bg-red-700 disabled:opacity-50", children: "Restore last snapshot" }))] }));
|
|
215
|
-
})(), restoreMsg && (_jsx("div", { className: `text-sm border rounded p-3 ${restoreMsg.startsWith('Snapshot restored') ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-600'}`, children: restoreMsg })), to && (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "text-xs text-gray-500 uppercase tracking-wide font-medium", children: ["Snapshots for \"", to, "\""] }), snapshotsLoading && _jsx("div", { className: "text-xs text-gray-400", children: "Loading snapshots\u2026" }), !snapshotsLoading && snapshots.length === 0 && (_jsx("div", { className: "text-xs text-gray-400", children: "No snapshots found." })), !snapshotsLoading && snapshots.length > 0 && (_jsx("ul", { className: "space-y-1", children: snapshots.map(snap => (_jsxs("li", { className: "flex items-center justify-between text-xs border rounded px-3 py-2", children: [_jsxs("div", { children: [_jsx("div", { className: "font-mono text-gray-700", children: snap.fileName }), _jsx("div", { className: "text-gray-400", children: snap.createdAt })] }), _jsx("button", { onClick: () => handleRestore(snap.snapshotId), disabled: running, className: "px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed", children: "Restore" })] }, snap.snapshotId))) }))] }))] }));
|
|
349
|
+
})(), restoreMsg && (_jsx("div", { className: `text-sm border rounded p-3 ${restoreMsg.startsWith('Snapshot restored') ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-600'}`, children: restoreMsg })), to && (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "text-xs text-gray-500 uppercase tracking-wide font-medium", children: ["Snapshots for \"", to, "\""] }), snapshotsLoading && _jsx("div", { className: "text-xs text-gray-400", children: "Loading snapshots\u2026" }), !snapshotsLoading && snapshots.length === 0 && (_jsx("div", { className: "text-xs text-gray-400", children: "No snapshots found." })), !snapshotsLoading && snapshots.length > 0 && (_jsx("ul", { className: "space-y-1", children: snapshots.map(snap => (_jsxs("li", { className: "flex items-center justify-between text-xs border rounded px-3 py-2", children: [_jsxs("div", { children: [_jsx("div", { className: "font-mono text-gray-700", children: snap.fileName }), _jsx("div", { className: "text-gray-400", children: snap.createdAt })] }), _jsx("button", { onClick: () => handleRestore(snap.snapshotId), disabled: running, className: "px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed", children: "Restore" })] }, snap.snapshotId))) }))] })), _jsxs("div", { className: "border-t border-gray-200 pt-6 space-y-4", children: [_jsx("h3", { className: "text-sm font-semibold text-gray-700", children: "File Sync" }), _jsx("p", { className: "text-xs text-gray-500", children: "Copy files only (no DB) between environments via rsync." }), _jsxs("form", { onSubmit: handleFsSubmit, className: "space-y-4", children: [_jsxs("div", { className: "flex gap-4", children: [_jsxs("div", { className: "flex-1", children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "From" }), _jsx("select", { value: fsFrom, onChange: e => setFsFrom(e.target.value), className: "w-full border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: fsRunning, children: envs.map(env => _jsx("option", { value: env.name, children: env.name }, env.name)) })] }), _jsxs("div", { className: "flex-1", children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "To" }), _jsx("select", { value: fsTo, onChange: e => setFsTo(e.target.value), className: "w-full border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: fsRunning, children: envs.map(env => _jsx("option", { value: env.name, children: env.name }, env.name)) })] })] }), _jsx("button", { type: "submit", disabled: fsRunning || !fsFrom || !fsTo || fsFrom === fsTo, className: "px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed", children: fsRunning ? 'Syncing…' : 'Sync files' })] }), fsRunning && fsProgress && (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "text-sm text-gray-700", children: [fsProgress.phase, ": ", fsProgress.message] }), _jsx("div", { className: "w-full bg-gray-200 rounded h-2", children: _jsx("div", { className: "bg-blue-500 h-2 rounded transition-all", style: { width: `${Math.max(0, Math.min(100, fsProgress.percent))}%` } }) }), _jsxs("div", { className: "text-xs text-gray-500", children: [fsProgress.percent, "%"] })] })), fsError && _jsx("div", { className: "text-sm text-red-600 border border-red-200 rounded p-3 bg-red-50", children: fsError }), fsJob?.status === 'completed' && _jsx("div", { className: "text-sm text-green-700 border border-green-200 rounded p-3 bg-green-50", children: "File sync complete." }), fsJob?.status === 'failed' && _jsxs("div", { className: "text-sm text-red-600 border border-red-200 rounded p-3 bg-red-50", children: ["File sync failed. ", fsJob.error] })] }), _jsxs("div", { className: "border-t border-gray-200 pt-6 space-y-4", children: [_jsx("h3", { className: "text-sm font-semibold text-red-700", children: "Clear Environment" }), _jsx("p", { className: "text-xs text-gray-500", children: "Delete all non-preserved rows from the target DB and/or wipe its file directory. Irreversible." }), _jsxs("form", { onSubmit: handleClrSubmit, className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-medium text-gray-600 mb-1", children: "Target" }), _jsx("select", { value: clrEnv, onChange: e => { setClrEnv(e.target.value); setClrConfirm(''); }, className: "w-full border border-gray-300 rounded px-2 py-1.5 text-sm", disabled: clrRunning, children: envs.map(env => _jsxs("option", { value: env.name, children: [env.name, " ", env.role === 'production' ? '(PRODUCTION)' : `(${env.role})`] }, env.name)) })] }), _jsxs("div", { className: "flex gap-6", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm text-gray-700", children: [_jsx("input", { type: "checkbox", checked: clrIncludeDb, onChange: e => setClrIncludeDb(e.target.checked), disabled: clrRunning }), "Clear DB"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm text-gray-700", children: [_jsx("input", { type: "checkbox", checked: clrIncludeFiles, onChange: e => setClrIncludeFiles(e.target.checked), disabled: clrRunning }), "Clear Files"] })] }), envs.find(e => e.name === clrEnv)?.role === 'production' && (_jsxs("div", { className: "border border-red-300 rounded p-3 space-y-2 bg-red-50", children: [_jsx("div", { className: "text-sm font-medium text-red-700", children: "Danger: clearing a production environment." }), _jsxs("label", { className: "block text-xs font-medium text-red-600 mb-1", children: ["Type ", _jsx("code", { className: "font-mono bg-red-100 px-1 rounded", children: clrEnv }), " to confirm"] }), _jsx("input", { type: "text", value: clrConfirm, onChange: e => setClrConfirm(e.target.value), placeholder: clrEnv, className: "w-full border border-red-300 rounded px-2 py-1.5 text-sm", disabled: clrRunning })] })), _jsx("button", { type: "submit", disabled: clrRunning || !clrEnv || (!clrIncludeDb && !clrIncludeFiles) || (envs.find(e => e.name === clrEnv)?.role === 'production' && clrConfirm !== clrEnv), className: "px-4 py-2 bg-red-600 text-white text-sm font-medium rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed", children: clrRunning ? 'Clearing…' : 'Clear environment' })] }), clrRunning && clrProgress && (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "text-sm text-gray-700", children: [clrProgress.phase, ": ", clrProgress.message] }), _jsx("div", { className: "w-full bg-gray-200 rounded h-2", children: _jsx("div", { className: "bg-red-500 h-2 rounded transition-all", style: { width: `${Math.max(0, Math.min(100, clrProgress.percent))}%` } }) }), _jsxs("div", { className: "text-xs text-gray-500", children: [clrProgress.percent, "%"] })] })), clrError && _jsx("div", { className: "text-sm text-red-600 border border-red-200 rounded p-3 bg-red-50", children: clrError }), clrJob?.status === 'completed' && _jsx("div", { className: "text-sm text-green-700 border border-green-200 rounded p-3 bg-green-50", children: "Environment cleared." }), clrJob?.status === 'failed' && _jsxs("div", { className: "text-sm text-red-600 border border-red-200 rounded p-3 bg-red-50", children: ["Clear failed. ", clrJob.error] })] })] }));
|
|
216
350
|
}
|