strapi-content-sync-pro 1.0.3 → 1.0.5
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/README.md +33 -14
- package/admin/src/components/BulkTransferTab.jsx +880 -0
- package/admin/src/components/ConfigTab.jsx +81 -3
- package/admin/src/components/HelpTab.jsx +148 -5
- package/admin/src/components/MediaTab.jsx +141 -30
- package/admin/src/components/SyncTab.jsx +2 -0
- package/admin/src/pages/App/index.jsx +12 -1
- package/docs/Screenshot 2026-04-22 183540.png +0 -0
- package/docs/Screenshot 2026-04-22 183552.png +0 -0
- package/docs/Screenshot 2026-04-23 114332.png +0 -0
- package/docs/Screenshot 2026-04-23 114644.png +0 -0
- package/docs/Screenshot 2026-04-23 114651.png +0 -0
- package/docs/Screenshot 2026-04-23 114737.png +0 -0
- package/docs/Screenshot 2026-04-23 114904.png +0 -0
- package/docs/Screenshot 2026-04-23 114940.png +0 -0
- package/docs/Screenshot 2026-04-23 115003.png +0 -0
- package/docs/Screenshot 2026-04-23 115024.png +0 -0
- package/docs/Screenshot 2026-04-23 115116.png +0 -0
- package/docs/Screenshot 2026-04-23 115141.png +0 -0
- package/docs/Screenshot 2026-04-23 115252.png +0 -0
- package/docs/Screenshot 2026-04-23 115448.png +0 -0
- package/docs/Screenshot 2026-04-23 120534.png +0 -0
- package/docs/Screenshot 2026-04-23 122544.png +0 -0
- package/docs/Screenshot 2026-04-23 122712.png +0 -0
- package/docs/Screenshot 2026-04-23 122730.png +0 -0
- package/docs/Screenshot 2026-04-23 122858.png +0 -0
- package/docs/Screenshot 2026-04-23 122924.png +0 -0
- package/docs/Screenshot 2026-04-23 122937.png +0 -0
- package/package.json +13 -4
- package/server/src/controllers/bulk-transfer.js +141 -0
- package/server/src/controllers/config.js +76 -3
- package/server/src/controllers/index.js +2 -0
- package/server/src/controllers/sync-media.js +24 -0
- package/server/src/routes/index.js +18 -0
- package/server/src/services/bulk-transfer.js +837 -0
- package/server/src/services/index.js +2 -0
- package/server/src/services/sync-media.js +168 -32
- package/server/src/services/sync.js +137 -1
- package/docs/Screenshot 2026-04-20 160506.png +0 -0
- package/docs/Screenshot 2026-04-20 160558.png +0 -0
- package/docs/Screenshot 2026-04-20 175903.png +0 -0
- package/docs/Screenshot 2026-04-20 175931.png +0 -0
- package/docs/Screenshot 2026-04-20 180001.png +0 -0
- package/docs/Screenshot 2026-04-20 180041.png +0 -0
- package/docs/Screenshot 2026-04-20 180116.png +0 -0
- package/docs/Screenshot 2026-04-20 180135.png +0 -0
- package/docs/Screenshot 2026-04-20 180202.png +0 -0
- package/docs/Screenshot 2026-04-20 180228.png +0 -0
- package/docs/Screenshot 2026-04-20 180251.png +0 -0
- package/docs/Screenshot 2026-04-20 180301.png +0 -0
- package/docs/clipchamp-screen-recording-script.md +0 -0
- package/docs/production-readiness-status.md +0 -34
- package/docs/production-readiness-test-matrix.md +0 -151
- package/docs/test-environments-setup-legacy.txt +0 -60
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Flex,
|
|
5
|
+
Typography,
|
|
6
|
+
Button,
|
|
7
|
+
Alert,
|
|
8
|
+
Checkbox,
|
|
9
|
+
SingleSelect,
|
|
10
|
+
SingleSelectOption,
|
|
11
|
+
Field,
|
|
12
|
+
Table,
|
|
13
|
+
Thead,
|
|
14
|
+
Tbody,
|
|
15
|
+
Tr,
|
|
16
|
+
Th,
|
|
17
|
+
Td,
|
|
18
|
+
Badge,
|
|
19
|
+
Loader,
|
|
20
|
+
} from '@strapi/design-system';
|
|
21
|
+
import { useFetchClient } from '@strapi/strapi/admin';
|
|
22
|
+
|
|
23
|
+
const PLUGIN_ID = 'strapi-content-sync-pro';
|
|
24
|
+
|
|
25
|
+
const DIRECTIONS = [
|
|
26
|
+
{ value: 'pull', label: 'Full Pull (Remote → Local)' },
|
|
27
|
+
{ value: 'push', label: 'Full Push (Local → Remote)' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const STATUS_VARIANT = {
|
|
31
|
+
pending: 'secondary',
|
|
32
|
+
running: 'warning',
|
|
33
|
+
paused: 'secondary',
|
|
34
|
+
success: 'success',
|
|
35
|
+
skipped: 'secondary',
|
|
36
|
+
error: 'danger',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
40
|
+
const { get, post } = useFetchClient();
|
|
41
|
+
|
|
42
|
+
const [direction, setDirection] = useState('pull');
|
|
43
|
+
const [scopes, setScopes] = useState({
|
|
44
|
+
content: true,
|
|
45
|
+
media: false,
|
|
46
|
+
users: false,
|
|
47
|
+
admins: false,
|
|
48
|
+
});
|
|
49
|
+
const [syncDeletions, setSyncDeletions] = useState(false);
|
|
50
|
+
const [autoContinue, setAutoContinue] = useState(true);
|
|
51
|
+
const [conflictStrategy, setConflictStrategy] = useState('latest');
|
|
52
|
+
|
|
53
|
+
const [preview, setPreview] = useState(null);
|
|
54
|
+
const [job, setJob] = useState(null);
|
|
55
|
+
const [message, setMessage] = useState(null);
|
|
56
|
+
const [busy, setBusy] = useState(false);
|
|
57
|
+
// Per-chunk selection. Key = chunk.index -> boolean (true = include in run).
|
|
58
|
+
// Populated from the preview plan; user can toggle individual rows or
|
|
59
|
+
// use the select-all checkbox in the table header.
|
|
60
|
+
const [selected, setSelected] = useState({});
|
|
61
|
+
const [history, setHistory] = useState([]);
|
|
62
|
+
const [historyBusy, setHistoryBusy] = useState(false);
|
|
63
|
+
// History id whose chunk-level details are expanded in the Previous Runs tab.
|
|
64
|
+
const [expandedHistoryId, setExpandedHistoryId] = useState(null);
|
|
65
|
+
// Internal sub-tab: 'run' shows the configuration + active job,
|
|
66
|
+
// 'history' shows the persisted previous-runs table.
|
|
67
|
+
const [subTab, setSubTab] = useState('run');
|
|
68
|
+
|
|
69
|
+
const pollRef = useRef(null);
|
|
70
|
+
|
|
71
|
+
const scopeCount = Object.values(scopes).filter(Boolean).length;
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (syncMode === 'single_side' && direction === 'push') {
|
|
75
|
+
setDirection('pull');
|
|
76
|
+
}
|
|
77
|
+
}, [syncMode, direction]);
|
|
78
|
+
|
|
79
|
+
// Refresh preview when choices change
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
async function loadPreview() {
|
|
83
|
+
if (scopeCount === 0) {
|
|
84
|
+
setPreview(null);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const { data } = await post(`/${PLUGIN_ID}/bulk-transfer/preview`, { direction, scopes });
|
|
89
|
+
if (!cancelled) {
|
|
90
|
+
const p = data?.data || null;
|
|
91
|
+
setPreview(p);
|
|
92
|
+
// Default-select every chunk for a fresh preview; preserve existing
|
|
93
|
+
// user toggles for chunks that still exist by index.
|
|
94
|
+
if (p?.chunks) {
|
|
95
|
+
setSelected((prev) => {
|
|
96
|
+
const next = {};
|
|
97
|
+
for (const c of p.chunks) {
|
|
98
|
+
next[c.index] = prev[c.index] !== undefined ? prev[c.index] : true;
|
|
99
|
+
}
|
|
100
|
+
return next;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (!cancelled) setPreview(null);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
loadPreview();
|
|
109
|
+
return () => { cancelled = true; };
|
|
110
|
+
}, [direction, scopes, scopeCount, post]);
|
|
111
|
+
|
|
112
|
+
// Load persisted run history on mount and whenever a job reaches a terminal
|
|
113
|
+
// state, so the "Previous Runs" panel stays current.
|
|
114
|
+
const loadHistory = async () => {
|
|
115
|
+
try {
|
|
116
|
+
const { data } = await get(`/${PLUGIN_ID}/bulk-transfer/history`);
|
|
117
|
+
setHistory(data?.data?.items || []);
|
|
118
|
+
} catch {
|
|
119
|
+
/* ignore */
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
loadHistory();
|
|
124
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
125
|
+
}, []);
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (job && ['success', 'partial', 'cancelled', 'error', 'paused'].includes(job.status)) {
|
|
128
|
+
loadHistory();
|
|
129
|
+
}
|
|
130
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
131
|
+
}, [job?.status]);
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
// Poll job status while it is running or paused so the UI keeps showing
|
|
135
|
+
// page-level progress and can be resumed.
|
|
136
|
+
if (!job || (job.status !== 'running' && job.status !== 'paused')) {
|
|
137
|
+
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
pollRef.current = setInterval(async () => {
|
|
141
|
+
try {
|
|
142
|
+
const { data } = await get(`/${PLUGIN_ID}/bulk-transfer/jobs/${job.id}`);
|
|
143
|
+
setJob(data?.data || null);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// swallow transient errors
|
|
146
|
+
}
|
|
147
|
+
}, 1500);
|
|
148
|
+
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
|
|
149
|
+
}, [job?.id, job?.status, get]);
|
|
150
|
+
|
|
151
|
+
const handleToggleScope = (key) => {
|
|
152
|
+
setScopes((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const handleToggleChunk = (index) => {
|
|
156
|
+
setSelected((prev) => ({ ...prev, [index]: !prev[index] }));
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const previewChunks = preview?.chunks || [];
|
|
160
|
+
const allSelected = previewChunks.length > 0 && previewChunks.every((c) => selected[c.index]);
|
|
161
|
+
const noneSelected = previewChunks.every((c) => !selected[c.index]);
|
|
162
|
+
|
|
163
|
+
const handleToggleAll = () => {
|
|
164
|
+
const target = !allSelected;
|
|
165
|
+
setSelected(() => {
|
|
166
|
+
const next = {};
|
|
167
|
+
for (const c of previewChunks) next[c.index] = target;
|
|
168
|
+
return next;
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const getSelectedIndexes = () =>
|
|
173
|
+
previewChunks.filter((c) => selected[c.index]).map((c) => c.index);
|
|
174
|
+
|
|
175
|
+
const handleStart = async () => {
|
|
176
|
+
setMessage(null);
|
|
177
|
+
setBusy(true);
|
|
178
|
+
try {
|
|
179
|
+
const selectedIndexes = getSelectedIndexes();
|
|
180
|
+
if (previewChunks.length > 0 && selectedIndexes.length === 0) {
|
|
181
|
+
throw new Error('Select at least one chunk to run.');
|
|
182
|
+
}
|
|
183
|
+
const { data } = await post(`/${PLUGIN_ID}/bulk-transfer/start`, {
|
|
184
|
+
direction,
|
|
185
|
+
scopes,
|
|
186
|
+
syncDeletions,
|
|
187
|
+
autoContinue,
|
|
188
|
+
conflictStrategy,
|
|
189
|
+
selectedIndexes,
|
|
190
|
+
});
|
|
191
|
+
setJob(data?.data || null);
|
|
192
|
+
setMessage({ type: 'success', text: `Bulk transfer started (${selectedIndexes.length} chunk(s)).` });
|
|
193
|
+
} catch (err) {
|
|
194
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to start bulk transfer' });
|
|
195
|
+
} finally {
|
|
196
|
+
setBusy(false);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const handleRestartFromHistory = async (historyId, overrides = {}) => {
|
|
201
|
+
setHistoryBusy(true);
|
|
202
|
+
setMessage(null);
|
|
203
|
+
try {
|
|
204
|
+
const { data } = await post(
|
|
205
|
+
`/${PLUGIN_ID}/bulk-transfer/history/${historyId}/restart`,
|
|
206
|
+
overrides
|
|
207
|
+
);
|
|
208
|
+
setJob(data?.data || null);
|
|
209
|
+
setSubTab('run');
|
|
210
|
+
setMessage({ type: 'success', text: 'Restarted bulk transfer from history.' });
|
|
211
|
+
await loadHistory();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to restart from history' });
|
|
214
|
+
} finally {
|
|
215
|
+
setHistoryBusy(false);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const handleResumeFromHistory = async (historyId) => {
|
|
220
|
+
setHistoryBusy(true);
|
|
221
|
+
setMessage(null);
|
|
222
|
+
try {
|
|
223
|
+
const { data } = await post(
|
|
224
|
+
`/${PLUGIN_ID}/bulk-transfer/history/${historyId}/resume`,
|
|
225
|
+
{}
|
|
226
|
+
);
|
|
227
|
+
const jobData = data?.data || null;
|
|
228
|
+
// Rehydrate the Run Transfer form so the UI visibly restores the
|
|
229
|
+
// configuration and chunk selection exactly as they were when paused.
|
|
230
|
+
const rs = jobData?.restoredState;
|
|
231
|
+
if (rs) {
|
|
232
|
+
setDirection(rs.direction || 'pull');
|
|
233
|
+
setScopes({
|
|
234
|
+
content: !!rs.scopes?.content,
|
|
235
|
+
media: !!rs.scopes?.media,
|
|
236
|
+
users: !!rs.scopes?.users,
|
|
237
|
+
admins: !!rs.scopes?.admins,
|
|
238
|
+
});
|
|
239
|
+
setSyncDeletions(!!rs.syncDeletions);
|
|
240
|
+
setAutoContinue(!!rs.autoContinue);
|
|
241
|
+
setConflictStrategy(rs.conflictStrategy || 'latest');
|
|
242
|
+
const sel = {};
|
|
243
|
+
const selSet = new Set((rs.selectedIndexes || []).map(Number));
|
|
244
|
+
for (const c of jobData?.chunks || []) sel[c.index] = selSet.has(c.index);
|
|
245
|
+
setSelected(sel);
|
|
246
|
+
}
|
|
247
|
+
setJob(jobData);
|
|
248
|
+
setSubTab('run');
|
|
249
|
+
setMessage({ type: 'success', text: 'Resumed bulk transfer from where it left off.' });
|
|
250
|
+
await loadHistory();
|
|
251
|
+
} catch (err) {
|
|
252
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to resume from history' });
|
|
253
|
+
} finally {
|
|
254
|
+
setHistoryBusy(false);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const handleLoadFromHistory = (entry) => {
|
|
259
|
+
// Rehydrate the form with the historical selection so the user can tweak
|
|
260
|
+
// and then press Start as usual. Does not create a job.
|
|
261
|
+
setDirection(entry.direction);
|
|
262
|
+
setScopes({
|
|
263
|
+
content: !!entry.scopes?.content,
|
|
264
|
+
media: !!entry.scopes?.media,
|
|
265
|
+
users: !!entry.scopes?.users,
|
|
266
|
+
admins: !!entry.scopes?.admins,
|
|
267
|
+
});
|
|
268
|
+
setSyncDeletions(!!entry.syncDeletions);
|
|
269
|
+
setAutoContinue(!!entry.autoContinue);
|
|
270
|
+
setConflictStrategy(entry.conflictStrategy || 'latest');
|
|
271
|
+
const sel = {};
|
|
272
|
+
for (const c of entry.chunks || []) sel[c.index] = c.selected !== false;
|
|
273
|
+
setSelected(sel);
|
|
274
|
+
setJob(null);
|
|
275
|
+
setSubTab('run');
|
|
276
|
+
setMessage({ type: 'info', text: `Loaded selection from run ${entry.id}. Adjust and press Start.` });
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const handleClearHistory = async () => {
|
|
280
|
+
setHistoryBusy(true);
|
|
281
|
+
try {
|
|
282
|
+
await post(`/${PLUGIN_ID}/bulk-transfer/history/clear`);
|
|
283
|
+
await loadHistory();
|
|
284
|
+
} catch (err) {
|
|
285
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to clear history' });
|
|
286
|
+
} finally {
|
|
287
|
+
setHistoryBusy(false);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const handleNext = async () => {
|
|
292
|
+
if (!job) return;
|
|
293
|
+
setBusy(true);
|
|
294
|
+
try {
|
|
295
|
+
const { data } = await post(`/${PLUGIN_ID}/bulk-transfer/jobs/${job.id}/next`);
|
|
296
|
+
setJob(data?.data || null);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to advance job' });
|
|
299
|
+
} finally {
|
|
300
|
+
setBusy(false);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const handleRunAll = async () => {
|
|
305
|
+
if (!job) return;
|
|
306
|
+
setBusy(true);
|
|
307
|
+
try {
|
|
308
|
+
const { data } = await post(`/${PLUGIN_ID}/bulk-transfer/jobs/${job.id}/run-all`);
|
|
309
|
+
setJob(data?.data || null);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to run remaining chunks' });
|
|
312
|
+
} finally {
|
|
313
|
+
setBusy(false);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const handleCancel = async () => {
|
|
318
|
+
if (!job) return;
|
|
319
|
+
try {
|
|
320
|
+
const { data } = await post(`/${PLUGIN_ID}/bulk-transfer/jobs/${job.id}/cancel`);
|
|
321
|
+
setJob(data?.data || null);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to cancel job' });
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const handlePause = async () => {
|
|
328
|
+
if (!job) return;
|
|
329
|
+
try {
|
|
330
|
+
const { data } = await post(`/${PLUGIN_ID}/bulk-transfer/jobs/${job.id}/pause`);
|
|
331
|
+
setJob(data?.data || null);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to pause job' });
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const handleResume = async () => {
|
|
338
|
+
if (!job) return;
|
|
339
|
+
setBusy(true);
|
|
340
|
+
try {
|
|
341
|
+
const { data } = await post(`/${PLUGIN_ID}/bulk-transfer/jobs/${job.id}/resume`);
|
|
342
|
+
setJob(data?.data || null);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to resume job' });
|
|
345
|
+
} finally {
|
|
346
|
+
setBusy(false);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const chunkRows = useMemo(() => job?.chunks || preview?.chunks || [], [job, preview]);
|
|
351
|
+
const runningOrDone = !!job;
|
|
352
|
+
const isRunning = job?.status === 'running';
|
|
353
|
+
const isPaused = job?.status === 'paused';
|
|
354
|
+
const isActive = isRunning || isPaused;
|
|
355
|
+
const isTerminal = job && ['success', 'partial', 'cancelled', 'error'].includes(job.status);
|
|
356
|
+
|
|
357
|
+
const jobStats = useMemo(() => {
|
|
358
|
+
if (!job) return null;
|
|
359
|
+
const totals = (job.chunks || []).reduce(
|
|
360
|
+
(acc, c) => {
|
|
361
|
+
acc.pushed += c.pushed || 0;
|
|
362
|
+
acc.pulled += c.pulled || 0;
|
|
363
|
+
acc.errors += c.errors || 0;
|
|
364
|
+
acc.pagesDone += c.page || 0;
|
|
365
|
+
if (c.pagesTotal) acc.pagesTotal += c.pagesTotal;
|
|
366
|
+
return acc;
|
|
367
|
+
},
|
|
368
|
+
{ pushed: 0, pulled: 0, errors: 0, pagesDone: 0, pagesTotal: 0 }
|
|
369
|
+
);
|
|
370
|
+
const currentChunk = (job.chunks || []).find((c) => c.status === 'running')
|
|
371
|
+
|| (job.chunks || []).find((c) => c.status === 'paused')
|
|
372
|
+
|| (job.chunks || [])[job.cursor]
|
|
373
|
+
|| null;
|
|
374
|
+
return { ...totals, currentChunk };
|
|
375
|
+
}, [job]);
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<Box>
|
|
379
|
+
<Typography variant="beta" tag="h2">Bulk Transfer</Typography>
|
|
380
|
+
<Typography variant="omega" textColor="neutral600">
|
|
381
|
+
One-click full pull or full push across selected scopes. The transfer runs chunk-by-chunk
|
|
382
|
+
(one content type / media profile per chunk) and can auto-advance or pause between chunks.
|
|
383
|
+
</Typography>
|
|
384
|
+
|
|
385
|
+
<Box paddingTop={3} paddingBottom={1}>
|
|
386
|
+
<Flex gap={2}>
|
|
387
|
+
<Button
|
|
388
|
+
variant={subTab === 'run' ? 'default' : 'tertiary'}
|
|
389
|
+
onClick={() => setSubTab('run')}
|
|
390
|
+
>
|
|
391
|
+
Run Transfer
|
|
392
|
+
</Button>
|
|
393
|
+
<Button
|
|
394
|
+
variant={subTab === 'history' ? 'default' : 'tertiary'}
|
|
395
|
+
onClick={() => setSubTab('history')}
|
|
396
|
+
>
|
|
397
|
+
Previous Runs{history.length ? ` (${history.length})` : ''}
|
|
398
|
+
</Button>
|
|
399
|
+
</Flex>
|
|
400
|
+
</Box>
|
|
401
|
+
|
|
402
|
+
{subTab === 'run' && (<>
|
|
403
|
+
|
|
404
|
+
{syncMode === 'single_side' && (
|
|
405
|
+
<Box paddingTop={4}>
|
|
406
|
+
<Alert variant="info" title="Single-side mode">
|
|
407
|
+
Only full pull is available because this instance is configured as single-side.
|
|
408
|
+
</Alert>
|
|
409
|
+
</Box>
|
|
410
|
+
)}
|
|
411
|
+
|
|
412
|
+
{message && (
|
|
413
|
+
<Box paddingTop={4}>
|
|
414
|
+
<Alert variant={message.type} closeLabel="Close" onClose={() => setMessage(null)}>
|
|
415
|
+
{message.text}
|
|
416
|
+
</Alert>
|
|
417
|
+
</Box>
|
|
418
|
+
)}
|
|
419
|
+
|
|
420
|
+
<Box paddingTop={4}>
|
|
421
|
+
<Flex gap={4} wrap="wrap" alignItems="flex-end">
|
|
422
|
+
<Box style={{ width: 280 }}>
|
|
423
|
+
<Field.Root>
|
|
424
|
+
<Field.Label>Direction</Field.Label>
|
|
425
|
+
<SingleSelect value={direction} onChange={setDirection} disabled={runningOrDone && isRunning}>
|
|
426
|
+
{DIRECTIONS.map((d) => (
|
|
427
|
+
<SingleSelectOption
|
|
428
|
+
key={d.value}
|
|
429
|
+
value={d.value}
|
|
430
|
+
disabled={syncMode === 'single_side' && d.value === 'push'}
|
|
431
|
+
>
|
|
432
|
+
{d.label}
|
|
433
|
+
</SingleSelectOption>
|
|
434
|
+
))}
|
|
435
|
+
</SingleSelect>
|
|
436
|
+
</Field.Root>
|
|
437
|
+
</Box>
|
|
438
|
+
|
|
439
|
+
<Box style={{ width: 220 }}>
|
|
440
|
+
<Field.Root>
|
|
441
|
+
<Field.Label>Conflict Strategy</Field.Label>
|
|
442
|
+
<SingleSelect value={conflictStrategy} onChange={setConflictStrategy} disabled={runningOrDone && isRunning}>
|
|
443
|
+
<SingleSelectOption value="latest">Latest updated wins</SingleSelectOption>
|
|
444
|
+
<SingleSelectOption value="local">Local wins</SingleSelectOption>
|
|
445
|
+
<SingleSelectOption value="remote">Remote wins</SingleSelectOption>
|
|
446
|
+
</SingleSelect>
|
|
447
|
+
</Field.Root>
|
|
448
|
+
</Box>
|
|
449
|
+
</Flex>
|
|
450
|
+
</Box>
|
|
451
|
+
|
|
452
|
+
<Box paddingTop={4}>
|
|
453
|
+
<Typography variant="delta">Scope</Typography>
|
|
454
|
+
<Box paddingTop={2}>
|
|
455
|
+
<Flex direction="column" gap={2}>
|
|
456
|
+
<Flex gap={2} alignItems="center">
|
|
457
|
+
<Checkbox
|
|
458
|
+
checked={scopes.content}
|
|
459
|
+
onCheckedChange={() => handleToggleScope('content')}
|
|
460
|
+
disabled={isRunning}
|
|
461
|
+
/>
|
|
462
|
+
<Typography>User-generated content (all <code>api::*</code> collection types)</Typography>
|
|
463
|
+
</Flex>
|
|
464
|
+
<Flex gap={2} alignItems="center">
|
|
465
|
+
<Checkbox
|
|
466
|
+
checked={scopes.media}
|
|
467
|
+
onCheckedChange={() => handleToggleScope('media')}
|
|
468
|
+
disabled={isRunning}
|
|
469
|
+
/>
|
|
470
|
+
<Typography>Media (files + morph links, via active media profiles)</Typography>
|
|
471
|
+
</Flex>
|
|
472
|
+
<Flex gap={2} alignItems="center">
|
|
473
|
+
<Checkbox
|
|
474
|
+
checked={scopes.users}
|
|
475
|
+
onCheckedChange={() => handleToggleScope('users')}
|
|
476
|
+
disabled={isRunning}
|
|
477
|
+
/>
|
|
478
|
+
<Typography>Strapi Users (<code>plugin::users-permissions.user</code>)</Typography>
|
|
479
|
+
</Flex>
|
|
480
|
+
<Flex gap={2} alignItems="center">
|
|
481
|
+
<Checkbox
|
|
482
|
+
checked={scopes.admins}
|
|
483
|
+
onCheckedChange={() => handleToggleScope('admins')}
|
|
484
|
+
disabled={isRunning}
|
|
485
|
+
/>
|
|
486
|
+
<Typography>Admin Users (<code>admin::user</code>) — experimental</Typography>
|
|
487
|
+
</Flex>
|
|
488
|
+
</Flex>
|
|
489
|
+
</Box>
|
|
490
|
+
</Box>
|
|
491
|
+
|
|
492
|
+
<Box paddingTop={4}>
|
|
493
|
+
<Typography variant="delta">Run Options</Typography>
|
|
494
|
+
<Box paddingTop={2}>
|
|
495
|
+
<Flex direction="column" gap={2}>
|
|
496
|
+
<Flex gap={2} alignItems="center">
|
|
497
|
+
<Checkbox
|
|
498
|
+
checked={syncDeletions}
|
|
499
|
+
onCheckedChange={() => setSyncDeletions((v) => !v)}
|
|
500
|
+
disabled={isRunning}
|
|
501
|
+
/>
|
|
502
|
+
<Typography>Also apply deletions (destination removes items missing on source)</Typography>
|
|
503
|
+
</Flex>
|
|
504
|
+
<Flex gap={2} alignItems="center">
|
|
505
|
+
<Checkbox
|
|
506
|
+
checked={autoContinue}
|
|
507
|
+
onCheckedChange={() => setAutoContinue((v) => !v)}
|
|
508
|
+
disabled={isRunning}
|
|
509
|
+
/>
|
|
510
|
+
<Typography>Auto-continue to next chunk (uncheck to stop after each chunk)</Typography>
|
|
511
|
+
</Flex>
|
|
512
|
+
</Flex>
|
|
513
|
+
</Box>
|
|
514
|
+
</Box>
|
|
515
|
+
|
|
516
|
+
{(scopes.users || scopes.admins) && syncDeletions && (
|
|
517
|
+
<Box paddingTop={4}>
|
|
518
|
+
<Alert variant="warning" title="Deletion sync on user scopes">
|
|
519
|
+
Enabling deletions on Users or Admin Users can remove accounts on the destination that
|
|
520
|
+
do not exist on the source. Proceed with care.
|
|
521
|
+
</Alert>
|
|
522
|
+
</Box>
|
|
523
|
+
)}
|
|
524
|
+
|
|
525
|
+
<Box paddingTop={4}>
|
|
526
|
+
<Flex gap={2} wrap="wrap">
|
|
527
|
+
<Button
|
|
528
|
+
onClick={handleStart}
|
|
529
|
+
loading={busy && !job}
|
|
530
|
+
disabled={busy || scopeCount === 0 || isActive || (previewChunks.length > 0 && noneSelected)}
|
|
531
|
+
>
|
|
532
|
+
{direction === 'pull' ? 'Start Full Pull' : 'Start Full Push'}
|
|
533
|
+
</Button>
|
|
534
|
+
{job && job.status === 'running' && !autoContinue && (
|
|
535
|
+
<Button variant="secondary" onClick={handleNext} loading={busy} disabled={busy}>
|
|
536
|
+
Run Next Chunk
|
|
537
|
+
</Button>
|
|
538
|
+
)}
|
|
539
|
+
{job && job.status === 'running' && (
|
|
540
|
+
<>
|
|
541
|
+
<Button variant="tertiary" onClick={handleRunAll} disabled={busy}>
|
|
542
|
+
Run All Remaining
|
|
543
|
+
</Button>
|
|
544
|
+
<Button variant="secondary" onClick={handlePause} disabled={busy}>
|
|
545
|
+
Pause
|
|
546
|
+
</Button>
|
|
547
|
+
<Button variant="danger-light" onClick={handleCancel}>
|
|
548
|
+
Cancel
|
|
549
|
+
</Button>
|
|
550
|
+
</>
|
|
551
|
+
)}
|
|
552
|
+
{job && job.status === 'paused' && (
|
|
553
|
+
<>
|
|
554
|
+
<Button onClick={handleResume} loading={busy} disabled={busy}>
|
|
555
|
+
Resume
|
|
556
|
+
</Button>
|
|
557
|
+
{!autoContinue && (
|
|
558
|
+
<Button variant="secondary" onClick={handleNext} disabled={busy}>
|
|
559
|
+
Run Next Page/Chunk
|
|
560
|
+
</Button>
|
|
561
|
+
)}
|
|
562
|
+
<Button variant="danger-light" onClick={handleCancel}>
|
|
563
|
+
Cancel
|
|
564
|
+
</Button>
|
|
565
|
+
</>
|
|
566
|
+
)}
|
|
567
|
+
{isTerminal && (
|
|
568
|
+
<Button variant="tertiary" onClick={() => setJob(null)}>
|
|
569
|
+
Start New Transfer
|
|
570
|
+
</Button>
|
|
571
|
+
)}
|
|
572
|
+
{isActive && jobStats && (
|
|
573
|
+
<Flex gap={2} alignItems="center" style={{ marginLeft: 'auto' }}>
|
|
574
|
+
{isRunning && <Loader small>Running…</Loader>}
|
|
575
|
+
{isPaused && (
|
|
576
|
+
<Badge backgroundColor="warning100" textColor="warning700">Paused</Badge>
|
|
577
|
+
)}
|
|
578
|
+
<Typography variant="pi" textColor="neutral700">
|
|
579
|
+
Chunk {job.cursor + (isTerminal ? 0 : 1)}/{job.total}
|
|
580
|
+
{jobStats.currentChunk?.label ? ` · ${jobStats.currentChunk.label}` : ''}
|
|
581
|
+
{jobStats.currentChunk?.pagesTotal
|
|
582
|
+
? ` · page ${jobStats.currentChunk.page || 0}/${jobStats.currentChunk.pagesTotal}`
|
|
583
|
+
: jobStats.currentChunk?.page
|
|
584
|
+
? ` · page ${jobStats.currentChunk.page}`
|
|
585
|
+
: ''}
|
|
586
|
+
{' · '}pushed {jobStats.pushed} · pulled {jobStats.pulled}
|
|
587
|
+
{jobStats.errors ? ` · ${jobStats.errors} error(s)` : ''}
|
|
588
|
+
</Typography>
|
|
589
|
+
</Flex>
|
|
590
|
+
)}
|
|
591
|
+
{preview && !job && (
|
|
592
|
+
<Typography variant="pi" textColor="neutral500" style={{ alignSelf: 'center' }}>
|
|
593
|
+
Plan: {preview.total} chunk{preview.total === 1 ? '' : 's'}
|
|
594
|
+
{previewChunks.length > 0
|
|
595
|
+
? ` · selected ${getSelectedIndexes().length}/${previewChunks.length}`
|
|
596
|
+
: ''}
|
|
597
|
+
</Typography>
|
|
598
|
+
)}
|
|
599
|
+
</Flex>
|
|
600
|
+
</Box>
|
|
601
|
+
|
|
602
|
+
{chunkRows.length > 0 && (
|
|
603
|
+
<Box paddingTop={4}>
|
|
604
|
+
<Typography variant="delta">
|
|
605
|
+
{job ? `Chunks (${job.cursor}/${job.total})` : `Planned Chunks (${preview?.total || chunkRows.length})`}
|
|
606
|
+
</Typography>
|
|
607
|
+
<Box paddingTop={2}>
|
|
608
|
+
<Table>
|
|
609
|
+
<Thead>
|
|
610
|
+
<Tr>
|
|
611
|
+
<Th style={{ width: 48 }}>
|
|
612
|
+
{!job && previewChunks.length > 0 ? (
|
|
613
|
+
<Checkbox
|
|
614
|
+
checked={allSelected ? true : noneSelected ? false : 'indeterminate'}
|
|
615
|
+
onCheckedChange={handleToggleAll}
|
|
616
|
+
/>
|
|
617
|
+
) : (
|
|
618
|
+
<Typography variant="sigma">Run</Typography>
|
|
619
|
+
)}
|
|
620
|
+
</Th>
|
|
621
|
+
<Th style={{ width: 60 }}><Typography variant="sigma">#</Typography></Th>
|
|
622
|
+
<Th><Typography variant="sigma">Kind</Typography></Th>
|
|
623
|
+
<Th><Typography variant="sigma">Target</Typography></Th>
|
|
624
|
+
<Th><Typography variant="sigma">Status</Typography></Th>
|
|
625
|
+
<Th><Typography variant="sigma">Page</Typography></Th>
|
|
626
|
+
<Th><Typography variant="sigma">Pushed / Pulled</Typography></Th>
|
|
627
|
+
<Th><Typography variant="sigma">Notes</Typography></Th>
|
|
628
|
+
</Tr>
|
|
629
|
+
</Thead>
|
|
630
|
+
<Tbody>
|
|
631
|
+
{chunkRows.map((c) => {
|
|
632
|
+
const pageLabel = c.pagesTotal
|
|
633
|
+
? `${c.page || 0}/${c.pagesTotal}`
|
|
634
|
+
: c.page
|
|
635
|
+
? `${c.page}`
|
|
636
|
+
: '—';
|
|
637
|
+
const pushPullLabel = (c.pushed || c.pulled || c.errors)
|
|
638
|
+
? `${c.pushed || 0} / ${c.pulled || 0}${c.errors ? ` (err ${c.errors})` : ''}`
|
|
639
|
+
: '—';
|
|
640
|
+
const isSelectedRow = job
|
|
641
|
+
? c.selected !== false
|
|
642
|
+
: !!selected[c.index];
|
|
643
|
+
return (
|
|
644
|
+
<Tr key={c.index}>
|
|
645
|
+
<Td>
|
|
646
|
+
{!job ? (
|
|
647
|
+
<Checkbox
|
|
648
|
+
checked={isSelectedRow}
|
|
649
|
+
onCheckedChange={() => handleToggleChunk(c.index)}
|
|
650
|
+
/>
|
|
651
|
+
) : (
|
|
652
|
+
<Badge>{isSelectedRow ? 'yes' : 'no'}</Badge>
|
|
653
|
+
)}
|
|
654
|
+
</Td>
|
|
655
|
+
<Td><Typography>{c.index + 1}</Typography></Td>
|
|
656
|
+
<Td><Badge>{c.kind}</Badge></Td>
|
|
657
|
+
<Td><Typography>{c.label}</Typography></Td>
|
|
658
|
+
<Td>
|
|
659
|
+
<Flex gap={2} alignItems="center">
|
|
660
|
+
{c.status === 'running' && <Loader small />}
|
|
661
|
+
<Badge active={c.status === 'running' || c.status === 'success'}>
|
|
662
|
+
{c.status}
|
|
663
|
+
</Badge>
|
|
664
|
+
</Flex>
|
|
665
|
+
</Td>
|
|
666
|
+
<Td><Typography variant="pi">{pageLabel}</Typography></Td>
|
|
667
|
+
<Td><Typography variant="pi">{pushPullLabel}</Typography></Td>
|
|
668
|
+
<Td>
|
|
669
|
+
{c.error && <Typography textColor="danger600" variant="pi">{c.error}</Typography>}
|
|
670
|
+
{!c.error && c.warning && <Typography textColor="warning600" variant="pi">{c.warning}</Typography>}
|
|
671
|
+
</Td>
|
|
672
|
+
</Tr>
|
|
673
|
+
);
|
|
674
|
+
})}
|
|
675
|
+
</Tbody>
|
|
676
|
+
</Table>
|
|
677
|
+
</Box>
|
|
678
|
+
</Box>
|
|
679
|
+
)}
|
|
680
|
+
|
|
681
|
+
{job && job.status && job.status !== 'running' && (
|
|
682
|
+
<Box paddingTop={4}>
|
|
683
|
+
<Alert
|
|
684
|
+
variant={job.status === 'success' ? 'success' : job.status === 'partial' ? 'warning' : 'danger'}
|
|
685
|
+
closeLabel="Close"
|
|
686
|
+
onClose={() => {}}
|
|
687
|
+
>
|
|
688
|
+
Transfer {job.status}. Ran {job.cursor}/{job.total} chunks
|
|
689
|
+
{job.errors?.length ? `, ${job.errors.length} error(s)` : ''}.
|
|
690
|
+
</Alert>
|
|
691
|
+
</Box>
|
|
692
|
+
)}
|
|
693
|
+
|
|
694
|
+
</>)}
|
|
695
|
+
|
|
696
|
+
{subTab === 'history' && (
|
|
697
|
+
<Box paddingTop={4}>
|
|
698
|
+
<Flex gap={2} alignItems="center" justifyContent="space-between">
|
|
699
|
+
<Typography variant="delta">Previous Runs</Typography>
|
|
700
|
+
{history.length > 0 && (
|
|
701
|
+
<Button variant="tertiary" onClick={handleClearHistory} disabled={historyBusy}>
|
|
702
|
+
Clear History
|
|
703
|
+
</Button>
|
|
704
|
+
)}
|
|
705
|
+
</Flex>
|
|
706
|
+
<Typography variant="pi" textColor="neutral600">
|
|
707
|
+
Paused, cancelled, and completed runs are preserved here. Restart a run from scratch
|
|
708
|
+
using the same chunk selection, or load its selection into the Run Transfer tab to tweak.
|
|
709
|
+
</Typography>
|
|
710
|
+
<Box paddingTop={2}>
|
|
711
|
+
{history.length === 0 ? (
|
|
712
|
+
<Typography variant="pi" textColor="neutral500">No previous runs yet.</Typography>
|
|
713
|
+
) : (
|
|
714
|
+
<Table>
|
|
715
|
+
<Thead>
|
|
716
|
+
<Tr>
|
|
717
|
+
<Th><Typography variant="sigma">When</Typography></Th>
|
|
718
|
+
<Th><Typography variant="sigma">Direction</Typography></Th>
|
|
719
|
+
<Th><Typography variant="sigma">Status</Typography></Th>
|
|
720
|
+
<Th><Typography variant="sigma">Chunks</Typography></Th>
|
|
721
|
+
<Th><Typography variant="sigma">Actions</Typography></Th>
|
|
722
|
+
</Tr>
|
|
723
|
+
</Thead>
|
|
724
|
+
<Tbody>
|
|
725
|
+
{history.map((h) => {
|
|
726
|
+
const selCount = (h.chunks || []).filter((c) => c.selected !== false).length;
|
|
727
|
+
const doneCount = (h.chunks || []).filter(
|
|
728
|
+
(c) => c.status === 'success' || c.status === 'partial'
|
|
729
|
+
).length;
|
|
730
|
+
const remaining = Math.max(selCount - doneCount, 0);
|
|
731
|
+
const when = h.startedAt || h.createdAt;
|
|
732
|
+
const isResumable = ['paused', 'cancelled', 'error'].includes(h.status) && remaining > 0;
|
|
733
|
+
const isExpanded = expandedHistoryId === h.id;
|
|
734
|
+
const aggPushed = (h.chunks || []).reduce((s, c) => s + (c.pushed || 0), 0);
|
|
735
|
+
const aggPulled = (h.chunks || []).reduce((s, c) => s + (c.pulled || 0), 0);
|
|
736
|
+
const aggErrors = (h.chunks || []).reduce((s, c) => s + (c.errors || 0), 0);
|
|
737
|
+
return (
|
|
738
|
+
<React.Fragment key={h.id}>
|
|
739
|
+
<Tr>
|
|
740
|
+
<Td>
|
|
741
|
+
<Typography variant="pi">
|
|
742
|
+
{when ? new Date(when).toLocaleString() : '—'}
|
|
743
|
+
</Typography>
|
|
744
|
+
</Td>
|
|
745
|
+
<Td><Badge>{h.direction}</Badge></Td>
|
|
746
|
+
<Td>
|
|
747
|
+
<Badge
|
|
748
|
+
backgroundColor={
|
|
749
|
+
h.status === 'success' ? 'success100'
|
|
750
|
+
: h.status === 'partial' ? 'warning100'
|
|
751
|
+
: h.status === 'paused' ? 'warning100'
|
|
752
|
+
: h.status === 'cancelled' || h.status === 'error' ? 'danger100'
|
|
753
|
+
: 'neutral100'
|
|
754
|
+
}
|
|
755
|
+
>
|
|
756
|
+
{h.status}
|
|
757
|
+
</Badge>
|
|
758
|
+
</Td>
|
|
759
|
+
<Td>
|
|
760
|
+
<Typography variant="pi">
|
|
761
|
+
{doneCount}/{selCount} done · {h.total} total
|
|
762
|
+
</Typography>
|
|
763
|
+
</Td>
|
|
764
|
+
<Td>
|
|
765
|
+
<Flex gap={2} wrap="wrap">
|
|
766
|
+
<Button
|
|
767
|
+
size="S"
|
|
768
|
+
variant="tertiary"
|
|
769
|
+
onClick={() => setExpandedHistoryId(isExpanded ? null : h.id)}
|
|
770
|
+
>
|
|
771
|
+
{isExpanded ? 'Hide Details' : 'View Details'}
|
|
772
|
+
</Button>
|
|
773
|
+
<Button
|
|
774
|
+
size="S"
|
|
775
|
+
variant="secondary"
|
|
776
|
+
onClick={() => handleLoadFromHistory(h)}
|
|
777
|
+
disabled={historyBusy || isActive}
|
|
778
|
+
>
|
|
779
|
+
Load Selection
|
|
780
|
+
</Button>
|
|
781
|
+
{isResumable && (
|
|
782
|
+
<Button
|
|
783
|
+
size="S"
|
|
784
|
+
onClick={() => handleResumeFromHistory(h.id)}
|
|
785
|
+
disabled={historyBusy || isActive}
|
|
786
|
+
>
|
|
787
|
+
Resume
|
|
788
|
+
</Button>
|
|
789
|
+
)}
|
|
790
|
+
<Button
|
|
791
|
+
size="S"
|
|
792
|
+
variant={isResumable ? 'secondary' : 'default'}
|
|
793
|
+
onClick={() => handleRestartFromHistory(h.id)}
|
|
794
|
+
disabled={historyBusy || isActive}
|
|
795
|
+
>
|
|
796
|
+
{isResumable ? 'Restart from Scratch' : 'Start Again'}
|
|
797
|
+
</Button>
|
|
798
|
+
</Flex>
|
|
799
|
+
</Td>
|
|
800
|
+
</Tr>
|
|
801
|
+
{isExpanded && (
|
|
802
|
+
<Tr>
|
|
803
|
+
<Td colSpan={5}>
|
|
804
|
+
<Box background="neutral100" padding={3}>
|
|
805
|
+
<Flex gap={4} wrap="wrap" paddingBottom={2}>
|
|
806
|
+
<Typography variant="pi">
|
|
807
|
+
<strong>Totals:</strong> pushed {aggPushed} · pulled {aggPulled} · errors {aggErrors}
|
|
808
|
+
</Typography>
|
|
809
|
+
<Typography variant="pi">
|
|
810
|
+
<strong>Conflict:</strong> {h.conflictStrategy || 'latest'}
|
|
811
|
+
</Typography>
|
|
812
|
+
<Typography variant="pi">
|
|
813
|
+
<strong>Deletions:</strong> {h.syncDeletions ? 'yes' : 'no'}
|
|
814
|
+
</Typography>
|
|
815
|
+
{h.completedAt && (
|
|
816
|
+
<Typography variant="pi">
|
|
817
|
+
<strong>Ended:</strong> {new Date(h.completedAt).toLocaleString()}
|
|
818
|
+
</Typography>
|
|
819
|
+
)}
|
|
820
|
+
</Flex>
|
|
821
|
+
<Table>
|
|
822
|
+
<Thead>
|
|
823
|
+
<Tr>
|
|
824
|
+
<Th style={{ width: 60 }}><Typography variant="sigma">#</Typography></Th>
|
|
825
|
+
<Th><Typography variant="sigma">Kind</Typography></Th>
|
|
826
|
+
<Th><Typography variant="sigma">Target</Typography></Th>
|
|
827
|
+
<Th><Typography variant="sigma">Status</Typography></Th>
|
|
828
|
+
<Th><Typography variant="sigma">Page</Typography></Th>
|
|
829
|
+
<Th><Typography variant="sigma">Pushed / Pulled</Typography></Th>
|
|
830
|
+
<Th><Typography variant="sigma">Notes</Typography></Th>
|
|
831
|
+
</Tr>
|
|
832
|
+
</Thead>
|
|
833
|
+
<Tbody>
|
|
834
|
+
{(h.chunks || []).map((c) => {
|
|
835
|
+
const pageLabel = c.pagesTotal
|
|
836
|
+
? `${c.page || 0}/${c.pagesTotal}`
|
|
837
|
+
: c.page
|
|
838
|
+
? `${c.page}`
|
|
839
|
+
: '—';
|
|
840
|
+
const pushPullLabel = (c.pushed || c.pulled || c.errors)
|
|
841
|
+
? `${c.pushed || 0} / ${c.pulled || 0}${c.errors ? ` (err ${c.errors})` : ''}`
|
|
842
|
+
: '—';
|
|
843
|
+
return (
|
|
844
|
+
<Tr key={c.index}>
|
|
845
|
+
<Td><Typography variant="pi">{c.index + 1}</Typography></Td>
|
|
846
|
+
<Td><Badge>{c.kind}</Badge></Td>
|
|
847
|
+
<Td><Typography variant="pi">{c.label}</Typography></Td>
|
|
848
|
+
<Td>
|
|
849
|
+
<Badge>{c.selected === false ? 'not selected' : c.status}</Badge>
|
|
850
|
+
</Td>
|
|
851
|
+
<Td><Typography variant="pi">{pageLabel}</Typography></Td>
|
|
852
|
+
<Td><Typography variant="pi">{pushPullLabel}</Typography></Td>
|
|
853
|
+
<Td>
|
|
854
|
+
{c.error && <Typography textColor="danger600" variant="pi">{c.error}</Typography>}
|
|
855
|
+
{!c.error && c.warning && <Typography textColor="warning600" variant="pi">{c.warning}</Typography>}
|
|
856
|
+
</Td>
|
|
857
|
+
</Tr>
|
|
858
|
+
);
|
|
859
|
+
})}
|
|
860
|
+
</Tbody>
|
|
861
|
+
</Table>
|
|
862
|
+
</Box>
|
|
863
|
+
</Td>
|
|
864
|
+
</Tr>
|
|
865
|
+
)}
|
|
866
|
+
</React.Fragment>
|
|
867
|
+
);
|
|
868
|
+
})}
|
|
869
|
+
</Tbody>
|
|
870
|
+
</Table>
|
|
871
|
+
)}
|
|
872
|
+
</Box>
|
|
873
|
+
</Box>
|
|
874
|
+
)}
|
|
875
|
+
</Box>
|
|
876
|
+
);
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
export { BulkTransferTab };
|
|
880
|
+
export default BulkTransferTab;
|