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.
- package/README.md +67 -18
- package/admin/src/components/BulkTransferTab.jsx +880 -0
- package/admin/src/components/ConfigTab.jsx +25 -4
- package/admin/src/components/HelpTab.jsx +201 -15
- package/admin/src/components/MediaTab.jsx +7 -0
- package/admin/src/components/StatsTab.jsx +470 -0
- package/admin/src/components/SyncProfilesTab.jsx +63 -5
- package/admin/src/components/SyncTab.jsx +53 -7
- package/admin/src/pages/App/index.jsx +15 -1
- package/docs/clipchamp-screen-recording-script.md +0 -0
- package/docs/production-readiness-status.md +34 -0
- package/docs/production-readiness-test-matrix.md +151 -0
- package/docs/test-environments-setup-legacy.txt +60 -0
- package/package.json +13 -4
- package/server/src/content-types/index.js +2 -0
- package/server/src/content-types/sync-run-report/schema.json +26 -0
- package/server/src/controllers/bulk-transfer.js +141 -0
- package/server/src/controllers/config.js +48 -5
- package/server/src/controllers/index.js +4 -0
- package/server/src/controllers/sync-log.js +6 -0
- package/server/src/controllers/sync-media.js +19 -0
- package/server/src/controllers/sync-stats.js +51 -0
- package/server/src/controllers/sync.js +9 -3
- package/server/src/routes/index.js +28 -0
- package/server/src/services/bulk-transfer.js +837 -0
- package/server/src/services/config.js +18 -2
- package/server/src/services/index.js +4 -0
- package/server/src/services/sync-execution.js +102 -5
- package/server/src/services/sync-log.js +36 -0
- package/server/src/services/sync-media.js +224 -1
- package/server/src/services/sync-profiles.js +92 -4
- package/server/src/services/sync-stats.js +353 -0
- package/server/src/services/sync.js +323 -101
- package/server/src/utils/applier.js +120 -13
- package/server/src/utils/comparator.js +22 -6
- 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
|
-
...
|
|
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
|
|
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
|
|
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
|
|
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
|
))}
|