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.
Files changed (54) hide show
  1. package/README.md +33 -14
  2. package/admin/src/components/BulkTransferTab.jsx +880 -0
  3. package/admin/src/components/ConfigTab.jsx +81 -3
  4. package/admin/src/components/HelpTab.jsx +148 -5
  5. package/admin/src/components/MediaTab.jsx +141 -30
  6. package/admin/src/components/SyncTab.jsx +2 -0
  7. package/admin/src/pages/App/index.jsx +12 -1
  8. package/docs/Screenshot 2026-04-22 183540.png +0 -0
  9. package/docs/Screenshot 2026-04-22 183552.png +0 -0
  10. package/docs/Screenshot 2026-04-23 114332.png +0 -0
  11. package/docs/Screenshot 2026-04-23 114644.png +0 -0
  12. package/docs/Screenshot 2026-04-23 114651.png +0 -0
  13. package/docs/Screenshot 2026-04-23 114737.png +0 -0
  14. package/docs/Screenshot 2026-04-23 114904.png +0 -0
  15. package/docs/Screenshot 2026-04-23 114940.png +0 -0
  16. package/docs/Screenshot 2026-04-23 115003.png +0 -0
  17. package/docs/Screenshot 2026-04-23 115024.png +0 -0
  18. package/docs/Screenshot 2026-04-23 115116.png +0 -0
  19. package/docs/Screenshot 2026-04-23 115141.png +0 -0
  20. package/docs/Screenshot 2026-04-23 115252.png +0 -0
  21. package/docs/Screenshot 2026-04-23 115448.png +0 -0
  22. package/docs/Screenshot 2026-04-23 120534.png +0 -0
  23. package/docs/Screenshot 2026-04-23 122544.png +0 -0
  24. package/docs/Screenshot 2026-04-23 122712.png +0 -0
  25. package/docs/Screenshot 2026-04-23 122730.png +0 -0
  26. package/docs/Screenshot 2026-04-23 122858.png +0 -0
  27. package/docs/Screenshot 2026-04-23 122924.png +0 -0
  28. package/docs/Screenshot 2026-04-23 122937.png +0 -0
  29. package/package.json +13 -4
  30. package/server/src/controllers/bulk-transfer.js +141 -0
  31. package/server/src/controllers/config.js +76 -3
  32. package/server/src/controllers/index.js +2 -0
  33. package/server/src/controllers/sync-media.js +24 -0
  34. package/server/src/routes/index.js +18 -0
  35. package/server/src/services/bulk-transfer.js +837 -0
  36. package/server/src/services/index.js +2 -0
  37. package/server/src/services/sync-media.js +168 -32
  38. package/server/src/services/sync.js +137 -1
  39. package/docs/Screenshot 2026-04-20 160506.png +0 -0
  40. package/docs/Screenshot 2026-04-20 160558.png +0 -0
  41. package/docs/Screenshot 2026-04-20 175903.png +0 -0
  42. package/docs/Screenshot 2026-04-20 175931.png +0 -0
  43. package/docs/Screenshot 2026-04-20 180001.png +0 -0
  44. package/docs/Screenshot 2026-04-20 180041.png +0 -0
  45. package/docs/Screenshot 2026-04-20 180116.png +0 -0
  46. package/docs/Screenshot 2026-04-20 180135.png +0 -0
  47. package/docs/Screenshot 2026-04-20 180202.png +0 -0
  48. package/docs/Screenshot 2026-04-20 180228.png +0 -0
  49. package/docs/Screenshot 2026-04-20 180251.png +0 -0
  50. package/docs/Screenshot 2026-04-20 180301.png +0 -0
  51. package/docs/clipchamp-screen-recording-script.md +0 -0
  52. package/docs/production-readiness-status.md +0 -34
  53. package/docs/production-readiness-test-matrix.md +0 -151
  54. 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;