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
|
@@ -143,6 +143,38 @@ const ConfigTab = () => {
|
|
|
143
143
|
}
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
+
// Validate + normalize a Strapi Server URL entered by the user.
|
|
147
|
+
// Returns { ok: true, normalized } or { ok: false, error }.
|
|
148
|
+
const validateBaseUrl = (raw) => {
|
|
149
|
+
if (!raw || !raw.trim()) {
|
|
150
|
+
return { ok: false, error: 'Server URL is required' };
|
|
151
|
+
}
|
|
152
|
+
let url = raw.trim();
|
|
153
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: 'Server URL must start with http:// or https:// (e.g. http://localhost:4010)',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// Strip trailing slashes and common mistakes like /admin or /api
|
|
160
|
+
url = url.replace(/\/+$/, '').replace(/\/admin$/i, '').replace(/\/api$/i, '');
|
|
161
|
+
try {
|
|
162
|
+
const parsed = new URL(url);
|
|
163
|
+
if (!parsed.hostname) {
|
|
164
|
+
return { ok: false, error: 'Server URL is missing a hostname' };
|
|
165
|
+
}
|
|
166
|
+
if (parsed.pathname && parsed.pathname !== '/' && parsed.pathname !== '') {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: `Server URL should be the Strapi root (e.g. http://localhost:4010), not include a path like "${parsed.pathname}"`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
return { ok: false, error: 'Server URL is not a valid URL' };
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, normalized: url };
|
|
176
|
+
};
|
|
177
|
+
|
|
146
178
|
// Login with credentials to remote server and get/create API token
|
|
147
179
|
const handleLoginWithCredentials = async () => {
|
|
148
180
|
if (!config.baseUrl || !credentials.email || !credentials.password) {
|
|
@@ -150,12 +182,22 @@ const ConfigTab = () => {
|
|
|
150
182
|
return;
|
|
151
183
|
}
|
|
152
184
|
|
|
185
|
+
const urlCheck = validateBaseUrl(config.baseUrl);
|
|
186
|
+
if (!urlCheck.ok) {
|
|
187
|
+
setLoginState({ loading: false, success: false, error: urlCheck.error });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// Auto-correct the stored value if we had to normalize it (e.g. trim slash or /admin)
|
|
191
|
+
if (urlCheck.normalized !== config.baseUrl) {
|
|
192
|
+
setConfig((prev) => ({ ...prev, baseUrl: urlCheck.normalized }));
|
|
193
|
+
}
|
|
194
|
+
|
|
153
195
|
setLoginState({ loading: true, success: false, error: null });
|
|
154
196
|
|
|
155
197
|
try {
|
|
156
198
|
// Call our backend to proxy the login request
|
|
157
199
|
const response = await post(`/${PLUGIN_ID}/config/remote-login`, {
|
|
158
|
-
baseUrl:
|
|
200
|
+
baseUrl: urlCheck.normalized,
|
|
159
201
|
email: credentials.email,
|
|
160
202
|
password: credentials.password,
|
|
161
203
|
});
|
|
@@ -368,10 +410,46 @@ const ConfigTab = () => {
|
|
|
368
410
|
placeholder="https://my-other-strapi.com"
|
|
369
411
|
value={config.baseUrl}
|
|
370
412
|
onChange={(e) => setConfig((p) => ({ ...p, baseUrl: e.target.value }))}
|
|
413
|
+
onBlur={(e) => {
|
|
414
|
+
const v = validateBaseUrl(e.target.value);
|
|
415
|
+
if (v.ok && v.normalized !== e.target.value) {
|
|
416
|
+
setConfig((p) => ({ ...p, baseUrl: v.normalized }));
|
|
417
|
+
}
|
|
418
|
+
}}
|
|
371
419
|
/>
|
|
372
|
-
<Field.Hint>URL of the remote Strapi server where this plugin is also installed
|
|
420
|
+
<Field.Hint>URL of the remote Strapi server where this plugin is also installed. Use the root URL only (e.g. http://localhost:4010) — do not append /admin or /api.</Field.Hint>
|
|
421
|
+
{config.baseUrl && (() => {
|
|
422
|
+
const v = validateBaseUrl(config.baseUrl);
|
|
423
|
+
return v.ok ? null : (
|
|
424
|
+
<Box paddingTop={1}>
|
|
425
|
+
<Typography variant="pi" textColor="danger600">
|
|
426
|
+
{v.error}
|
|
427
|
+
</Typography>
|
|
428
|
+
</Box>
|
|
429
|
+
);
|
|
430
|
+
})()}
|
|
373
431
|
</Field.Root>
|
|
374
432
|
|
|
433
|
+
<Box>
|
|
434
|
+
<Alert variant="default" title="Before you continue">
|
|
435
|
+
<Box>
|
|
436
|
+
<Typography variant="pi">
|
|
437
|
+
• <strong>Server URL</strong> must be the Strapi root (e.g. <code>http://localhost:4010</code>) — not <code>/admin</code>, <code>/api</code>, or an internal IP the server can't see.
|
|
438
|
+
</Typography>
|
|
439
|
+
</Box>
|
|
440
|
+
<Box paddingTop={1}>
|
|
441
|
+
<Typography variant="pi">
|
|
442
|
+
• Use an <strong>existing admin user's</strong> email and password for the remote Strapi panel. This is not an API token and not a local DB user.
|
|
443
|
+
</Typography>
|
|
444
|
+
</Box>
|
|
445
|
+
<Box paddingTop={1}>
|
|
446
|
+
<Typography variant="pi">
|
|
447
|
+
• If you see "Missing or invalid credentials", verify the URL first, then the email/password. A wrong URL often surfaces as an auth error.
|
|
448
|
+
</Typography>
|
|
449
|
+
</Box>
|
|
450
|
+
</Alert>
|
|
451
|
+
</Box>
|
|
452
|
+
|
|
375
453
|
<Field.Root>
|
|
376
454
|
<Field.Label>API Token</Field.Label>
|
|
377
455
|
<Flex gap={2}>
|
|
@@ -391,7 +469,7 @@ const ConfigTab = () => {
|
|
|
391
469
|
<Button
|
|
392
470
|
variant="secondary"
|
|
393
471
|
onClick={() => setShowLoginModal(true)}
|
|
394
|
-
disabled={!config.baseUrl}
|
|
472
|
+
disabled={!config.baseUrl || !validateBaseUrl(config.baseUrl).ok}
|
|
395
473
|
>
|
|
396
474
|
{config.apiToken ? 'Regenerate' : 'Generate'}
|
|
397
475
|
</Button>
|
|
@@ -49,6 +49,7 @@ export const HelpTab = () => {
|
|
|
49
49
|
<Tabs.Trigger value="content-types">Content Types</Tabs.Trigger>
|
|
50
50
|
<Tabs.Trigger value="sync-profiles">Sync Profiles</Tabs.Trigger>
|
|
51
51
|
<Tabs.Trigger value="execution">Sync Execution</Tabs.Trigger>
|
|
52
|
+
<Tabs.Trigger value="bulk-transfer">Bulk Transfer</Tabs.Trigger>
|
|
52
53
|
<Tabs.Trigger value="media">Media</Tabs.Trigger>
|
|
53
54
|
<Tabs.Trigger value="stats">Stats</Tabs.Trigger>
|
|
54
55
|
<Tabs.Trigger value="enforcement">Enforcement</Tabs.Trigger>
|
|
@@ -130,6 +131,8 @@ export const HelpTab = () => {
|
|
|
130
131
|
<li><Typography variant="omega">Conflict resolution strategies (Latest, Local, Remote wins)</Typography></li>
|
|
131
132
|
<li><Typography variant="omega">Pagination support for large datasets with bounded memory usage</Typography></li>
|
|
132
133
|
<li><Typography variant="omega">Dependency resolution - sync related entities automatically</Typography></li>
|
|
134
|
+
<li><Typography variant="omega"><strong>Bulk Transfer</strong> - top-level tab for one-click full push / full pull with selectable chunks, page-level progress, pause / resume / cancel, and persisted run history (restart or resume any previous run)</Typography></li>
|
|
135
|
+
<li><Typography variant="omega">Media sync (URL or rsync) with MIME filters and morph-link remapping</Typography></li>
|
|
133
136
|
<li><Typography variant="omega">Enforcement checks - schema, version, and time validation</Typography></li>
|
|
134
137
|
<li><Typography variant="omega">Configurable alerts via email, webhook, or Strapi logs</Typography></li>
|
|
135
138
|
<li><Typography variant="omega">Secure communication via API tokens and HMAC signatures</Typography></li>
|
|
@@ -143,6 +146,7 @@ export const HelpTab = () => {
|
|
|
143
146
|
<ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
|
|
144
147
|
<li><Typography variant="omega"><strong>Sync Profiles</strong> - Define WHAT to sync (direction, conflict strategy, field policies)</Typography></li>
|
|
145
148
|
<li><Typography variant="omega"><strong>Sync Execution</strong> - Define WHEN to sync (on-demand, scheduled, live) and dependency handling</Typography></li>
|
|
149
|
+
<li><Typography variant="omega"><strong>Bulk Transfer</strong> - One-click full pull / push across selectable chunks with pause / resume and persisted history (separate top-level tab)</Typography></li>
|
|
146
150
|
<li><Typography variant="omega"><strong>Enforcement</strong> - Pre-sync validation (schema match, version check, time sync)</Typography></li>
|
|
147
151
|
<li><Typography variant="omega"><strong>Alerts</strong> - Notifications for sync success/failure</Typography></li>
|
|
148
152
|
</ul>
|
|
@@ -153,7 +157,8 @@ export const HelpTab = () => {
|
|
|
153
157
|
<li><Typography variant="omega"><strong>Configuration Tab</strong> - Set up remote server URL, API token, instance ID, and shared secret</Typography></li>
|
|
154
158
|
<li><Typography variant="omega"><strong>Content Types Tab</strong> - Enable content types for sync (auto-generates default profiles)</Typography></li>
|
|
155
159
|
<li><Typography variant="omega"><strong>Sync Profiles Tab</strong> - Customize sync behavior or use defaults</Typography></li>
|
|
156
|
-
<li><Typography variant="omega"><strong>Sync
|
|
160
|
+
<li><Typography variant="omega"><strong>Sync Tab</strong> - Configure execution settings, page size, and run sync operations</Typography></li>
|
|
161
|
+
<li><Typography variant="omega"><strong>Bulk Transfer Tab</strong> - For a first-time migration or full refresh, run a one-click Full Pull or Full Push with selectable chunks, pause / resume, and persisted run history</Typography></li>
|
|
157
162
|
</ol>
|
|
158
163
|
</HelpSection>
|
|
159
164
|
|
|
@@ -568,6 +573,18 @@ http://localhost:1337</CodeBlock>
|
|
|
568
573
|
</ul>
|
|
569
574
|
</HelpSection>
|
|
570
575
|
|
|
576
|
+
<HelpSection title="Bulk Transfer (Full Pull / Full Push)">
|
|
577
|
+
<Typography variant="omega">
|
|
578
|
+
Bulk Transfer is now its own <strong>top-level tab</strong> (separate from Sync). It is a
|
|
579
|
+
one-click helper for moving everything between local and remote, with selectable chunks,
|
|
580
|
+
page-level progress, pause / resume / cancel, and a persisted run history you can restart
|
|
581
|
+
or resume from.
|
|
582
|
+
</Typography>
|
|
583
|
+
<Typography variant="omega" paddingTop={2}>
|
|
584
|
+
See the <strong>Bulk Transfer</strong> help tab for the full guide.
|
|
585
|
+
</Typography>
|
|
586
|
+
</HelpSection>
|
|
587
|
+
|
|
571
588
|
<HelpSection title="Execution Status">
|
|
572
589
|
<Typography variant="omega">
|
|
573
590
|
The Status tab in Sync shows the current state of all profiles:
|
|
@@ -579,11 +596,103 @@ http://localhost:1337</CodeBlock>
|
|
|
579
596
|
<li><Typography variant="omega"><strong>Next Run</strong> - When scheduled sync will run next</Typography></li>
|
|
580
597
|
<li><Typography variant="omega"><strong>Status</strong> - Running or Idle</Typography></li>
|
|
581
598
|
</ul>
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
599
|
+
</HelpSection>
|
|
600
|
+
</Box>
|
|
601
|
+
</Tabs.Content>
|
|
602
|
+
|
|
603
|
+
{/* Bulk Transfer Tab */}
|
|
604
|
+
<Tabs.Content value="bulk-transfer">
|
|
605
|
+
<Box paddingTop={4}>
|
|
606
|
+
<HelpSection title="What is Bulk Transfer?">
|
|
607
|
+
<Typography variant="omega">
|
|
608
|
+
<strong>Bulk Transfer</strong> is a dedicated top-level tab for one-click, full-scope data
|
|
609
|
+
movement between the local and remote Strapi instances. It is the fastest way to perform an
|
|
610
|
+
initial migration, a full refresh, or a disaster-recovery style mirror, without having to
|
|
611
|
+
orchestrate individual Sync Profiles.
|
|
612
|
+
</Typography>
|
|
613
|
+
<Typography variant="omega" paddingTop={2}>
|
|
614
|
+
Unlike the Sync tab — which is profile-driven and can be scheduled or live — Bulk Transfer is
|
|
615
|
+
an explicit, user-driven run: pick a direction, pick the scopes, pick (or deselect) the chunks,
|
|
616
|
+
and start. It is designed to be safely pausable, resumable, and inspectable.
|
|
617
|
+
</Typography>
|
|
618
|
+
</HelpSection>
|
|
619
|
+
|
|
620
|
+
<HelpSection title="Direction & Scopes">
|
|
621
|
+
<ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
|
|
622
|
+
<li><Typography variant="omega"><strong>Direction</strong> — <em>Full Pull</em> (remote → local) or <em>Full Push</em> (local → remote). In <strong>Single-side</strong> mode only Full Pull is available.</Typography></li>
|
|
623
|
+
<li><Typography variant="omega"><strong>Content</strong> — All user-defined <code>api::*</code> content types.</Typography></li>
|
|
624
|
+
<li><Typography variant="omega"><strong>Media</strong> — Files and morph links via the active media profiles.</Typography></li>
|
|
625
|
+
<li><Typography variant="omega"><strong>Strapi Users</strong> — <code>plugin::users-permissions.user</code>.</Typography></li>
|
|
626
|
+
<li><Typography variant="omega"><strong>Admin Users</strong> — <code>admin::user</code> (experimental; use with care).</Typography></li>
|
|
627
|
+
<li><Typography variant="omega"><strong>Apply deletions</strong> — Destination removes items missing on source. Use with care, especially with user scopes.</Typography></li>
|
|
628
|
+
<li><Typography variant="omega"><strong>Conflict strategy</strong> — Latest updated wins (default), Local wins, or Remote wins.</Typography></li>
|
|
629
|
+
<li><Typography variant="omega"><strong>Auto-continue</strong> — Run all selected chunks back-to-back, or pause between chunks to review.</Typography></li>
|
|
630
|
+
</ul>
|
|
631
|
+
</HelpSection>
|
|
632
|
+
|
|
633
|
+
<HelpSection title="Selectable Chunks & Page-level Progress">
|
|
634
|
+
<Typography variant="omega">
|
|
635
|
+
A bulk job expands into one <strong>chunk</strong> per content type and one chunk per active
|
|
636
|
+
media profile (plus any selected user scopes). In the Run Transfer view you can:
|
|
637
|
+
</Typography>
|
|
638
|
+
<ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
|
|
639
|
+
<li><Typography variant="omega">Toggle individual chunks, or use <strong>Select / Deselect All</strong> to run only a subset.</Typography></li>
|
|
640
|
+
<li><Typography variant="omega">Watch per-chunk <strong>page progress</strong> (<code>page X of Y</code>, records pushed / pulled, errors) updated in near real-time.</Typography></li>
|
|
641
|
+
<li><Typography variant="omega">See consolidated run stats (total pushed, pulled, errors, pages done) and a spinner while the job is actively processing.</Typography></li>
|
|
642
|
+
<li><Typography variant="omega">Use <strong>Pause</strong> to stop after the current page, <strong>Resume</strong> to continue from the exact saved cursor, <strong>Run Next Chunk</strong> in manual mode, or <strong>Cancel</strong> to stop the run without losing the progress already made.</Typography></li>
|
|
643
|
+
</ul>
|
|
644
|
+
<Typography variant="pi" textColor="neutral600" paddingTop={2}>
|
|
645
|
+
Because transfer is paginated end-to-end using the global <code>syncPageSize</code>, memory
|
|
646
|
+
stays bounded even on very large datasets.
|
|
647
|
+
</Typography>
|
|
648
|
+
</HelpSection>
|
|
585
649
|
|
|
586
|
-
|
|
650
|
+
<HelpSection title="Pause, Resume & Cancel">
|
|
651
|
+
<Typography variant="omega">
|
|
652
|
+
Bulk Transfer treats <strong>pause</strong>, <strong>cancel</strong>, and <strong>resume</strong> as
|
|
653
|
+
first-class operations:
|
|
654
|
+
</Typography>
|
|
655
|
+
<ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
|
|
656
|
+
<li><Typography variant="omega"><strong>Pause</strong> — Stops after the current page finishes. The chunk cursor, page number, and counters are persisted, so resume continues from the same place.</Typography></li>
|
|
657
|
+
<li><Typography variant="omega"><strong>Cancel</strong> — Distinct from pause: the run is marked cancelled and the in-flight chunk is preserved as <em>paused</em> with accumulated progress, not falsely reported as successful.</Typography></li>
|
|
658
|
+
<li><Typography variant="omega"><strong>Resume</strong> — Re-opens the run exactly where it stopped: same direction, scopes, deletion flag, auto-continue, conflict strategy, and the same chunk selection, with cursor/page restored.</Typography></li>
|
|
659
|
+
</ul>
|
|
660
|
+
</HelpSection>
|
|
661
|
+
|
|
662
|
+
<HelpSection title="Previous Runs & History">
|
|
663
|
+
<Typography variant="omega">
|
|
664
|
+
Every bulk run — completed, paused, cancelled, or failed — is recorded in the
|
|
665
|
+
<strong> Previous Runs</strong> sub-tab. History is persisted in the plugin store (not just
|
|
666
|
+
in memory), with serialized writes so concurrent updates from the polling loop and control
|
|
667
|
+
actions never clobber each other.
|
|
668
|
+
</Typography>
|
|
669
|
+
<ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
|
|
670
|
+
<li><Typography variant="omega"><strong>Expand</strong> a row to inspect per-chunk status, page progress, record counts, and error messages for that run.</Typography></li>
|
|
671
|
+
<li><Typography variant="omega"><strong>Restart</strong> — Re-runs the same configuration from scratch (fresh cursor) while keeping the original entry in history.</Typography></li>
|
|
672
|
+
<li><Typography variant="omega"><strong>Resume</strong> — For paused / cancelled / incomplete runs, rehydrates the Run Transfer tab with the exact prior state (direction, scopes, options, chunk selection) and continues from the saved cursor and page.</Typography></li>
|
|
673
|
+
<li><Typography variant="omega"><strong>Clear history</strong> — Removes the stored history entries.</Typography></li>
|
|
674
|
+
</ul>
|
|
675
|
+
<Typography variant="omega" paddingTop={2}>
|
|
676
|
+
Each run is also recorded as a Stats report with <code>runType = bulk_transfer</code>, so the
|
|
677
|
+
before/after snapshots are available in the Stats tab.
|
|
678
|
+
</Typography>
|
|
679
|
+
</HelpSection>
|
|
680
|
+
|
|
681
|
+
<HelpSection title="Bulk Transfer vs Sync">
|
|
682
|
+
<ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
|
|
683
|
+
<li><Typography variant="omega"><strong>Sync</strong> is <em>profile-driven</em>: on-demand, scheduled, or live; intended for ongoing incremental synchronization with field-level policies.</Typography></li>
|
|
684
|
+
<li><Typography variant="omega"><strong>Bulk Transfer</strong> is <em>operation-driven</em>: an explicit full pull/push, chunked and resumable, intended for migrations, backfills, and full refreshes.</Typography></li>
|
|
685
|
+
</ul>
|
|
686
|
+
<Typography variant="pi" textColor="warning600" paddingTop={2}>
|
|
687
|
+
Note: The active in-memory job registry is cleared on Strapi restart, but the run history
|
|
688
|
+
(and its saved cursor/page/selection) is persisted — so after a restart you can open Previous
|
|
689
|
+
Runs and <strong>Resume</strong> the interrupted job.
|
|
690
|
+
</Typography>
|
|
691
|
+
</HelpSection>
|
|
692
|
+
</Box>
|
|
693
|
+
</Tabs.Content>
|
|
694
|
+
|
|
695
|
+
{/* Media Tab */}
|
|
587
696
|
<Tabs.Content value="media">
|
|
588
697
|
<Box paddingTop={4}>
|
|
589
698
|
<HelpSection title="Pagination for Large Datasets">
|
|
@@ -731,6 +840,40 @@ http://localhost:1337</CodeBlock>
|
|
|
731
840
|
</Typography>
|
|
732
841
|
</HelpSection>
|
|
733
842
|
|
|
843
|
+
<HelpSection title="Live status, Pause, Resume, and Stop">
|
|
844
|
+
<Typography variant="omega">
|
|
845
|
+
Media sync runs can take a long time. The Media tab shows live state for any profile
|
|
846
|
+
that is currently running or paused and automatically polls status every 2 seconds
|
|
847
|
+
while work is in progress, so you can navigate away and come back without losing
|
|
848
|
+
visibility. The Status sub-tab shows the active phase (for example
|
|
849
|
+
<code> listing</code>, <code>pushing</code>, <code>pulling</code>) and live counters
|
|
850
|
+
for <code>pushed</code>, <code>pulled</code>, <code>skipped</code>, and
|
|
851
|
+
<code> errors</code>.
|
|
852
|
+
</Typography>
|
|
853
|
+
<ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
|
|
854
|
+
<li><Typography variant="omega"><strong>Run</strong> - starts the profile as a background job; the UI flips to Running immediately and keeps polling.</Typography></li>
|
|
855
|
+
<li><Typography variant="omega"><strong>Pause</strong> - requests a cooperative halt at the next checkpoint (between pages/batches). Already in-flight file transfers finish first.</Typography></li>
|
|
856
|
+
<li><Typography variant="omega"><strong>Resume</strong> - continues a paused run from where it stopped without redoing completed work.</Typography></li>
|
|
857
|
+
<li><Typography variant="omega"><strong>Stop</strong> - cancels the run at the next checkpoint. Work already synced is kept; remaining items are skipped.</Typography></li>
|
|
858
|
+
</ul>
|
|
859
|
+
<Typography variant="omega" paddingTop={2}>
|
|
860
|
+
Corresponding endpoints:
|
|
861
|
+
</Typography>
|
|
862
|
+
<ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
|
|
863
|
+
<li><code>POST /api/strapi-content-sync-pro/media-sync/profiles/:id/pause</code></li>
|
|
864
|
+
<li><code>POST /api/strapi-content-sync-pro/media-sync/profiles/:id/resume</code></li>
|
|
865
|
+
<li><code>POST /api/strapi-content-sync-pro/media-sync/profiles/:id/cancel</code></li>
|
|
866
|
+
<li><code>GET /api/strapi-content-sync-pro/media-sync/status</code> (returns <code>running</code>, <code>paused</code>, and per-profile <code>progress</code>)</li>
|
|
867
|
+
</ul>
|
|
868
|
+
<Typography variant="pi" textColor="warning600" paddingTop={2}>
|
|
869
|
+
<strong>Note:</strong> Pause/Resume/Stop are implemented for the <strong>URL</strong>
|
|
870
|
+
strategy, which is the default and most common choice. The <strong>rsync</strong>
|
|
871
|
+
strategy runs as a single child process and cannot be paused mid-flight; for rsync
|
|
872
|
+
profiles these controls are a no-op until the current rsync invocation finishes on
|
|
873
|
+
its own.
|
|
874
|
+
</Typography>
|
|
875
|
+
</HelpSection>
|
|
876
|
+
|
|
734
877
|
<HelpSection title="Dry run & testing">
|
|
735
878
|
<Typography variant="omega">
|
|
736
879
|
Toggle <strong>Dry run</strong> on a profile to list what would change without transferring any
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
Dialog,
|
|
20
20
|
IconButton,
|
|
21
21
|
} from '@strapi/design-system';
|
|
22
|
-
import { Pencil, Trash, Play, Check } from '@strapi/icons';
|
|
22
|
+
import { Pencil, Trash, Play, Check, Stop } from '@strapi/icons';
|
|
23
23
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
24
24
|
|
|
25
25
|
const PLUGIN_ID = 'strapi-content-sync-pro';
|
|
@@ -122,8 +122,25 @@ const MediaTab = () => {
|
|
|
122
122
|
}
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
+
const refreshStatus = async () => {
|
|
126
|
+
try {
|
|
127
|
+
const sRes = await get(`/${PLUGIN_ID}/media-sync/status`);
|
|
128
|
+
setStatus(sRes.data.data || {});
|
|
129
|
+
} catch {
|
|
130
|
+
/* silent — polling should not spam errors */
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
125
134
|
useEffect(() => { reload(); }, []);
|
|
126
135
|
|
|
136
|
+
// Live status polling: while anything is running or paused, poll every 2s.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const anyActive = (status?.profiles || []).some((p) => p.running || p.paused);
|
|
139
|
+
if (!anyActive) return undefined;
|
|
140
|
+
const id = setInterval(refreshStatus, 2000);
|
|
141
|
+
return () => clearInterval(id);
|
|
142
|
+
}, [status]);
|
|
143
|
+
|
|
127
144
|
const handleSaveGlobal = async () => {
|
|
128
145
|
setSaving(true); setMessage(null);
|
|
129
146
|
try {
|
|
@@ -174,25 +191,67 @@ const MediaTab = () => {
|
|
|
174
191
|
};
|
|
175
192
|
|
|
176
193
|
const handleRunProfile = async (id) => {
|
|
177
|
-
|
|
194
|
+
setMessage(null);
|
|
195
|
+
// Fire-and-forget: don't await — the sync may run for a long time.
|
|
196
|
+
// Poll status to reflect progress and completion.
|
|
197
|
+
post(`/${PLUGIN_ID}/media-sync/profiles/${id}/run`, {})
|
|
198
|
+
.then(() => {
|
|
199
|
+
setMessage({ type: 'success', text: 'Media sync complete.' });
|
|
200
|
+
refreshStatus();
|
|
201
|
+
})
|
|
202
|
+
.catch((err) => {
|
|
203
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
|
|
204
|
+
refreshStatus();
|
|
205
|
+
});
|
|
206
|
+
setMessage({ type: 'success', text: 'Media sync started. You can pause or stop it from the Status tab.' });
|
|
207
|
+
// Kick an immediate status refresh so the UI flips to Running right away.
|
|
208
|
+
setTimeout(refreshStatus, 500);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const handleRunAll = async () => {
|
|
212
|
+
setMessage(null);
|
|
213
|
+
post(`/${PLUGIN_ID}/media-sync/run-active`, {})
|
|
214
|
+
.then(() => {
|
|
215
|
+
setMessage({ type: 'success', text: 'All active media profiles synced.' });
|
|
216
|
+
refreshStatus();
|
|
217
|
+
})
|
|
218
|
+
.catch((err) => {
|
|
219
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
|
|
220
|
+
refreshStatus();
|
|
221
|
+
});
|
|
222
|
+
setMessage({ type: 'success', text: 'Sync All started. Watch progress in the Status tab.' });
|
|
223
|
+
setTimeout(refreshStatus, 500);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const handlePauseProfile = async (id) => {
|
|
178
227
|
try {
|
|
179
|
-
await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/
|
|
180
|
-
setMessage({ type: 'success', text: '
|
|
181
|
-
|
|
228
|
+
await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/pause`, {});
|
|
229
|
+
setMessage({ type: 'success', text: 'Pause requested. The run will halt at the next checkpoint.' });
|
|
230
|
+
refreshStatus();
|
|
182
231
|
} catch (err) {
|
|
183
232
|
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
|
|
184
|
-
}
|
|
233
|
+
}
|
|
185
234
|
};
|
|
186
235
|
|
|
187
|
-
const
|
|
188
|
-
setRunning(true); setMessage(null);
|
|
236
|
+
const handleResumeProfile = async (id) => {
|
|
189
237
|
try {
|
|
190
|
-
await post(`/${PLUGIN_ID}/media-sync/
|
|
191
|
-
setMessage({ type: 'success', text: '
|
|
192
|
-
|
|
238
|
+
await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/resume`, {});
|
|
239
|
+
setMessage({ type: 'success', text: 'Run resumed.' });
|
|
240
|
+
refreshStatus();
|
|
193
241
|
} catch (err) {
|
|
194
242
|
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
|
|
195
|
-
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const handleCancelProfile = async (id) => {
|
|
247
|
+
if (!confirm('Stop this media profile run? Progress already done is kept, remaining work is aborted.')) return;
|
|
248
|
+
try {
|
|
249
|
+
await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/cancel`, {});
|
|
250
|
+
setMessage({ type: 'success', text: 'Stop requested.' });
|
|
251
|
+
refreshStatus();
|
|
252
|
+
} catch (err) {
|
|
253
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
|
|
254
|
+
}
|
|
196
255
|
};
|
|
197
256
|
|
|
198
257
|
const handleTest = async () => {
|
|
@@ -245,7 +304,7 @@ const MediaTab = () => {
|
|
|
245
304
|
<Typography variant="delta">Media Sync Profiles</Typography>
|
|
246
305
|
<Flex gap={2}>
|
|
247
306
|
<Button variant="secondary" onClick={handleTest} loading={testing} disabled={testing}>Test connection</Button>
|
|
248
|
-
<Button variant="secondary" onClick={handleRunAll}
|
|
307
|
+
<Button variant="secondary" onClick={handleRunAll} disabled={!!status?.running}>Sync All Active</Button>
|
|
249
308
|
<Button onClick={() => { setEditProfile({ ...EMPTY_PROFILE, includeMime: defaults?.mimeAll || [] }); setEditMode('create'); }}>
|
|
250
309
|
Create Profile
|
|
251
310
|
</Button>
|
|
@@ -289,9 +348,30 @@ const MediaTab = () => {
|
|
|
289
348
|
{!p.active && (
|
|
290
349
|
<Button variant="tertiary" size="S" onClick={() => handleActivate(p.id)} startIcon={<Check />}>Activate</Button>
|
|
291
350
|
)}
|
|
292
|
-
{p.active && (
|
|
293
|
-
|
|
294
|
-
|
|
351
|
+
{p.active && (() => {
|
|
352
|
+
const sp = (status?.profiles || []).find((x) => x.id === p.id);
|
|
353
|
+
const isRunning = !!sp?.running;
|
|
354
|
+
const isPaused = !!sp?.paused;
|
|
355
|
+
if (isRunning && !isPaused) {
|
|
356
|
+
return (
|
|
357
|
+
<>
|
|
358
|
+
<Button variant="tertiary" size="S" onClick={() => handlePauseProfile(p.id)}>Pause</Button>
|
|
359
|
+
<IconButton label="Stop" onClick={() => handleCancelProfile(p.id)}><Stop /></IconButton>
|
|
360
|
+
</>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (isPaused) {
|
|
364
|
+
return (
|
|
365
|
+
<>
|
|
366
|
+
<Button variant="secondary" size="S" onClick={() => handleResumeProfile(p.id)} startIcon={<Play />}>Resume</Button>
|
|
367
|
+
<IconButton label="Stop" onClick={() => handleCancelProfile(p.id)}><Stop /></IconButton>
|
|
368
|
+
</>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return (
|
|
372
|
+
<Button variant="secondary" size="S" onClick={() => handleRunProfile(p.id)} startIcon={<Play />}>Run</Button>
|
|
373
|
+
);
|
|
374
|
+
})()}
|
|
295
375
|
<IconButton label="Edit" onClick={() => { setEditProfile({ ...p }); setEditMode('edit'); }}><Pencil /></IconButton>
|
|
296
376
|
<IconButton label="Delete" onClick={() => handleDelete(p.id)}><Trash /></IconButton>
|
|
297
377
|
</Flex>
|
|
@@ -382,21 +462,52 @@ const MediaTab = () => {
|
|
|
382
462
|
{/* ── Status Tab ──────────────────────────────────────────────── */}
|
|
383
463
|
<Tabs.Content value="status">
|
|
384
464
|
<Box paddingTop={4}>
|
|
385
|
-
<
|
|
386
|
-
|
|
387
|
-
<
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
465
|
+
<Flex justifyContent="space-between" alignItems="center" paddingBottom={3}>
|
|
466
|
+
<Typography variant="delta">Media Sync Status</Typography>
|
|
467
|
+
<Button variant="tertiary" size="S" onClick={refreshStatus}>Refresh</Button>
|
|
468
|
+
</Flex>
|
|
469
|
+
{status?.profiles?.map((sp) => {
|
|
470
|
+
const prog = sp.progress || null;
|
|
471
|
+
const stateLabel = sp.paused ? 'Paused' : sp.running ? 'Running' : 'Idle';
|
|
472
|
+
return (
|
|
473
|
+
<Box key={sp.id} background="neutral0" padding={3} hasRadius shadow="tableShadow" marginBottom={2}>
|
|
474
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
475
|
+
<Flex gap={2} alignItems="center">
|
|
476
|
+
<Typography variant="omega" fontWeight="bold">{sp.name}</Typography>
|
|
477
|
+
{sp.active && <Badge active>Active</Badge>}
|
|
478
|
+
<Badge>{stateLabel}</Badge>
|
|
479
|
+
{prog?.phase && (sp.running || sp.paused) && (
|
|
480
|
+
<Typography variant="pi" textColor="neutral600">phase: {prog.phase}</Typography>
|
|
481
|
+
)}
|
|
482
|
+
</Flex>
|
|
483
|
+
<Flex gap={1} alignItems="center">
|
|
484
|
+
{sp.running && !sp.paused && (
|
|
485
|
+
<>
|
|
486
|
+
<Button variant="tertiary" size="S" onClick={() => handlePauseProfile(sp.id)}>Pause</Button>
|
|
487
|
+
<Button variant="danger-light" size="S" startIcon={<Stop />} onClick={() => handleCancelProfile(sp.id)}>Stop</Button>
|
|
488
|
+
</>
|
|
489
|
+
)}
|
|
490
|
+
{sp.paused && (
|
|
491
|
+
<>
|
|
492
|
+
<Button variant="secondary" size="S" startIcon={<Play />} onClick={() => handleResumeProfile(sp.id)}>Resume</Button>
|
|
493
|
+
<Button variant="danger-light" size="S" startIcon={<Stop />} onClick={() => handleCancelProfile(sp.id)}>Stop</Button>
|
|
494
|
+
</>
|
|
495
|
+
)}
|
|
496
|
+
<Typography variant="pi" textColor="neutral600" paddingLeft={2}>
|
|
497
|
+
Mode: {(sp.executionMode || '').replace('_', ' ')} | Last: {sp.lastExecutedAt ? new Date(sp.lastExecutedAt).toLocaleString() : 'never'}
|
|
498
|
+
</Typography>
|
|
499
|
+
</Flex>
|
|
393
500
|
</Flex>
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
501
|
+
{(sp.running || sp.paused) && prog && (
|
|
502
|
+
<Box paddingTop={2}>
|
|
503
|
+
<Typography variant="pi" textColor="neutral700">
|
|
504
|
+
pushed: {prog.pushed || 0} · pulled: {prog.pulled || 0} · skipped: {prog.skipped || 0} · errors: {prog.errors || 0}
|
|
505
|
+
</Typography>
|
|
506
|
+
</Box>
|
|
507
|
+
)}
|
|
508
|
+
</Box>
|
|
509
|
+
);
|
|
510
|
+
})}
|
|
400
511
|
{status?.lastResult && (
|
|
401
512
|
<Box paddingTop={3} background="neutral0" padding={4} hasRadius shadow="tableShadow">
|
|
402
513
|
<Typography variant="sigma">Last Run Result</Typography>
|
|
@@ -10,12 +10,14 @@ import { StatsTab } from '../../components/StatsTab';
|
|
|
10
10
|
import { HelpTab } from '../../components/HelpTab';
|
|
11
11
|
import { SyncProfilesTab } from '../../components/SyncProfilesTab';
|
|
12
12
|
import { MediaTab } from '../../components/MediaTab';
|
|
13
|
+
import BulkTransferTab from '../../components/BulkTransferTab';
|
|
13
14
|
|
|
14
15
|
const TABS = [
|
|
15
16
|
{ key: 'config', label: 'Configuration' },
|
|
16
17
|
{ key: 'content-types', label: 'Content Types' },
|
|
17
18
|
{ key: 'sync-profiles', label: 'Sync Profiles' },
|
|
18
19
|
{ key: 'sync', label: 'Sync' },
|
|
20
|
+
{ key: 'bulk-transfer', label: 'Bulk Transfer' },
|
|
19
21
|
{ key: 'media', label: 'Media' },
|
|
20
22
|
{ key: 'stats', label: 'Stats' },
|
|
21
23
|
{ key: 'logs', label: 'Logs' },
|
|
@@ -29,8 +31,16 @@ const HomePage = () => {
|
|
|
29
31
|
<Main>
|
|
30
32
|
<Box padding={8} background="neutral100">
|
|
31
33
|
<Typography variant="alpha" tag="h1">
|
|
32
|
-
Content Sync Pro Plugin -
|
|
34
|
+
Content Sync Pro Plugin - Bulk Data Transfer, Live Sync, and Automated Content Replication between Strapi instances
|
|
33
35
|
</Typography>
|
|
36
|
+
<Box paddingTop={2}>
|
|
37
|
+
<Typography variant="omega" textColor="neutral600">
|
|
38
|
+
Copy, migrate, and keep content, media, users, and relations in sync across environments.
|
|
39
|
+
Run full one-click Bulk Transfers with selectable chunks, page-level progress, pause /
|
|
40
|
+
resume / cancel, and persisted run history — alongside profile-driven bi-directional sync,
|
|
41
|
+
field-level policies, scheduling, live hooks, and alerts.
|
|
42
|
+
</Typography>
|
|
43
|
+
</Box>
|
|
34
44
|
|
|
35
45
|
|
|
36
46
|
<Box paddingTop={4} paddingBottom={6}>
|
|
@@ -51,6 +61,7 @@ const HomePage = () => {
|
|
|
51
61
|
{activeTab === 'content-types' && <ContentTypesTab />}
|
|
52
62
|
{activeTab === 'sync-profiles' && <SyncProfilesTab />}
|
|
53
63
|
{activeTab === 'sync' && <SyncTab />}
|
|
64
|
+
{activeTab === 'bulk-transfer' && <BulkTransferTab />}
|
|
54
65
|
{activeTab === 'media' && <MediaTab />}
|
|
55
66
|
{activeTab === 'stats' && <StatsTab />}
|
|
56
67
|
{activeTab === 'logs' && <LogsTab />}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|