strapi-content-sync-pro 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +67 -18
  2. package/admin/src/components/BulkTransferTab.jsx +880 -0
  3. package/admin/src/components/ConfigTab.jsx +25 -4
  4. package/admin/src/components/HelpTab.jsx +201 -15
  5. package/admin/src/components/MediaTab.jsx +7 -0
  6. package/admin/src/components/StatsTab.jsx +470 -0
  7. package/admin/src/components/SyncProfilesTab.jsx +63 -5
  8. package/admin/src/components/SyncTab.jsx +53 -7
  9. package/admin/src/pages/App/index.jsx +15 -1
  10. package/docs/clipchamp-screen-recording-script.md +0 -0
  11. package/docs/production-readiness-status.md +34 -0
  12. package/docs/production-readiness-test-matrix.md +151 -0
  13. package/docs/test-environments-setup-legacy.txt +60 -0
  14. package/package.json +13 -4
  15. package/server/src/content-types/index.js +2 -0
  16. package/server/src/content-types/sync-run-report/schema.json +26 -0
  17. package/server/src/controllers/bulk-transfer.js +141 -0
  18. package/server/src/controllers/config.js +48 -5
  19. package/server/src/controllers/index.js +4 -0
  20. package/server/src/controllers/sync-log.js +6 -0
  21. package/server/src/controllers/sync-media.js +19 -0
  22. package/server/src/controllers/sync-stats.js +51 -0
  23. package/server/src/controllers/sync.js +9 -3
  24. package/server/src/routes/index.js +28 -0
  25. package/server/src/services/bulk-transfer.js +837 -0
  26. package/server/src/services/config.js +18 -2
  27. package/server/src/services/index.js +4 -0
  28. package/server/src/services/sync-execution.js +102 -5
  29. package/server/src/services/sync-log.js +36 -0
  30. package/server/src/services/sync-media.js +224 -1
  31. package/server/src/services/sync-profiles.js +92 -4
  32. package/server/src/services/sync-stats.js +353 -0
  33. package/server/src/services/sync.js +323 -101
  34. package/server/src/utils/applier.js +120 -13
  35. package/server/src/utils/comparator.js +22 -6
  36. package/server/src/utils/fetcher.js +11 -2
@@ -0,0 +1,470 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ Box,
4
+ Typography,
5
+ Flex,
6
+ Button,
7
+ Table,
8
+ Thead,
9
+ Tbody,
10
+ Tr,
11
+ Th,
12
+ Td,
13
+ Alert,
14
+ NumberInput,
15
+ Field,
16
+ Searchbar,
17
+ SingleSelect,
18
+ SingleSelectOption,
19
+ Tabs,
20
+ } from '@strapi/design-system';
21
+ import { useFetchClient } from '@strapi/strapi/admin';
22
+
23
+ const PLUGIN_ID = 'strapi-content-sync-pro';
24
+ const SNAPSHOT_PAGE_SIZES = [10, 25, 50, 100];
25
+ const REPORTS_PAGE_SIZES = [5, 10, 20, 50];
26
+ const REPORT_ROW_PAGE_SIZE = 25;
27
+
28
+ const StatsTab = () => {
29
+ const { get, post, put } = useFetchClient();
30
+ const [snapshot, setSnapshot] = useState(null);
31
+ const [reports, setReports] = useState([]);
32
+ const [reportMeta, setReportMeta] = useState(null);
33
+ const [loading, setLoading] = useState(true);
34
+ const [message, setMessage] = useState(null);
35
+ const [retention, setRetention] = useState({ maxLogEntries: 2000, maxReportEntries: 200 });
36
+
37
+ // Snapshot filters / pagination
38
+ const [snapshotSearch, setSnapshotSearch] = useState('');
39
+ const [snapshotTypeFilter, setSnapshotTypeFilter] = useState('all'); // all | content | media | media_morph
40
+ const [snapshotSideFilter, setSnapshotSideFilter] = useState('all'); // all | local | remote | equal
41
+ const [snapshotPage, setSnapshotPage] = useState(1);
42
+ const [snapshotPageSize, setSnapshotPageSize] = useState(25);
43
+
44
+ // Reports filters / pagination
45
+ const [reportsPage, setReportsPage] = useState(1);
46
+ const [reportsPageSize, setReportsPageSize] = useState(10);
47
+ const [reportsStatusFilter, setReportsStatusFilter] = useState('all'); // all | success | failed
48
+ const [expandedReport, setExpandedReport] = useState(null);
49
+
50
+ const loadData = async () => {
51
+ setLoading(true);
52
+ try {
53
+ const [snapshotRes, reportsRes, globalRes] = await Promise.all([
54
+ get(`/${PLUGIN_ID}/stats/snapshot`),
55
+ get(`/${PLUGIN_ID}/stats/reports?page=${reportsPage}&pageSize=${reportsPageSize}`),
56
+ get(`/${PLUGIN_ID}/sync-execution/global-settings`),
57
+ ]);
58
+ setSnapshot(snapshotRes?.data?.data || null);
59
+ setReports(reportsRes?.data?.data || []);
60
+ setReportMeta(reportsRes?.data?.meta || null);
61
+ const g = globalRes?.data?.data || {};
62
+ setRetention({
63
+ maxLogEntries: g.maxLogEntries || 2000,
64
+ maxReportEntries: g.maxReportEntries || 200,
65
+ });
66
+ } catch (err) {
67
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to load stats' });
68
+ } finally {
69
+ setLoading(false);
70
+ }
71
+ };
72
+
73
+ useEffect(() => {
74
+ loadData();
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [reportsPage, reportsPageSize]);
77
+
78
+ // Filter + paginate snapshot rows client-side
79
+ const filteredSnapshotRows = useMemo(() => {
80
+ const rows = snapshot?.rows || [];
81
+ const q = snapshotSearch.trim().toLowerCase();
82
+ return rows.filter((row) => {
83
+ if (snapshotTypeFilter !== 'all') {
84
+ const t = row.type || 'content';
85
+ if (t !== snapshotTypeFilter) return false;
86
+ }
87
+ if (snapshotSideFilter !== 'all') {
88
+ const side = (row.newestSide || '').toLowerCase();
89
+ if (side !== snapshotSideFilter) return false;
90
+ }
91
+ if (q && !String(row.uid || '').toLowerCase().includes(q)) return false;
92
+ return true;
93
+ });
94
+ }, [snapshot, snapshotSearch, snapshotTypeFilter, snapshotSideFilter]);
95
+
96
+ const snapshotTotalPages = Math.max(1, Math.ceil(filteredSnapshotRows.length / snapshotPageSize));
97
+ const pagedSnapshotRows = useMemo(() => {
98
+ const start = (snapshotPage - 1) * snapshotPageSize;
99
+ return filteredSnapshotRows.slice(start, start + snapshotPageSize);
100
+ }, [filteredSnapshotRows, snapshotPage, snapshotPageSize]);
101
+
102
+ useEffect(() => {
103
+ if (snapshotPage > snapshotTotalPages) setSnapshotPage(1);
104
+ }, [snapshotTotalPages, snapshotPage]);
105
+
106
+ const filteredReports = useMemo(() => {
107
+ if (reportsStatusFilter === 'all') return reports;
108
+ return reports.filter((r) => (r.status || '').toLowerCase() === reportsStatusFilter);
109
+ }, [reports, reportsStatusFilter]);
110
+
111
+ const handleClearLogs = async () => {
112
+ try {
113
+ await post(`/${PLUGIN_ID}/logs/clear`);
114
+ setMessage({ type: 'success', text: 'Logs cleared successfully' });
115
+ await loadData();
116
+ } catch (err) {
117
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to clear logs' });
118
+ }
119
+ };
120
+
121
+ const handleClearReports = async () => {
122
+ try {
123
+ await post(`/${PLUGIN_ID}/stats/reports/clear`);
124
+ setMessage({ type: 'success', text: 'Stats reports cleared successfully' });
125
+ await loadData();
126
+ } catch (err) {
127
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to clear reports' });
128
+ }
129
+ };
130
+
131
+ const handleSaveRetention = async () => {
132
+ try {
133
+ await put(`/${PLUGIN_ID}/sync-execution/global-settings`, {
134
+ maxLogEntries: Number(retention.maxLogEntries),
135
+ maxReportEntries: Number(retention.maxReportEntries),
136
+ });
137
+ await post(`/${PLUGIN_ID}/stats/retention/run`, {
138
+ maxLogs: Number(retention.maxLogEntries),
139
+ maxReports: Number(retention.maxReportEntries),
140
+ });
141
+ setMessage({ type: 'success', text: 'Retention saved and applied' });
142
+ await loadData();
143
+ } catch (err) {
144
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to save retention' });
145
+ }
146
+ };
147
+
148
+ const renderRows = (rows = []) => rows.map((row) => (
149
+ <Tr key={`${row.uid}-${row.type || 'content'}`}>
150
+ <Td>
151
+ <Typography>{row.uid}</Typography>
152
+ {row.type && row.type !== 'content' && (
153
+ <Typography variant="pi" textColor="neutral500">{row.type}</Typography>
154
+ )}
155
+ </Td>
156
+ <Td><Typography>{row.localCount ?? '—'}</Typography></Td>
157
+ <Td><Typography>{row.remoteCount ?? '—'}</Typography></Td>
158
+ <Td><Typography>{row.localNewestUpdatedAt ? new Date(row.localNewestUpdatedAt).toLocaleString() : '—'}</Typography></Td>
159
+ <Td><Typography>{row.remoteNewestUpdatedAt ? new Date(row.remoteNewestUpdatedAt).toLocaleString() : '—'}</Typography></Td>
160
+ <Td><Typography>{row.newestSide || '—'}</Typography></Td>
161
+ </Tr>
162
+ ));
163
+
164
+ if (loading) return <Typography>Loading…</Typography>;
165
+
166
+ return (
167
+ <Box>
168
+ <Typography variant="beta" tag="h2">Data Stats & Reports</Typography>
169
+ <Typography variant="omega" textColor="neutral600">
170
+ Review local vs remote counts and newest timestamps, with before/after snapshots per sync run.
171
+ </Typography>
172
+
173
+ {message && (
174
+ <Box paddingTop={4}>
175
+ <Alert variant={message.type} closeLabel="Close" onClose={() => setMessage(null)}>
176
+ {message.text}
177
+ </Alert>
178
+ </Box>
179
+ )}
180
+
181
+ <Box paddingTop={4}>
182
+ <Flex justifyContent="space-between" alignItems="flex-end" gap={4} wrap="wrap">
183
+ <Flex gap={2}>
184
+ <Button variant="secondary" onClick={loadData}>Refresh Stats</Button>
185
+ <Button variant="danger-light" onClick={handleClearLogs}>Clear Logs</Button>
186
+ <Button variant="danger-light" onClick={handleClearReports}>Clear Stats Reports</Button>
187
+ </Flex>
188
+ <Flex gap={2} alignItems="flex-end" wrap="wrap">
189
+ <Box style={{ width: 140 }}>
190
+ <Field.Root>
191
+ <Field.Label>Max Logs</Field.Label>
192
+ <NumberInput
193
+ value={retention.maxLogEntries}
194
+ onValueChange={(v) => setRetention((p) => ({ ...p, maxLogEntries: v }))}
195
+ min={100}
196
+ />
197
+ </Field.Root>
198
+ </Box>
199
+ <Box style={{ width: 140 }}>
200
+ <Field.Root>
201
+ <Field.Label>Max Reports</Field.Label>
202
+ <NumberInput
203
+ value={retention.maxReportEntries}
204
+ onValueChange={(v) => setRetention((p) => ({ ...p, maxReportEntries: v }))}
205
+ min={10}
206
+ />
207
+ </Field.Root>
208
+ </Box>
209
+ <Button onClick={handleSaveRetention}>Save & Apply Retention</Button>
210
+ </Flex>
211
+ </Flex>
212
+ </Box>
213
+
214
+ <Box paddingTop={4}>
215
+ <Tabs.Root defaultValue="snapshot">
216
+ <Tabs.List>
217
+ <Tabs.Trigger value="snapshot">Current Snapshot</Tabs.Trigger>
218
+ <Tabs.Trigger value="reports">Run Reports</Tabs.Trigger>
219
+ </Tabs.List>
220
+
221
+ <Tabs.Content value="snapshot">
222
+ <Box paddingTop={4}>
223
+ <Flex justifyContent="flex-end" alignItems="flex-end" gap={4} wrap="wrap">
224
+ <Flex gap={2} alignItems="flex-end" wrap="wrap">
225
+ <Box style={{ width: 260 }}>
226
+ <Searchbar
227
+ name="snapshotSearch"
228
+ onClear={() => setSnapshotSearch('')}
229
+ value={snapshotSearch}
230
+ onChange={(e) => { setSnapshotSearch(e.target.value); setSnapshotPage(1); }}
231
+ clearLabel="Clear search"
232
+ placeholder="Filter by UID…"
233
+ >
234
+ Search
235
+ </Searchbar>
236
+ </Box>
237
+ <Box style={{ width: 160 }}>
238
+ <Field.Root>
239
+ <Field.Label>Type</Field.Label>
240
+ <SingleSelect
241
+ value={snapshotTypeFilter}
242
+ onChange={(v) => { setSnapshotTypeFilter(v); setSnapshotPage(1); }}
243
+ >
244
+ <SingleSelectOption value="all">All</SingleSelectOption>
245
+ <SingleSelectOption value="content">Content</SingleSelectOption>
246
+ <SingleSelectOption value="media">Media</SingleSelectOption>
247
+ <SingleSelectOption value="media_morph">Media Morph</SingleSelectOption>
248
+ </SingleSelect>
249
+ </Field.Root>
250
+ </Box>
251
+ <Box style={{ width: 160 }}>
252
+ <Field.Root>
253
+ <Field.Label>Newest side</Field.Label>
254
+ <SingleSelect
255
+ value={snapshotSideFilter}
256
+ onChange={(v) => { setSnapshotSideFilter(v); setSnapshotPage(1); }}
257
+ >
258
+ <SingleSelectOption value="all">All</SingleSelectOption>
259
+ <SingleSelectOption value="local">Local</SingleSelectOption>
260
+ <SingleSelectOption value="remote">Remote</SingleSelectOption>
261
+ <SingleSelectOption value="equal">Equal</SingleSelectOption>
262
+ </SingleSelect>
263
+ </Field.Root>
264
+ </Box>
265
+ <Box style={{ width: 120 }}>
266
+ <Field.Root>
267
+ <Field.Label>Page size</Field.Label>
268
+ <SingleSelect
269
+ value={String(snapshotPageSize)}
270
+ onChange={(v) => { setSnapshotPageSize(Number(v)); setSnapshotPage(1); }}
271
+ >
272
+ {SNAPSHOT_PAGE_SIZES.map((n) => (
273
+ <SingleSelectOption key={n} value={String(n)}>{n}</SingleSelectOption>
274
+ ))}
275
+ </SingleSelect>
276
+ </Field.Root>
277
+ </Box>
278
+ </Flex>
279
+ </Flex>
280
+
281
+ <Table>
282
+ <Thead>
283
+ <Tr>
284
+ <Th><Typography variant="sigma">Content Type</Typography></Th>
285
+ <Th><Typography variant="sigma">Local Count</Typography></Th>
286
+ <Th><Typography variant="sigma">Remote Count</Typography></Th>
287
+ <Th><Typography variant="sigma">Local Newest</Typography></Th>
288
+ <Th><Typography variant="sigma">Remote Newest</Typography></Th>
289
+ <Th><Typography variant="sigma">Newest Side</Typography></Th>
290
+ </Tr>
291
+ </Thead>
292
+ <Tbody>
293
+ {renderRows(pagedSnapshotRows)}
294
+ {pagedSnapshotRows.length === 0 && (
295
+ <Tr><Td colSpan={6}><Typography textColor="neutral500">No stats match the current filters.</Typography></Td></Tr>
296
+ )}
297
+ </Tbody>
298
+ </Table>
299
+
300
+ <Flex justifyContent="space-between" alignItems="center" paddingTop={2}>
301
+ <Typography variant="pi" textColor="neutral500">
302
+ Showing {pagedSnapshotRows.length} of {filteredSnapshotRows.length} rows
303
+ {snapshot?.rows ? ` (total ${snapshot.rows.length})` : ''}
304
+ </Typography>
305
+ <Flex gap={2} alignItems="center">
306
+ <Button variant="tertiary" disabled={snapshotPage <= 1} onClick={() => setSnapshotPage((p) => Math.max(1, p - 1))}>Previous</Button>
307
+ <Typography variant="pi">Page {snapshotPage} / {snapshotTotalPages}</Typography>
308
+ <Button variant="tertiary" disabled={snapshotPage >= snapshotTotalPages} onClick={() => setSnapshotPage((p) => Math.min(snapshotTotalPages, p + 1))}>Next</Button>
309
+ </Flex>
310
+ </Flex>
311
+ </Box>
312
+ </Tabs.Content>
313
+
314
+ <Tabs.Content value="reports">
315
+ <Box paddingTop={4}>
316
+ <Flex justifyContent="space-between" alignItems="flex-end" gap={4} wrap="wrap">
317
+ <Box>
318
+ <Typography variant="delta">Run Reports (Before vs After)</Typography>
319
+ <Typography variant="pi" textColor="neutral500" paddingTop={1}>
320
+ Showing {reports.length} on this page • {reportMeta?.pagination?.total || 0} total reports
321
+ </Typography>
322
+ </Box>
323
+ <Flex gap={2} alignItems="flex-end" wrap="wrap">
324
+ <Box style={{ width: 160 }}>
325
+ <Field.Root>
326
+ <Field.Label>Status</Field.Label>
327
+ <SingleSelect
328
+ value={reportsStatusFilter}
329
+ onChange={(v) => setReportsStatusFilter(v)}
330
+ >
331
+ <SingleSelectOption value="all">All</SingleSelectOption>
332
+ <SingleSelectOption value="success">Success</SingleSelectOption>
333
+ <SingleSelectOption value="failed">Failed</SingleSelectOption>
334
+ </SingleSelect>
335
+ </Field.Root>
336
+ </Box>
337
+ <Box style={{ width: 120 }}>
338
+ <Field.Root>
339
+ <Field.Label>Page size</Field.Label>
340
+ <SingleSelect
341
+ value={String(reportsPageSize)}
342
+ onChange={(v) => { setReportsPageSize(Number(v)); setReportsPage(1); }}
343
+ >
344
+ {REPORTS_PAGE_SIZES.map((n) => (
345
+ <SingleSelectOption key={n} value={String(n)}>{n}</SingleSelectOption>
346
+ ))}
347
+ </SingleSelect>
348
+ </Field.Root>
349
+ </Box>
350
+ </Flex>
351
+ </Flex>
352
+
353
+ {filteredReports.length === 0 && (
354
+ <Box paddingTop={3}>
355
+ <Typography textColor="neutral500">No reports match the current filter.</Typography>
356
+ </Box>
357
+ )}
358
+
359
+ {filteredReports.map((r, idx) => {
360
+ const reportKey = r.documentId || r.id || idx;
361
+ const isOpen = expandedReport === reportKey;
362
+ const beforeRows = r.beforeStats?.rows || [];
363
+ const afterRows = r.afterStats?.rows || [];
364
+ return (
365
+ <Box key={reportKey} background="neutral100" hasRadius padding={4} marginTop={3}>
366
+ <Flex justifyContent="space-between" alignItems="center" wrap="wrap" gap={2}>
367
+ <Box>
368
+ <Typography variant="omega" fontWeight="bold">
369
+ {r.runType || 'sync'} • {r.status || 'unknown'} • {r.startedAt ? new Date(r.startedAt).toLocaleString() : 'N/A'}
370
+ </Typography>
371
+ <Typography variant="pi" textColor="neutral600" paddingTop={1}>
372
+ Trigger: {r.trigger || 'manual'} • Before rows: {beforeRows.length} • After rows: {afterRows.length}
373
+ </Typography>
374
+ </Box>
375
+ <Button
376
+ variant="tertiary"
377
+ onClick={() => setExpandedReport(isOpen ? null : reportKey)}
378
+ >
379
+ {isOpen ? 'Hide details' : 'Show details'}
380
+ </Button>
381
+ </Flex>
382
+
383
+ {isOpen && (
384
+ <>
385
+ <Box paddingTop={3}>
386
+ <Typography variant="sigma">Before (first {Math.min(beforeRows.length, REPORT_ROW_PAGE_SIZE)} of {beforeRows.length})</Typography>
387
+ <Table>
388
+ <Thead>
389
+ <Tr>
390
+ <Th><Typography variant="sigma">Content Type</Typography></Th>
391
+ <Th><Typography variant="sigma">Local Count</Typography></Th>
392
+ <Th><Typography variant="sigma">Remote Count</Typography></Th>
393
+ <Th><Typography variant="sigma">Newest Side</Typography></Th>
394
+ </Tr>
395
+ </Thead>
396
+ <Tbody>
397
+ {beforeRows.slice(0, REPORT_ROW_PAGE_SIZE).map((row) => (
398
+ <Tr key={`before-${reportKey}-${row.uid}-${row.type || 'content'}`}>
399
+ <Td>
400
+ <Typography>{row.uid}</Typography>
401
+ {row.type && row.type !== 'content' && (
402
+ <Typography variant="pi" textColor="neutral500">{row.type}</Typography>
403
+ )}
404
+ </Td>
405
+ <Td><Typography>{row.localCount ?? '—'}</Typography></Td>
406
+ <Td><Typography>{row.remoteCount ?? '—'}</Typography></Td>
407
+ <Td><Typography>{row.newestSide || '—'}</Typography></Td>
408
+ </Tr>
409
+ ))}
410
+ </Tbody>
411
+ </Table>
412
+ </Box>
413
+
414
+ <Box paddingTop={3}>
415
+ <Typography variant="sigma">After (first {Math.min(afterRows.length, REPORT_ROW_PAGE_SIZE)} of {afterRows.length})</Typography>
416
+ <Table>
417
+ <Thead>
418
+ <Tr>
419
+ <Th><Typography variant="sigma">Content Type</Typography></Th>
420
+ <Th><Typography variant="sigma">Local Count</Typography></Th>
421
+ <Th><Typography variant="sigma">Remote Count</Typography></Th>
422
+ <Th><Typography variant="sigma">Newest Side</Typography></Th>
423
+ </Tr>
424
+ </Thead>
425
+ <Tbody>
426
+ {afterRows.slice(0, REPORT_ROW_PAGE_SIZE).map((row) => (
427
+ <Tr key={`after-${reportKey}-${row.uid}-${row.type || 'content'}`}>
428
+ <Td>
429
+ <Typography>{row.uid}</Typography>
430
+ {row.type && row.type !== 'content' && (
431
+ <Typography variant="pi" textColor="neutral500">{row.type}</Typography>
432
+ )}
433
+ </Td>
434
+ <Td><Typography>{row.localCount ?? '—'}</Typography></Td>
435
+ <Td><Typography>{row.remoteCount ?? '—'}</Typography></Td>
436
+ <Td><Typography>{row.newestSide || '—'}</Typography></Td>
437
+ </Tr>
438
+ ))}
439
+ </Tbody>
440
+ </Table>
441
+ </Box>
442
+ </>
443
+ )}
444
+ </Box>
445
+ );
446
+ })}
447
+
448
+ {(reportMeta?.pagination?.pageCount || 1) > 1 && (
449
+ <Flex justifyContent="flex-end" alignItems="center" gap={2} paddingTop={3}>
450
+ <Button variant="tertiary" disabled={reportsPage <= 1} onClick={() => setReportsPage((p) => Math.max(1, p - 1))}>Previous</Button>
451
+ <Typography variant="pi">Page {reportsPage} / {reportMeta?.pagination?.pageCount || 1}</Typography>
452
+ <Button
453
+ variant="tertiary"
454
+ disabled={reportsPage >= (reportMeta?.pagination?.pageCount || 1)}
455
+ onClick={() => setReportsPage((p) => p + 1)}
456
+ >
457
+ Next
458
+ </Button>
459
+ </Flex>
460
+ )}
461
+ </Box>
462
+ </Tabs.Content>
463
+ </Tabs.Root>
464
+ </Box>
465
+ </Box>
466
+ );
467
+ };
468
+
469
+ export { StatsTab };
470
+ export default StatsTab;
@@ -77,12 +77,14 @@ const SyncProfilesTab = () => {
77
77
  contentType: '',
78
78
  direction: 'both',
79
79
  conflictStrategy: 'latest',
80
+ syncDeletions: false,
80
81
  isActive: false,
81
82
  isSimple: true,
82
83
  fieldPolicies: [],
83
84
  });
84
85
  const [schemaFields, setSchemaFields] = useState([]);
85
86
  const [loadingSchema, setLoadingSchema] = useState(false);
87
+ const [syncMode, setSyncMode] = useState('paired');
86
88
 
87
89
  // Sorted profiles
88
90
  const sortedProfiles = useMemo(() => {
@@ -202,15 +204,17 @@ const SyncProfilesTab = () => {
202
204
 
203
205
  const loadData = async () => {
204
206
  try {
205
- const [profilesRes, ctRes, scRes] = await Promise.all([
207
+ const [profilesRes, ctRes, scRes, configRes] = await Promise.all([
206
208
  get(`/${PLUGIN_ID}/sync-profiles`),
207
209
  get(`/${PLUGIN_ID}/content-types`),
208
210
  get(`/${PLUGIN_ID}/sync-config`),
211
+ get(`/${PLUGIN_ID}/config`),
209
212
  ]);
210
213
  setProfiles(profilesRes.data.data || []);
211
214
  setContentTypes(ctRes.data.data || []);
212
215
  const config = scRes.data.data || { contentTypes: [] };
213
216
  setEnabledTypes(config.contentTypes?.filter(ct => ct.enabled).map(ct => ct.uid) || []);
217
+ setSyncMode(configRes?.data?.data?.syncMode || 'paired');
214
218
  } catch (err) {
215
219
  console.error('Failed to load data', err);
216
220
  setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message || 'Failed to load profiles' });
@@ -287,6 +291,7 @@ const SyncProfilesTab = () => {
287
291
  contentType: '',
288
292
  direction: 'both',
289
293
  conflictStrategy: 'latest',
294
+ syncDeletions: false,
290
295
  isActive: false,
291
296
  isSimple: true,
292
297
  fieldPolicies: [],
@@ -303,6 +308,7 @@ const SyncProfilesTab = () => {
303
308
  contentType: profile.contentType,
304
309
  direction: profile.direction || 'both',
305
310
  conflictStrategy: profile.conflictStrategy || 'latest',
311
+ syncDeletions: !!profile.syncDeletions,
306
312
  isActive: profile.isActive,
307
313
  isSimple: profile.isSimple !== false,
308
314
  fieldPolicies: profile.fieldPolicies || [],
@@ -321,9 +327,13 @@ const SyncProfilesTab = () => {
321
327
  bidirectional: { direction: 'both', conflictStrategy: 'latest' },
322
328
  };
323
329
  const config = presetConfig[preset] || {};
330
+ const modeAdjusted = syncMode === 'single_side'
331
+ ? { direction: 'pull', conflictStrategy: config.conflictStrategy || 'remote_wins' }
332
+ : config;
333
+
324
334
  setFormData((prev) => ({
325
335
  ...prev,
326
- ...config,
336
+ ...modeAdjusted,
327
337
  isSimple: true,
328
338
  }));
329
339
  };
@@ -347,6 +357,16 @@ const SyncProfilesTab = () => {
347
357
  isSimple: createMode === 'simple',
348
358
  };
349
359
 
360
+ if (syncMode === 'single_side') {
361
+ payload.direction = 'pull';
362
+ if (!payload.isSimple && Array.isArray(payload.fieldPolicies)) {
363
+ payload.fieldPolicies = payload.fieldPolicies.map((fp) => ({
364
+ ...fp,
365
+ direction: fp.direction === 'none' ? 'none' : 'pull',
366
+ }));
367
+ }
368
+ }
369
+
350
370
  if (editingProfile) {
351
371
  await put(`/${PLUGIN_ID}/sync-profiles/${editingProfile.id}`, payload);
352
372
  setMessage({ type: 'success', text: 'Profile updated successfully' });
@@ -412,6 +432,14 @@ const SyncProfilesTab = () => {
412
432
  </Button>
413
433
  </Flex>
414
434
 
435
+ {syncMode === 'single_side' && (
436
+ <Box paddingTop={4}>
437
+ <Alert variant="info" title="Single-side mode restrictions">
438
+ Profiles are pull-only in single-side mode. Push and bidirectional options are disabled and existing profiles are normalized to pull.
439
+ </Alert>
440
+ </Box>
441
+ )}
442
+
415
443
  {message && (
416
444
  <Box paddingTop={4}>
417
445
  <Alert variant={message.type} closeLabel="Close" onClose={() => setMessage(null)}>
@@ -484,7 +512,12 @@ const SyncProfilesTab = () => {
484
512
  </Td>
485
513
  <Td><Typography fontWeight="bold">{profile.name}</Typography></Td>
486
514
  <Td><Typography textColor="neutral600">{getContentTypeName(profile.contentType)}</Typography></Td>
487
- <Td><Badge>{getDirectionLabel(profile.direction)}</Badge></Td>
515
+ <Td>
516
+ <Flex gap={1} alignItems="center">
517
+ <Badge>{getDirectionLabel(profile.direction)}</Badge>
518
+ {profile.syncDeletions && <Badge active>Deletes</Badge>}
519
+ </Flex>
520
+ </Td>
488
521
  <Td><Badge>{profile.conflictStrategy}</Badge></Td>
489
522
  <Td>
490
523
  <Badge active={!profile.isSimple}>
@@ -539,6 +572,7 @@ const SyncProfilesTab = () => {
539
572
  {createMode === 'simple'
540
573
  ? 'Choose a preset and configure basic options.'
541
574
  : 'Configure individual field-level sync policies.'}
575
+ {syncMode === 'single_side' && ' Single-side mode allows pull-only settings.'}
542
576
  </Typography>
543
577
  </Box>
544
578
  </Box>
@@ -575,6 +609,7 @@ const SyncProfilesTab = () => {
575
609
  variant={selectedPreset === preset.value ? 'default' : 'tertiary'}
576
610
  onClick={() => handlePresetSelect(preset.value)}
577
611
  size="S"
612
+ disabled={syncMode === 'single_side' && preset.value !== 'full_pull'}
578
613
  >
579
614
  {preset.label}
580
615
  </Button>
@@ -604,7 +639,11 @@ const SyncProfilesTab = () => {
604
639
  onChange={(value) => setFormData((p) => ({ ...p, direction: value }))}
605
640
  >
606
641
  {DIRECTION_OPTIONS.map((opt) => (
607
- <SingleSelectOption key={opt.value} value={opt.value}>
642
+ <SingleSelectOption
643
+ key={opt.value}
644
+ value={opt.value}
645
+ disabled={syncMode === 'single_side' && opt.value !== 'pull'}
646
+ >
608
647
  {opt.label}
609
648
  </SingleSelectOption>
610
649
  ))}
@@ -630,6 +669,21 @@ const SyncProfilesTab = () => {
630
669
  </Field.Root>
631
670
  </Box>
632
671
 
672
+ {/* Deletions Toggle */}
673
+ <Box paddingBottom={4}>
674
+ <Checkbox
675
+ checked={formData.syncDeletions}
676
+ onCheckedChange={(checked) => setFormData((p) => ({ ...p, syncDeletions: checked }))}
677
+ >
678
+ Sync Deletions (exclusive)
679
+ </Checkbox>
680
+ <Box paddingTop={1}>
681
+ <Typography variant="pi" textColor="neutral500">
682
+ When enabled, missing records are treated as deletions and propagated one-way based on profile direction.
683
+ </Typography>
684
+ </Box>
685
+ </Box>
686
+
633
687
  {/* Active Checkbox */}
634
688
  <Box paddingBottom={4}>
635
689
  <Checkbox
@@ -683,7 +737,11 @@ const SyncProfilesTab = () => {
683
737
  size="S"
684
738
  >
685
739
  {FIELD_DIRECTION_OPTIONS.map((opt) => (
686
- <SingleSelectOption key={opt.value} value={opt.value}>
740
+ <SingleSelectOption
741
+ key={opt.value}
742
+ value={opt.value}
743
+ disabled={syncMode === 'single_side' && !['pull', 'none'].includes(opt.value)}
744
+ >
687
745
  {opt.label}
688
746
  </SingleSelectOption>
689
747
  ))}