plugin-migration-manager 2.0.0 → 2.0.2
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/client-v2.d.ts +2 -0
- package/client-v2.js +1 -0
- package/dist/client-v2/946.4af90ac0c3eee699.js +10 -0
- package/dist/client-v2/index.js +10 -0
- package/dist/externalVersion.js +5 -4
- package/dist/locale/en-US.json +68 -8
- package/dist/locale/id-ID.json +10 -8
- package/dist/locale/zh-CN.json +68 -0
- package/dist/server/controllers/migration.js +17 -1
- package/dist/server/index.js +8 -8
- package/package.json +40 -23
- package/server.d.ts +2 -2
- package/server.js +1 -1
- package/src/client-v2/index.tsx +1 -0
- package/src/client-v2/locale.ts +13 -0
- package/src/client-v2/pages/MigrationPage.tsx +808 -0
- package/src/client-v2/plugin.tsx +22 -0
- package/src/locale/en-US.json +68 -8
- package/src/locale/id-ID.json +10 -8
- package/src/locale/zh-CN.json +68 -0
- package/src/server/controllers/migration.ts +17 -0
- package/src/server/index.ts +33 -34
- package/INSTALL.md +0 -100
- package/Migration_Manager_Documentation_1.0.pdf +0 -0
- package/README.md +0 -30
- package/client.d.ts +0 -2
- package/client.js +0 -1
- package/dist/client/bf902aacdcc9b8b8.js +0 -10
- package/dist/client/index.js +0 -10
- package/dist/index.js +0 -48
- package/plugin-migration-manager-2.0.0.tgz +0 -0
- package/src/client/index.tsx +0 -18
- package/src/client/pages/MigrationPage.tsx +0 -565
- package/src/index.ts +0 -2
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Alert, Button, Card, Input, Modal, Space, Spin, Table, Tabs, Tag, Typography, Upload, message } from 'antd';
|
|
3
|
+
import {
|
|
4
|
+
BranchesOutlined,
|
|
5
|
+
DatabaseOutlined,
|
|
6
|
+
DownloadOutlined,
|
|
7
|
+
ExclamationCircleOutlined,
|
|
8
|
+
LayoutOutlined,
|
|
9
|
+
UploadOutlined,
|
|
10
|
+
} from '@ant-design/icons';
|
|
11
|
+
import { useFlowContext } from '@nocobase/flow-engine';
|
|
12
|
+
import { useRequest } from 'ahooks';
|
|
13
|
+
import { useT } from '../locale';
|
|
14
|
+
|
|
15
|
+
const { Title, Text } = Typography;
|
|
16
|
+
const { Search } = Input;
|
|
17
|
+
|
|
18
|
+
type Id = string | number;
|
|
19
|
+
type Dict = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
type CollectionItem = {
|
|
22
|
+
name: string;
|
|
23
|
+
title?: string;
|
|
24
|
+
fields?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type WorkflowItem = {
|
|
28
|
+
id: Id;
|
|
29
|
+
title?: string;
|
|
30
|
+
key?: string;
|
|
31
|
+
enabled?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type UISchemaItem = {
|
|
35
|
+
routeId?: Id;
|
|
36
|
+
displayTitle?: string;
|
|
37
|
+
title?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
type?: string;
|
|
40
|
+
schemaUid?: string;
|
|
41
|
+
uid?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ItemsShape = {
|
|
45
|
+
collections: CollectionItem[];
|
|
46
|
+
workflows: WorkflowItem[];
|
|
47
|
+
uiSchemas: UISchemaItem[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type MigrationPayload = {
|
|
51
|
+
collections?: unknown[];
|
|
52
|
+
workflows?: unknown[];
|
|
53
|
+
uiSchemas?: unknown[];
|
|
54
|
+
desktopRoutes?: unknown[];
|
|
55
|
+
options?: Dict;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type ErrorItem = {
|
|
59
|
+
collection?: string;
|
|
60
|
+
route?: string;
|
|
61
|
+
schema?: string;
|
|
62
|
+
error?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type ImportResults = {
|
|
66
|
+
collections?: { success?: number; failed?: number; errors?: ErrorItem[]; conflicts?: ConflictRow[] };
|
|
67
|
+
workflows?: { success?: number; failed?: number; errors?: ErrorItem[] };
|
|
68
|
+
uiSchemas?: { success?: number; failed?: number; errors?: ErrorItem[] };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type ConflictRow = {
|
|
72
|
+
collection: string;
|
|
73
|
+
field: string;
|
|
74
|
+
current: Dict;
|
|
75
|
+
incoming: Dict;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type WrappedResponse<T> = {
|
|
79
|
+
data?: T | { data?: T };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function isRecord(value: unknown): value is Dict {
|
|
83
|
+
return typeof value === 'object' && value !== null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function unwrapResponse<T>(response: unknown): T {
|
|
87
|
+
if (isRecord(response) && isRecord(response.data)) {
|
|
88
|
+
if ('data' in response.data) {
|
|
89
|
+
return response.data.data as T;
|
|
90
|
+
}
|
|
91
|
+
return response.data as T;
|
|
92
|
+
}
|
|
93
|
+
return response as T;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getErrorMessage(error: unknown, fallback: string) {
|
|
97
|
+
return error instanceof Error ? error.message : fallback;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function prettifyCollectionTitle(title?: string) {
|
|
101
|
+
if (!title) {
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
return title
|
|
105
|
+
.replace(/\{\{t\("(.+?)"\)\}\}/g, '$1')
|
|
106
|
+
.replace(/_/g, ' ')
|
|
107
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function delay(ms: number) {
|
|
111
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getDisplayText(value: unknown) {
|
|
115
|
+
return value === undefined || value === null ? '' : String(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getCounts(payload: MigrationPayload) {
|
|
119
|
+
return {
|
|
120
|
+
collections: payload.collections?.length || 0,
|
|
121
|
+
workflows: payload.workflows?.length || 0,
|
|
122
|
+
uiSchemas: payload.uiSchemas?.length || 0,
|
|
123
|
+
desktopRoutes: payload.desktopRoutes?.length || 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export default function MigrationPage() {
|
|
128
|
+
const ctx = useFlowContext();
|
|
129
|
+
const t = useT();
|
|
130
|
+
const api = ctx.api;
|
|
131
|
+
const [activeTab, setActiveTab] = useState('export');
|
|
132
|
+
const [availableItems, setAvailableItems] = useState<ItemsShape>({ collections: [], workflows: [], uiSchemas: [] });
|
|
133
|
+
const [selectedCollections, setSelectedCollections] = useState<React.Key[]>([]);
|
|
134
|
+
const [selectedWorkflows, setSelectedWorkflows] = useState<React.Key[]>([]);
|
|
135
|
+
const [selectedSchemas, setSelectedSchemas] = useState<React.Key[]>([]);
|
|
136
|
+
const [restarting, setRestarting] = useState(false);
|
|
137
|
+
const [applying, setApplying] = useState(false);
|
|
138
|
+
const [lastImportPayload, setLastImportPayload] = useState<MigrationPayload | null>(null);
|
|
139
|
+
const [lastWasPreview, setLastWasPreview] = useState(false);
|
|
140
|
+
const [collectionQuery, setCollectionQuery] = useState('');
|
|
141
|
+
const [workflowQuery, setWorkflowQuery] = useState('');
|
|
142
|
+
const [schemaQuery, setSchemaQuery] = useState('');
|
|
143
|
+
const [collectionPage, setCollectionPage] = useState({ current: 1, pageSize: 10 });
|
|
144
|
+
const [workflowPage, setWorkflowPage] = useState({ current: 1, pageSize: 10 });
|
|
145
|
+
const [schemaPage, setSchemaPage] = useState({ current: 1, pageSize: 10 });
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
|
|
149
|
+
if (restarting || applying) {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
event.returnValue = '';
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
window.addEventListener('beforeunload', beforeUnloadHandler);
|
|
156
|
+
return () => window.removeEventListener('beforeunload', beforeUnloadHandler);
|
|
157
|
+
}, [restarting, applying]);
|
|
158
|
+
|
|
159
|
+
const { run: fetchItems, loading: loadingItems } = useRequest(
|
|
160
|
+
() => api.request({ url: 'migration:list', method: 'get' }),
|
|
161
|
+
{
|
|
162
|
+
onSuccess: (response: WrappedResponse<ItemsShape>) => {
|
|
163
|
+
const payload = unwrapResponse<ItemsShape>(response);
|
|
164
|
+
setAvailableItems({
|
|
165
|
+
collections: payload?.collections ?? [],
|
|
166
|
+
workflows: payload?.workflows ?? [],
|
|
167
|
+
uiSchemas: payload?.uiSchemas ?? [],
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
onError: () => setAvailableItems({ collections: [], workflows: [], uiSchemas: [] }),
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const { run: exportData, loading: exporting } = useRequest(
|
|
175
|
+
(payload: MigrationPayload) =>
|
|
176
|
+
api.request({
|
|
177
|
+
url: 'migration:export',
|
|
178
|
+
method: 'post',
|
|
179
|
+
data: { data: payload },
|
|
180
|
+
}),
|
|
181
|
+
{
|
|
182
|
+
manual: true,
|
|
183
|
+
onSuccess: (response: WrappedResponse<MigrationPayload>) => {
|
|
184
|
+
const payload = unwrapResponse<MigrationPayload>(response);
|
|
185
|
+
const counts = getCounts(payload || {});
|
|
186
|
+
const hasData =
|
|
187
|
+
counts.collections > 0 || counts.workflows > 0 || counts.uiSchemas > 0 || counts.desktopRoutes > 0;
|
|
188
|
+
|
|
189
|
+
if (!hasData) {
|
|
190
|
+
message.warning(t('Export succeeded but no data.'));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
|
195
|
+
const url = URL.createObjectURL(blob);
|
|
196
|
+
const anchor = document.createElement('a');
|
|
197
|
+
anchor.href = url;
|
|
198
|
+
anchor.download = `nocobase-migration-${Date.now()}.json`;
|
|
199
|
+
anchor.click();
|
|
200
|
+
URL.revokeObjectURL(url);
|
|
201
|
+
message.success(
|
|
202
|
+
t(
|
|
203
|
+
'Export successful! {{collections}} collections, {{workflows}} workflows, {{uiSchemas}} UI schemas, {{routes}} routes',
|
|
204
|
+
{
|
|
205
|
+
collections: counts.collections,
|
|
206
|
+
workflows: counts.workflows,
|
|
207
|
+
uiSchemas: counts.uiSchemas,
|
|
208
|
+
routes: counts.desktopRoutes,
|
|
209
|
+
},
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
},
|
|
213
|
+
onError: (error: unknown) =>
|
|
214
|
+
message.error(t('Export failed: {{message}}', { message: getErrorMessage(error, t('Unknown error')) })),
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const triggerRestart = async (silentWaitMs = 10000, extraWaitMs = 2000) => {
|
|
219
|
+
setRestarting(true);
|
|
220
|
+
try {
|
|
221
|
+
await api.request({ url: 'app:restart', method: 'post', timeout: 60000 });
|
|
222
|
+
await delay(silentWaitMs);
|
|
223
|
+
await delay(extraWaitMs);
|
|
224
|
+
} finally {
|
|
225
|
+
setRestarting(false);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const applyWithRetry = async (collectionsPayload: unknown[], attempts = 15, intervalMs = 1500) => {
|
|
230
|
+
let lastError: unknown = null;
|
|
231
|
+
|
|
232
|
+
for (let index = 0; index < attempts; index += 1) {
|
|
233
|
+
try {
|
|
234
|
+
const response = await api.request({
|
|
235
|
+
url: 'migration:apply',
|
|
236
|
+
method: 'post',
|
|
237
|
+
data: { data: { collections: collectionsPayload } },
|
|
238
|
+
headers: { 'Content-Type': 'application/json' },
|
|
239
|
+
timeout: 600000,
|
|
240
|
+
});
|
|
241
|
+
return unwrapResponse<{ results?: { errors?: ErrorItem[] } }>(response);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
lastError = error;
|
|
244
|
+
const status = isRecord(error) && isRecord(error.response) ? error.response.status : undefined;
|
|
245
|
+
const errorMessage = getErrorMessage(error, '');
|
|
246
|
+
if (status === 503 || errorMessage.includes('APP_COMMANDING')) {
|
|
247
|
+
await delay(intervalMs);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
throw lastError || new Error(t('Apply failed after several attempts.'));
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const { run: runImport, loading: importing } = useRequest(
|
|
258
|
+
(payloadWithOptions: MigrationPayload) =>
|
|
259
|
+
api.request({
|
|
260
|
+
url: 'migration:import',
|
|
261
|
+
method: 'post',
|
|
262
|
+
data: { data: payloadWithOptions },
|
|
263
|
+
headers: { 'Content-Type': 'application/json' },
|
|
264
|
+
timeout: 600000,
|
|
265
|
+
}),
|
|
266
|
+
{
|
|
267
|
+
manual: true,
|
|
268
|
+
onSuccess: async (response: WrappedResponse<{ needsConfirmation?: boolean; results?: ImportResults }>) => {
|
|
269
|
+
const data = unwrapResponse<{ needsConfirmation?: boolean; results?: ImportResults }>(response);
|
|
270
|
+
const { needsConfirmation, results } = data || {};
|
|
271
|
+
|
|
272
|
+
if (needsConfirmation) {
|
|
273
|
+
const conflicts = results?.collections?.conflicts || [];
|
|
274
|
+
Modal.confirm({
|
|
275
|
+
title: t('Confirm Potentially Data-Altering Changes'),
|
|
276
|
+
icon: <ExclamationCircleOutlined />,
|
|
277
|
+
width: 760,
|
|
278
|
+
okText: t('Proceed & Override'),
|
|
279
|
+
cancelText: t('Cancel'),
|
|
280
|
+
content: (
|
|
281
|
+
<div>
|
|
282
|
+
<Alert
|
|
283
|
+
type="warning"
|
|
284
|
+
showIcon
|
|
285
|
+
message={t('Conflicts were found on existing fields.')}
|
|
286
|
+
style={{ marginBottom: 12 }}
|
|
287
|
+
/>
|
|
288
|
+
<div style={{ maxHeight: 360, overflow: 'auto' }}>
|
|
289
|
+
<Table<ConflictRow>
|
|
290
|
+
size="small"
|
|
291
|
+
pagination={false}
|
|
292
|
+
rowKey={(record) => `${record.collection}.${record.field}`}
|
|
293
|
+
dataSource={conflicts}
|
|
294
|
+
columns={[
|
|
295
|
+
{ title: t('Collection'), dataIndex: 'collection' },
|
|
296
|
+
{ title: t('Field'), dataIndex: 'field' },
|
|
297
|
+
{
|
|
298
|
+
title: t('Current'),
|
|
299
|
+
render: (_, record) =>
|
|
300
|
+
`type=${getDisplayText(record.current.type)}, interface=${getDisplayText(
|
|
301
|
+
record.current.interface,
|
|
302
|
+
)}, unique=${getDisplayText(record.current.unique)}, allowNull=${getDisplayText(
|
|
303
|
+
record.current.allowNull,
|
|
304
|
+
)}, pk=${getDisplayText(record.current.primaryKey)}`,
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
title: t('Incoming'),
|
|
308
|
+
render: (_, record) =>
|
|
309
|
+
`type=${getDisplayText(record.incoming.type)}, interface=${getDisplayText(
|
|
310
|
+
record.incoming.interface,
|
|
311
|
+
)}, unique=${getDisplayText(record.incoming.unique)}, allowNull=${getDisplayText(
|
|
312
|
+
record.incoming.allowNull,
|
|
313
|
+
)}, pk=${getDisplayText(record.incoming.primaryKey)}`,
|
|
314
|
+
},
|
|
315
|
+
]}
|
|
316
|
+
/>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
),
|
|
320
|
+
onOk: () => {
|
|
321
|
+
if (!lastImportPayload) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
setLastWasPreview(false);
|
|
325
|
+
runImport({ ...lastImportPayload, options: { forceOverride: true } });
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (lastWasPreview && lastImportPayload) {
|
|
332
|
+
setLastWasPreview(false);
|
|
333
|
+
runImport(lastImportPayload);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const allErrors = [
|
|
338
|
+
...(results?.collections?.errors || []),
|
|
339
|
+
...(results?.workflows?.errors || []),
|
|
340
|
+
...(results?.uiSchemas?.errors || []),
|
|
341
|
+
];
|
|
342
|
+
|
|
343
|
+
Modal.success({
|
|
344
|
+
title: t('Import Successful'),
|
|
345
|
+
content: (
|
|
346
|
+
<div>
|
|
347
|
+
<p>
|
|
348
|
+
{t('Collections')}: {results?.collections?.success || 0} {t('succeeded')},{' '}
|
|
349
|
+
{results?.collections?.failed || 0} {t('failed')}
|
|
350
|
+
</p>
|
|
351
|
+
<p>
|
|
352
|
+
{t('Workflows')}: {results?.workflows?.success || 0} {t('succeeded')}, {results?.workflows?.failed || 0}{' '}
|
|
353
|
+
{t('failed')}
|
|
354
|
+
</p>
|
|
355
|
+
<p>
|
|
356
|
+
{t('UI Schemas')}: {results?.uiSchemas?.success || 0} {t('succeeded')},{' '}
|
|
357
|
+
{results?.uiSchemas?.failed || 0} {t('failed')}
|
|
358
|
+
</p>
|
|
359
|
+
{allErrors.length > 0 && (
|
|
360
|
+
<Alert
|
|
361
|
+
message={t('Some errors occurred')}
|
|
362
|
+
description={
|
|
363
|
+
<ul>
|
|
364
|
+
{allErrors.map((error, index) => (
|
|
365
|
+
<li key={index}>{error.error || String(error)}</li>
|
|
366
|
+
))}
|
|
367
|
+
</ul>
|
|
368
|
+
}
|
|
369
|
+
type="warning"
|
|
370
|
+
style={{ marginTop: 10 }}
|
|
371
|
+
/>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
),
|
|
375
|
+
okText: t('Continue'),
|
|
376
|
+
onOk: async () => {
|
|
377
|
+
try {
|
|
378
|
+
setApplying(true);
|
|
379
|
+
await triggerRestart(12000, 2000);
|
|
380
|
+
const collectionsPayload = (lastImportPayload?.collections || []).map((collection) =>
|
|
381
|
+
typeof collection === 'string' ? { name: collection } : collection,
|
|
382
|
+
);
|
|
383
|
+
const applyData = await applyWithRetry(collectionsPayload, 15, 1500);
|
|
384
|
+
|
|
385
|
+
Modal.success({
|
|
386
|
+
title: t('Apply Complete'),
|
|
387
|
+
content: (
|
|
388
|
+
<div>
|
|
389
|
+
<p>
|
|
390
|
+
{t(
|
|
391
|
+
'The changes have been successfully applied. Click Continue to refresh the page for the changes to take effect.',
|
|
392
|
+
)}
|
|
393
|
+
</p>
|
|
394
|
+
{(applyData?.results?.errors?.length || 0) > 0 && (
|
|
395
|
+
<Alert
|
|
396
|
+
type="warning"
|
|
397
|
+
message={t('Errors occurred during apply')}
|
|
398
|
+
description={
|
|
399
|
+
<ul style={{ marginBottom: 0 }}>
|
|
400
|
+
{(applyData?.results?.errors || []).map((error, index) => (
|
|
401
|
+
<li key={index}>
|
|
402
|
+
{error.collection || error.route || error.schema || t('item')}: {error.error}
|
|
403
|
+
</li>
|
|
404
|
+
))}
|
|
405
|
+
</ul>
|
|
406
|
+
}
|
|
407
|
+
/>
|
|
408
|
+
)}
|
|
409
|
+
</div>
|
|
410
|
+
),
|
|
411
|
+
okText: t('Continue'),
|
|
412
|
+
onOk: () => {
|
|
413
|
+
window.location.reload();
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
} catch (error) {
|
|
417
|
+
message.error(getErrorMessage(error, t('Apply failed')));
|
|
418
|
+
} finally {
|
|
419
|
+
setApplying(false);
|
|
420
|
+
setLastImportPayload(null);
|
|
421
|
+
fetchItems();
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
fetchItems();
|
|
426
|
+
},
|
|
427
|
+
onError: (error: unknown) =>
|
|
428
|
+
message.error(t('Import failed: {{message}}', { message: getErrorMessage(error, t('Unknown error')) })),
|
|
429
|
+
},
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const handleExport = () => {
|
|
433
|
+
if (!selectedCollections.length && !selectedWorkflows.length && !selectedSchemas.length) {
|
|
434
|
+
message.warning(t('Select at least one item to export'));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
Modal.confirm({
|
|
439
|
+
title: t('Export Confirmation'),
|
|
440
|
+
icon: <ExclamationCircleOutlined />,
|
|
441
|
+
content: (
|
|
442
|
+
<div>
|
|
443
|
+
<p>{t('You will export:')}</p>
|
|
444
|
+
<ul>
|
|
445
|
+
<li>
|
|
446
|
+
{selectedCollections.length} {t('Collections')}
|
|
447
|
+
</li>
|
|
448
|
+
<li>
|
|
449
|
+
{selectedWorkflows.length} {t('Workflows')}
|
|
450
|
+
</li>
|
|
451
|
+
<li>
|
|
452
|
+
{selectedSchemas.length} {t('UI Schemas/Routes')}
|
|
453
|
+
</li>
|
|
454
|
+
</ul>
|
|
455
|
+
<Alert
|
|
456
|
+
message={t('Note')}
|
|
457
|
+
description={t(
|
|
458
|
+
'Export includes collection structure, workflow config, UI schema subtree, and desktop routes.',
|
|
459
|
+
)}
|
|
460
|
+
type="info"
|
|
461
|
+
style={{ marginTop: 10 }}
|
|
462
|
+
/>
|
|
463
|
+
</div>
|
|
464
|
+
),
|
|
465
|
+
onOk: () => {
|
|
466
|
+
exportData({
|
|
467
|
+
collections: selectedCollections.map(String),
|
|
468
|
+
workflows: selectedWorkflows,
|
|
469
|
+
uiSchemas: [],
|
|
470
|
+
desktopRoutes: selectedSchemas.map(String),
|
|
471
|
+
});
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const handleImport = (file: File) => {
|
|
477
|
+
const reader = new FileReader();
|
|
478
|
+
reader.onload = (event) => {
|
|
479
|
+
try {
|
|
480
|
+
const payload = JSON.parse(String(event.target?.result || '{}')) as MigrationPayload;
|
|
481
|
+
const counts = getCounts(payload);
|
|
482
|
+
setLastImportPayload(payload);
|
|
483
|
+
setLastWasPreview(true);
|
|
484
|
+
Modal.confirm({
|
|
485
|
+
title: t('Import Confirmation'),
|
|
486
|
+
icon: <ExclamationCircleOutlined />,
|
|
487
|
+
content: (
|
|
488
|
+
<div>
|
|
489
|
+
<p>{t('The file will import:')}</p>
|
|
490
|
+
<ul>
|
|
491
|
+
<li>
|
|
492
|
+
{counts.collections} {t('Collections')}
|
|
493
|
+
</li>
|
|
494
|
+
<li>
|
|
495
|
+
{counts.workflows} {t('Workflows')}
|
|
496
|
+
</li>
|
|
497
|
+
<li>
|
|
498
|
+
{counts.uiSchemas} {t('UI Schemas')}
|
|
499
|
+
</li>
|
|
500
|
+
</ul>
|
|
501
|
+
<Alert
|
|
502
|
+
message={t('Warning')}
|
|
503
|
+
description={t('The first step will run a PREVIEW. If safe, the process will proceed automatically.')}
|
|
504
|
+
type="warning"
|
|
505
|
+
style={{ marginTop: 10 }}
|
|
506
|
+
/>
|
|
507
|
+
</div>
|
|
508
|
+
),
|
|
509
|
+
onOk: () => runImport({ ...payload, options: { preview: true } }),
|
|
510
|
+
});
|
|
511
|
+
} catch {
|
|
512
|
+
message.error(t('Invalid or corrupt file'));
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
reader.readAsText(file);
|
|
516
|
+
return false;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const filteredCollections = useMemo(() => {
|
|
520
|
+
const query = collectionQuery.trim().toLowerCase();
|
|
521
|
+
const data = availableItems.collections.filter((collection) =>
|
|
522
|
+
(collection.title || collection.name || '').toLowerCase().includes(query),
|
|
523
|
+
);
|
|
524
|
+
return { total: data.length, data };
|
|
525
|
+
}, [availableItems.collections, collectionQuery]);
|
|
526
|
+
|
|
527
|
+
const pagedCollections = useMemo(() => {
|
|
528
|
+
const start = (collectionPage.current - 1) * collectionPage.pageSize;
|
|
529
|
+
return filteredCollections.data.slice(start, start + collectionPage.pageSize);
|
|
530
|
+
}, [collectionPage, filteredCollections]);
|
|
531
|
+
|
|
532
|
+
const filteredWorkflows = useMemo(() => {
|
|
533
|
+
const query = workflowQuery.trim().toLowerCase();
|
|
534
|
+
const data = availableItems.workflows.filter((workflow) => (workflow.title || '').toLowerCase().includes(query));
|
|
535
|
+
return { total: data.length, data };
|
|
536
|
+
}, [availableItems.workflows, workflowQuery]);
|
|
537
|
+
|
|
538
|
+
const pagedWorkflows = useMemo(() => {
|
|
539
|
+
const start = (workflowPage.current - 1) * workflowPage.pageSize;
|
|
540
|
+
return filteredWorkflows.data.slice(start, start + workflowPage.pageSize);
|
|
541
|
+
}, [filteredWorkflows, workflowPage]);
|
|
542
|
+
|
|
543
|
+
const filteredSchemas = useMemo(() => {
|
|
544
|
+
const query = schemaQuery.trim().toLowerCase();
|
|
545
|
+
const data = availableItems.uiSchemas.filter((schema) =>
|
|
546
|
+
(schema.displayTitle || schema.title || schema.name || '').toLowerCase().includes(query),
|
|
547
|
+
);
|
|
548
|
+
return { total: data.length, data };
|
|
549
|
+
}, [availableItems.uiSchemas, schemaQuery]);
|
|
550
|
+
|
|
551
|
+
const pagedSchemas = useMemo(() => {
|
|
552
|
+
const start = (schemaPage.current - 1) * schemaPage.pageSize;
|
|
553
|
+
return filteredSchemas.data.slice(start, start + schemaPage.pageSize);
|
|
554
|
+
}, [filteredSchemas, schemaPage]);
|
|
555
|
+
|
|
556
|
+
const isBusy = restarting || applying;
|
|
557
|
+
|
|
558
|
+
return (
|
|
559
|
+
<div style={{ padding: 24, position: 'relative' }}>
|
|
560
|
+
<Card>
|
|
561
|
+
<Title level={2}>{t('Migration Manager')}</Title>
|
|
562
|
+
<Text type="secondary">
|
|
563
|
+
{t('Export and import collections, workflows, and UI schemas across NocoBase instances')}
|
|
564
|
+
</Text>
|
|
565
|
+
|
|
566
|
+
<Tabs
|
|
567
|
+
activeKey={activeTab}
|
|
568
|
+
onChange={setActiveTab}
|
|
569
|
+
items={[
|
|
570
|
+
{
|
|
571
|
+
key: 'export',
|
|
572
|
+
label: t('Export'),
|
|
573
|
+
children: (
|
|
574
|
+
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
|
575
|
+
<Card
|
|
576
|
+
title={
|
|
577
|
+
<Space>
|
|
578
|
+
<DatabaseOutlined />
|
|
579
|
+
{t('Collections')}
|
|
580
|
+
</Space>
|
|
581
|
+
}
|
|
582
|
+
size="small"
|
|
583
|
+
>
|
|
584
|
+
<div style={{ marginBottom: 12 }}>
|
|
585
|
+
<Search
|
|
586
|
+
allowClear
|
|
587
|
+
placeholder={t('Filter by Title')}
|
|
588
|
+
onSearch={(value) => {
|
|
589
|
+
setCollectionQuery(value);
|
|
590
|
+
setCollectionPage({ ...collectionPage, current: 1 });
|
|
591
|
+
}}
|
|
592
|
+
onChange={(event) => {
|
|
593
|
+
setCollectionQuery(event.target.value);
|
|
594
|
+
setCollectionPage({ ...collectionPage, current: 1 });
|
|
595
|
+
}}
|
|
596
|
+
value={collectionQuery}
|
|
597
|
+
style={{ maxWidth: 320 }}
|
|
598
|
+
/>
|
|
599
|
+
</div>
|
|
600
|
+
<Table<CollectionItem>
|
|
601
|
+
rowSelection={{
|
|
602
|
+
selectedRowKeys: selectedCollections,
|
|
603
|
+
onChange: setSelectedCollections,
|
|
604
|
+
}}
|
|
605
|
+
columns={[
|
|
606
|
+
{ title: t('Name'), dataIndex: 'name', key: 'name' },
|
|
607
|
+
{ title: t('Title'), dataIndex: 'title', key: 'title', render: prettifyCollectionTitle },
|
|
608
|
+
{ title: t('Fields'), dataIndex: 'fields', key: 'fields' },
|
|
609
|
+
]}
|
|
610
|
+
dataSource={pagedCollections}
|
|
611
|
+
rowKey="name"
|
|
612
|
+
loading={loadingItems}
|
|
613
|
+
pagination={{
|
|
614
|
+
current: collectionPage.current,
|
|
615
|
+
pageSize: collectionPage.pageSize,
|
|
616
|
+
total: filteredCollections.total,
|
|
617
|
+
showSizeChanger: true,
|
|
618
|
+
onChange: (current, pageSize) => setCollectionPage({ current, pageSize }),
|
|
619
|
+
}}
|
|
620
|
+
size="small"
|
|
621
|
+
/>
|
|
622
|
+
</Card>
|
|
623
|
+
|
|
624
|
+
<Card
|
|
625
|
+
title={
|
|
626
|
+
<Space>
|
|
627
|
+
<BranchesOutlined />
|
|
628
|
+
{t('Workflows')}
|
|
629
|
+
</Space>
|
|
630
|
+
}
|
|
631
|
+
size="small"
|
|
632
|
+
>
|
|
633
|
+
<div style={{ marginBottom: 12 }}>
|
|
634
|
+
<Search
|
|
635
|
+
allowClear
|
|
636
|
+
placeholder={t('Filter by Title')}
|
|
637
|
+
onSearch={(value) => {
|
|
638
|
+
setWorkflowQuery(value);
|
|
639
|
+
setWorkflowPage({ ...workflowPage, current: 1 });
|
|
640
|
+
}}
|
|
641
|
+
onChange={(event) => {
|
|
642
|
+
setWorkflowQuery(event.target.value);
|
|
643
|
+
setWorkflowPage({ ...workflowPage, current: 1 });
|
|
644
|
+
}}
|
|
645
|
+
value={workflowQuery}
|
|
646
|
+
style={{ maxWidth: 320 }}
|
|
647
|
+
/>
|
|
648
|
+
</div>
|
|
649
|
+
<Table<WorkflowItem>
|
|
650
|
+
rowSelection={{
|
|
651
|
+
selectedRowKeys: selectedWorkflows,
|
|
652
|
+
onChange: setSelectedWorkflows,
|
|
653
|
+
}}
|
|
654
|
+
columns={[
|
|
655
|
+
{ title: t('Title'), dataIndex: 'title', key: 'title' },
|
|
656
|
+
{ title: t('Key'), dataIndex: 'key', key: 'key' },
|
|
657
|
+
{
|
|
658
|
+
title: t('Status'),
|
|
659
|
+
dataIndex: 'enabled',
|
|
660
|
+
key: 'enabled',
|
|
661
|
+
render: (value: boolean) => (value ? t('Enabled') : t('Disabled')),
|
|
662
|
+
},
|
|
663
|
+
]}
|
|
664
|
+
dataSource={pagedWorkflows}
|
|
665
|
+
rowKey="id"
|
|
666
|
+
loading={loadingItems}
|
|
667
|
+
pagination={{
|
|
668
|
+
current: workflowPage.current,
|
|
669
|
+
pageSize: workflowPage.pageSize,
|
|
670
|
+
total: filteredWorkflows.total,
|
|
671
|
+
showSizeChanger: true,
|
|
672
|
+
onChange: (current, pageSize) => setWorkflowPage({ current, pageSize }),
|
|
673
|
+
}}
|
|
674
|
+
size="small"
|
|
675
|
+
/>
|
|
676
|
+
</Card>
|
|
677
|
+
|
|
678
|
+
<Card
|
|
679
|
+
title={
|
|
680
|
+
<Space>
|
|
681
|
+
<LayoutOutlined />
|
|
682
|
+
{t('UI Schemas')}
|
|
683
|
+
</Space>
|
|
684
|
+
}
|
|
685
|
+
size="small"
|
|
686
|
+
>
|
|
687
|
+
<div style={{ marginBottom: 12 }}>
|
|
688
|
+
<Search
|
|
689
|
+
allowClear
|
|
690
|
+
placeholder={t('Filter by Title')}
|
|
691
|
+
onSearch={(value) => {
|
|
692
|
+
setSchemaQuery(value);
|
|
693
|
+
setSchemaPage({ ...schemaPage, current: 1 });
|
|
694
|
+
}}
|
|
695
|
+
onChange={(event) => {
|
|
696
|
+
setSchemaQuery(event.target.value);
|
|
697
|
+
setSchemaPage({ ...schemaPage, current: 1 });
|
|
698
|
+
}}
|
|
699
|
+
value={schemaQuery}
|
|
700
|
+
style={{ maxWidth: 320 }}
|
|
701
|
+
/>
|
|
702
|
+
</div>
|
|
703
|
+
<Table<UISchemaItem>
|
|
704
|
+
rowSelection={{
|
|
705
|
+
selectedRowKeys: selectedSchemas,
|
|
706
|
+
onChange: setSelectedSchemas,
|
|
707
|
+
}}
|
|
708
|
+
columns={[
|
|
709
|
+
{
|
|
710
|
+
title: t('Menu / Page'),
|
|
711
|
+
dataIndex: 'displayTitle',
|
|
712
|
+
key: 'displayTitle',
|
|
713
|
+
render: (_, record) => (
|
|
714
|
+
<Space>
|
|
715
|
+
<Text>{record?.displayTitle || record?.title || record?.name || t('Untitled')}</Text>
|
|
716
|
+
<Tag>{record?.type}</Tag>
|
|
717
|
+
</Space>
|
|
718
|
+
),
|
|
719
|
+
},
|
|
720
|
+
{ title: t('UID'), dataIndex: 'schemaUid', key: 'schemaUid' },
|
|
721
|
+
]}
|
|
722
|
+
dataSource={pagedSchemas}
|
|
723
|
+
rowKey={(record) => record.schemaUid || record.uid || String(record.routeId || '')}
|
|
724
|
+
loading={loadingItems}
|
|
725
|
+
pagination={{
|
|
726
|
+
current: schemaPage.current,
|
|
727
|
+
pageSize: schemaPage.pageSize,
|
|
728
|
+
total: filteredSchemas.total,
|
|
729
|
+
showSizeChanger: true,
|
|
730
|
+
onChange: (current, pageSize) => setSchemaPage({ current, pageSize }),
|
|
731
|
+
}}
|
|
732
|
+
size="small"
|
|
733
|
+
/>
|
|
734
|
+
</Card>
|
|
735
|
+
|
|
736
|
+
<Button
|
|
737
|
+
type="primary"
|
|
738
|
+
icon={<DownloadOutlined />}
|
|
739
|
+
onClick={handleExport}
|
|
740
|
+
loading={exporting}
|
|
741
|
+
size="large"
|
|
742
|
+
disabled={isBusy}
|
|
743
|
+
>
|
|
744
|
+
{t('Export Selected Items')}
|
|
745
|
+
</Button>
|
|
746
|
+
</Space>
|
|
747
|
+
),
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
key: 'import',
|
|
751
|
+
label: t('Import'),
|
|
752
|
+
children: (
|
|
753
|
+
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
|
754
|
+
<Alert
|
|
755
|
+
message={t('Import from Development to Production')}
|
|
756
|
+
description={
|
|
757
|
+
<div>
|
|
758
|
+
<p>{t('Upload the exported JSON file from the development server.')}</p>
|
|
759
|
+
<p>
|
|
760
|
+
<strong>{t('What will be imported:')}</strong>
|
|
761
|
+
</p>
|
|
762
|
+
<ul>
|
|
763
|
+
<li>{t('Collection structure (without data)')}</li>
|
|
764
|
+
<li>{t('Workflow configuration')}</li>
|
|
765
|
+
<li>{t('UI Schema/Page design')}</li>
|
|
766
|
+
</ul>
|
|
767
|
+
<p>
|
|
768
|
+
<strong>{t('What will NOT be affected:')}</strong>
|
|
769
|
+
</p>
|
|
770
|
+
<ul>
|
|
771
|
+
<li>{t('Data within collections')}</li>
|
|
772
|
+
<li>{t('Collections not present in the import file')}</li>
|
|
773
|
+
</ul>
|
|
774
|
+
</div>
|
|
775
|
+
}
|
|
776
|
+
type="info"
|
|
777
|
+
/>
|
|
778
|
+
|
|
779
|
+
<Upload accept=".json" beforeUpload={handleImport} showUploadList={false} disabled={isBusy}>
|
|
780
|
+
<Button type="primary" icon={<UploadOutlined />} loading={importing} size="large" disabled={isBusy}>
|
|
781
|
+
{t('Upload Migration File (.json)')}
|
|
782
|
+
</Button>
|
|
783
|
+
</Upload>
|
|
784
|
+
</Space>
|
|
785
|
+
),
|
|
786
|
+
},
|
|
787
|
+
]}
|
|
788
|
+
/>
|
|
789
|
+
</Card>
|
|
790
|
+
|
|
791
|
+
{isBusy && (
|
|
792
|
+
<div
|
|
793
|
+
style={{
|
|
794
|
+
position: 'fixed',
|
|
795
|
+
inset: 0,
|
|
796
|
+
backgroundColor: 'rgba(255,255,255,0.8)',
|
|
797
|
+
display: 'flex',
|
|
798
|
+
alignItems: 'center',
|
|
799
|
+
justifyContent: 'center',
|
|
800
|
+
zIndex: 9999,
|
|
801
|
+
}}
|
|
802
|
+
>
|
|
803
|
+
<Spin size="large" tip={restarting ? t('Restarting...') : t('Applying...')} />
|
|
804
|
+
</div>
|
|
805
|
+
)}
|
|
806
|
+
</div>
|
|
807
|
+
);
|
|
808
|
+
}
|