strapi-content-sync-pro 1.0.4 → 1.0.6
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/LICENSE +1 -1
- package/README.md +32 -14
- package/admin/src/components/BulkTransferTab.jsx +185 -20
- package/admin/src/components/ConfigTab.jsx +81 -3
- package/admin/src/components/ContentTypesTab.jsx +28 -1
- package/admin/src/components/HelpTab.jsx +34 -0
- package/admin/src/components/LogsTab.jsx +66 -8
- package/admin/src/components/MediaTab.jsx +253 -36
- package/admin/src/components/SyncProfilesTab.jsx +140 -4
- package/admin/src/components/SyncTab.jsx +161 -35
- 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/docs/sync-strategy-approach-review.md +127 -0
- package/package.json +1 -1
- package/server/src/controllers/config.js +76 -3
- package/server/src/controllers/sync-media.js +24 -0
- package/server/src/routes/index.js +3 -0
- package/server/src/services/bulk-transfer.js +45 -1
- package/server/src/services/dependency-resolver.js +37 -0
- package/server/src/services/sync-execution.js +21 -9
- package/server/src/services/sync-media.js +168 -32
- package/server/src/services/sync-profiles.js +36 -15
- package/server/src/services/sync.js +234 -134
- package/server/src/utils/fetcher.js +7 -0
- 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
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
Button,
|
|
7
7
|
SingleSelect,
|
|
8
8
|
SingleSelectOption,
|
|
9
|
+
TextInput,
|
|
9
10
|
Table,
|
|
10
11
|
Thead,
|
|
11
12
|
Tbody,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
Td,
|
|
14
15
|
Th,
|
|
15
16
|
} from '@strapi/design-system';
|
|
17
|
+
import { CaretUp, CaretDown } from '@strapi/icons';
|
|
16
18
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
17
19
|
|
|
18
20
|
const PLUGIN_ID = 'strapi-content-sync-pro';
|
|
@@ -24,6 +26,9 @@ const LogsTab = () => {
|
|
|
24
26
|
const [meta, setMeta] = useState(null);
|
|
25
27
|
const [page, setPage] = useState(1);
|
|
26
28
|
const [statusFilter, setStatusFilter] = useState('');
|
|
29
|
+
const [search, setSearch] = useState('');
|
|
30
|
+
const [sortField, setSortField] = useState('');
|
|
31
|
+
const [sortDir, setSortDir] = useState('asc');
|
|
27
32
|
const [loading, setLoading] = useState(true);
|
|
28
33
|
|
|
29
34
|
const fetchLogs = useCallback(async () => {
|
|
@@ -46,6 +51,48 @@ const LogsTab = () => {
|
|
|
46
51
|
fetchLogs();
|
|
47
52
|
}, [fetchLogs]);
|
|
48
53
|
|
|
54
|
+
const handleSort = (field) => {
|
|
55
|
+
if (sortField === field) {
|
|
56
|
+
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
57
|
+
} else {
|
|
58
|
+
setSortField(field);
|
|
59
|
+
setSortDir('asc');
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const displayedLogs = (() => {
|
|
64
|
+
let result = [...logs];
|
|
65
|
+
if (search.trim()) {
|
|
66
|
+
const q = search.trim().toLowerCase();
|
|
67
|
+
result = result.filter(
|
|
68
|
+
(l) =>
|
|
69
|
+
(l.action || '').toLowerCase().includes(q) ||
|
|
70
|
+
(l.contentType || '').toLowerCase().includes(q) ||
|
|
71
|
+
(l.message || '').toLowerCase().includes(q)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (sortField) {
|
|
75
|
+
result.sort((a, b) => {
|
|
76
|
+
const aVal = a[sortField] ?? '';
|
|
77
|
+
const bVal = b[sortField] ?? '';
|
|
78
|
+
if (typeof aVal === 'string') {
|
|
79
|
+
return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
80
|
+
}
|
|
81
|
+
return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
const SortableTh = ({ field, children }) => (
|
|
88
|
+
<Th onClick={() => handleSort(field)} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
|
89
|
+
<Flex alignItems="center" gap={1}>
|
|
90
|
+
<Typography variant="sigma">{children}</Typography>
|
|
91
|
+
{sortField === field && (sortDir === 'asc' ? <CaretUp /> : <CaretDown />)}
|
|
92
|
+
</Flex>
|
|
93
|
+
</Th>
|
|
94
|
+
);
|
|
95
|
+
|
|
49
96
|
return (
|
|
50
97
|
<Box>
|
|
51
98
|
<Flex justifyContent="space-between" alignItems="center">
|
|
@@ -65,19 +112,30 @@ const LogsTab = () => {
|
|
|
65
112
|
</Flex>
|
|
66
113
|
</Flex>
|
|
67
114
|
|
|
68
|
-
<Box paddingTop={
|
|
115
|
+
<Box paddingTop={3} paddingBottom={2}>
|
|
116
|
+
<TextInput
|
|
117
|
+
placeholder="Search action, content type, message…"
|
|
118
|
+
value={search}
|
|
119
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
120
|
+
label="Search"
|
|
121
|
+
size="S"
|
|
122
|
+
style={{ maxWidth: 340 }}
|
|
123
|
+
/>
|
|
124
|
+
</Box>
|
|
125
|
+
|
|
126
|
+
<Box paddingTop={2}>
|
|
69
127
|
<Table>
|
|
70
128
|
<Thead>
|
|
71
129
|
<Tr>
|
|
72
|
-
<
|
|
73
|
-
<
|
|
74
|
-
<
|
|
75
|
-
<
|
|
130
|
+
<SortableTh field="createdAt">Time</SortableTh>
|
|
131
|
+
<SortableTh field="action">Action</SortableTh>
|
|
132
|
+
<SortableTh field="contentType">Content Type</SortableTh>
|
|
133
|
+
<SortableTh field="status">Status</SortableTh>
|
|
76
134
|
<Th><Typography variant="sigma">Message</Typography></Th>
|
|
77
135
|
</Tr>
|
|
78
136
|
</Thead>
|
|
79
137
|
<Tbody>
|
|
80
|
-
{
|
|
138
|
+
{displayedLogs.map((log, i) => (
|
|
81
139
|
<Tr key={log.id || i}>
|
|
82
140
|
<Td><Typography>{new Date(log.createdAt).toLocaleString()}</Typography></Td>
|
|
83
141
|
<Td><Typography>{log.action}</Typography></Td>
|
|
@@ -96,11 +154,11 @@ const LogsTab = () => {
|
|
|
96
154
|
<Td><Typography>{log.message}</Typography></Td>
|
|
97
155
|
</Tr>
|
|
98
156
|
))}
|
|
99
|
-
{
|
|
157
|
+
{displayedLogs.length === 0 && (
|
|
100
158
|
<Tr>
|
|
101
159
|
<Td colSpan={5}>
|
|
102
160
|
<Typography textColor="neutral500">
|
|
103
|
-
{loading ? 'Loading…' : 'No logs found'}
|
|
161
|
+
{loading ? 'Loading…' : search ? 'No logs match the search.' : 'No logs found'}
|
|
104
162
|
</Typography>
|
|
105
163
|
</Td>
|
|
106
164
|
</Tr>
|
|
@@ -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, CaretUp, CaretDown } from '@strapi/icons';
|
|
23
23
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
24
24
|
|
|
25
25
|
const PLUGIN_ID = 'strapi-content-sync-pro';
|
|
@@ -103,6 +103,13 @@ const MediaTab = () => {
|
|
|
103
103
|
const [editProfile, setEditProfile] = useState(null);
|
|
104
104
|
const [editMode, setEditMode] = useState(null); // 'create' | 'edit'
|
|
105
105
|
|
|
106
|
+
// Profile list filter + sort
|
|
107
|
+
const [profileSearch, setProfileSearch] = useState('');
|
|
108
|
+
const [profileStrategyFilter, setProfileStrategyFilter] = useState('');
|
|
109
|
+
const [profileDirectionFilter, setProfileDirectionFilter] = useState('');
|
|
110
|
+
const [profileSortField, setProfileSortField] = useState('name');
|
|
111
|
+
const [profileSortDir, setProfileSortDir] = useState('asc');
|
|
112
|
+
|
|
106
113
|
const reload = async () => {
|
|
107
114
|
try {
|
|
108
115
|
const [pRes, gRes, sRes, dRes] = await Promise.all([
|
|
@@ -122,8 +129,25 @@ const MediaTab = () => {
|
|
|
122
129
|
}
|
|
123
130
|
};
|
|
124
131
|
|
|
132
|
+
const refreshStatus = async () => {
|
|
133
|
+
try {
|
|
134
|
+
const sRes = await get(`/${PLUGIN_ID}/media-sync/status`);
|
|
135
|
+
setStatus(sRes.data.data || {});
|
|
136
|
+
} catch {
|
|
137
|
+
/* silent — polling should not spam errors */
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
125
141
|
useEffect(() => { reload(); }, []);
|
|
126
142
|
|
|
143
|
+
// Live status polling: while anything is running or paused, poll every 2s.
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const anyActive = (status?.profiles || []).some((p) => p.running || p.paused);
|
|
146
|
+
if (!anyActive) return undefined;
|
|
147
|
+
const id = setInterval(refreshStatus, 2000);
|
|
148
|
+
return () => clearInterval(id);
|
|
149
|
+
}, [status]);
|
|
150
|
+
|
|
127
151
|
const handleSaveGlobal = async () => {
|
|
128
152
|
setSaving(true); setMessage(null);
|
|
129
153
|
try {
|
|
@@ -174,25 +198,67 @@ const MediaTab = () => {
|
|
|
174
198
|
};
|
|
175
199
|
|
|
176
200
|
const handleRunProfile = async (id) => {
|
|
177
|
-
|
|
201
|
+
setMessage(null);
|
|
202
|
+
// Fire-and-forget: don't await — the sync may run for a long time.
|
|
203
|
+
// Poll status to reflect progress and completion.
|
|
204
|
+
post(`/${PLUGIN_ID}/media-sync/profiles/${id}/run`, {})
|
|
205
|
+
.then(() => {
|
|
206
|
+
setMessage({ type: 'success', text: 'Media sync complete.' });
|
|
207
|
+
refreshStatus();
|
|
208
|
+
})
|
|
209
|
+
.catch((err) => {
|
|
210
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
|
|
211
|
+
refreshStatus();
|
|
212
|
+
});
|
|
213
|
+
setMessage({ type: 'success', text: 'Media sync started. You can pause or stop it from the Status tab.' });
|
|
214
|
+
// Kick an immediate status refresh so the UI flips to Running right away.
|
|
215
|
+
setTimeout(refreshStatus, 500);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const handleRunAll = async () => {
|
|
219
|
+
setMessage(null);
|
|
220
|
+
post(`/${PLUGIN_ID}/media-sync/run-active`, {})
|
|
221
|
+
.then(() => {
|
|
222
|
+
setMessage({ type: 'success', text: 'All active media profiles synced.' });
|
|
223
|
+
refreshStatus();
|
|
224
|
+
})
|
|
225
|
+
.catch((err) => {
|
|
226
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
|
|
227
|
+
refreshStatus();
|
|
228
|
+
});
|
|
229
|
+
setMessage({ type: 'success', text: 'Sync All started. Watch progress in the Status tab.' });
|
|
230
|
+
setTimeout(refreshStatus, 500);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const handlePauseProfile = async (id) => {
|
|
178
234
|
try {
|
|
179
|
-
await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/
|
|
180
|
-
setMessage({ type: 'success', text: '
|
|
181
|
-
|
|
235
|
+
await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/pause`, {});
|
|
236
|
+
setMessage({ type: 'success', text: 'Pause requested. The run will halt at the next checkpoint.' });
|
|
237
|
+
refreshStatus();
|
|
182
238
|
} catch (err) {
|
|
183
239
|
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
|
|
184
|
-
}
|
|
240
|
+
}
|
|
185
241
|
};
|
|
186
242
|
|
|
187
|
-
const
|
|
188
|
-
setRunning(true); setMessage(null);
|
|
243
|
+
const handleResumeProfile = async (id) => {
|
|
189
244
|
try {
|
|
190
|
-
await post(`/${PLUGIN_ID}/media-sync/
|
|
191
|
-
setMessage({ type: 'success', text: '
|
|
192
|
-
|
|
245
|
+
await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/resume`, {});
|
|
246
|
+
setMessage({ type: 'success', text: 'Run resumed.' });
|
|
247
|
+
refreshStatus();
|
|
248
|
+
} catch (err) {
|
|
249
|
+
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const handleCancelProfile = async (id) => {
|
|
254
|
+
if (!confirm('Stop this media profile run? Progress already done is kept, remaining work is aborted.')) return;
|
|
255
|
+
try {
|
|
256
|
+
await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/cancel`, {});
|
|
257
|
+
setMessage({ type: 'success', text: 'Stop requested.' });
|
|
258
|
+
refreshStatus();
|
|
193
259
|
} catch (err) {
|
|
194
260
|
setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
|
|
195
|
-
}
|
|
261
|
+
}
|
|
196
262
|
};
|
|
197
263
|
|
|
198
264
|
const handleTest = async () => {
|
|
@@ -216,6 +282,51 @@ const MediaTab = () => {
|
|
|
216
282
|
const ep = editProfile || {};
|
|
217
283
|
const updateEp = (patch) => setEditProfile((p) => ({ ...p, ...patch }));
|
|
218
284
|
|
|
285
|
+
const handleProfileSort = (field) => {
|
|
286
|
+
if (profileSortField === field) {
|
|
287
|
+
setProfileSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
288
|
+
} else {
|
|
289
|
+
setProfileSortField(field);
|
|
290
|
+
setProfileSortDir('asc');
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const displayedProfiles = (() => {
|
|
295
|
+
let result = [...profiles];
|
|
296
|
+
if (profileSearch.trim()) {
|
|
297
|
+
const q = profileSearch.trim().toLowerCase();
|
|
298
|
+
result = result.filter((p) => (p.name || '').toLowerCase().includes(q));
|
|
299
|
+
}
|
|
300
|
+
if (profileStrategyFilter) {
|
|
301
|
+
result = result.filter((p) => p.strategy === profileStrategyFilter);
|
|
302
|
+
}
|
|
303
|
+
if (profileDirectionFilter) {
|
|
304
|
+
result = result.filter((p) => p.direction === profileDirectionFilter);
|
|
305
|
+
}
|
|
306
|
+
result.sort((a, b) => {
|
|
307
|
+
let aVal = a[profileSortField] ?? '';
|
|
308
|
+
let bVal = b[profileSortField] ?? '';
|
|
309
|
+
if (typeof aVal === 'boolean') { aVal = aVal ? 1 : 0; bVal = bVal ? 1 : 0; }
|
|
310
|
+
if (typeof aVal === 'string') {
|
|
311
|
+
return profileSortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
312
|
+
}
|
|
313
|
+
return profileSortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
314
|
+
});
|
|
315
|
+
return result;
|
|
316
|
+
})();
|
|
317
|
+
|
|
318
|
+
const SortableCol = ({ field, style, children }) => (
|
|
319
|
+
<Box
|
|
320
|
+
style={{ ...style, cursor: 'pointer', userSelect: 'none' }}
|
|
321
|
+
onClick={() => handleProfileSort(field)}
|
|
322
|
+
>
|
|
323
|
+
<Flex alignItems="center" gap={1}>
|
|
324
|
+
<Typography variant="sigma">{children}</Typography>
|
|
325
|
+
{profileSortField === field && (profileSortDir === 'asc' ? <CaretUp /> : <CaretDown />)}
|
|
326
|
+
</Flex>
|
|
327
|
+
</Box>
|
|
328
|
+
);
|
|
329
|
+
|
|
219
330
|
return (
|
|
220
331
|
<Box padding={4}>
|
|
221
332
|
<Box paddingBottom={4}>
|
|
@@ -245,7 +356,7 @@ const MediaTab = () => {
|
|
|
245
356
|
<Typography variant="delta">Media Sync Profiles</Typography>
|
|
246
357
|
<Flex gap={2}>
|
|
247
358
|
<Button variant="secondary" onClick={handleTest} loading={testing} disabled={testing}>Test connection</Button>
|
|
248
|
-
<Button variant="secondary" onClick={handleRunAll}
|
|
359
|
+
<Button variant="secondary" onClick={handleRunAll} disabled={!!status?.running}>Sync All Active</Button>
|
|
249
360
|
<Button onClick={() => { setEditProfile({ ...EMPTY_PROFILE, includeMime: defaults?.mimeAll || [] }); setEditMode('create'); }}>
|
|
250
361
|
Create Profile
|
|
251
362
|
</Button>
|
|
@@ -258,17 +369,71 @@ const MediaTab = () => {
|
|
|
258
369
|
</Box>
|
|
259
370
|
) : (
|
|
260
371
|
<Box>
|
|
372
|
+
{/* Filter bar */}
|
|
373
|
+
<Flex gap={3} wrap="wrap" marginBottom={3} alignItems="flex-end">
|
|
374
|
+
<Box style={{ flex: '1 1 180px', minWidth: 160 }}>
|
|
375
|
+
<TextInput
|
|
376
|
+
placeholder="Search by name…"
|
|
377
|
+
value={profileSearch}
|
|
378
|
+
onChange={(e) => setProfileSearch(e.target.value)}
|
|
379
|
+
label="Search"
|
|
380
|
+
size="S"
|
|
381
|
+
/>
|
|
382
|
+
</Box>
|
|
383
|
+
<Box style={{ minWidth: 160 }}>
|
|
384
|
+
<SingleSelect
|
|
385
|
+
placeholder="All strategies"
|
|
386
|
+
value={profileStrategyFilter}
|
|
387
|
+
onChange={setProfileStrategyFilter}
|
|
388
|
+
onClear={() => setProfileStrategyFilter('')}
|
|
389
|
+
size="S"
|
|
390
|
+
label="Strategy"
|
|
391
|
+
>
|
|
392
|
+
<SingleSelectOption value="url">URL</SingleSelectOption>
|
|
393
|
+
<SingleSelectOption value="rsync">rsync</SingleSelectOption>
|
|
394
|
+
<SingleSelectOption value="disabled">Disabled</SingleSelectOption>
|
|
395
|
+
</SingleSelect>
|
|
396
|
+
</Box>
|
|
397
|
+
<Box style={{ minWidth: 160 }}>
|
|
398
|
+
<SingleSelect
|
|
399
|
+
placeholder="All directions"
|
|
400
|
+
value={profileDirectionFilter}
|
|
401
|
+
onChange={setProfileDirectionFilter}
|
|
402
|
+
onClear={() => setProfileDirectionFilter('')}
|
|
403
|
+
size="S"
|
|
404
|
+
label="Direction"
|
|
405
|
+
>
|
|
406
|
+
<SingleSelectOption value="push">Push</SingleSelectOption>
|
|
407
|
+
<SingleSelectOption value="pull">Pull</SingleSelectOption>
|
|
408
|
+
<SingleSelectOption value="both">Both</SingleSelectOption>
|
|
409
|
+
</SingleSelect>
|
|
410
|
+
</Box>
|
|
411
|
+
{(profileSearch || profileStrategyFilter || profileDirectionFilter) && (
|
|
412
|
+
<Button
|
|
413
|
+
variant="tertiary"
|
|
414
|
+
size="S"
|
|
415
|
+
onClick={() => { setProfileSearch(''); setProfileStrategyFilter(''); setProfileDirectionFilter(''); }}
|
|
416
|
+
>
|
|
417
|
+
Clear filters
|
|
418
|
+
</Button>
|
|
419
|
+
)}
|
|
420
|
+
</Flex>
|
|
421
|
+
|
|
261
422
|
{/* Header */}
|
|
262
423
|
<Flex background="neutral100" padding={3} hasRadius style={{ fontWeight: 600 }}>
|
|
263
|
-
<
|
|
264
|
-
<
|
|
265
|
-
<
|
|
266
|
-
<
|
|
267
|
-
<
|
|
424
|
+
<SortableCol field="name" style={{ flex: 2 }}>Name</SortableCol>
|
|
425
|
+
<SortableCol field="strategy" style={{ flex: 1 }}>Strategy</SortableCol>
|
|
426
|
+
<SortableCol field="direction" style={{ flex: 1 }}>Direction</SortableCol>
|
|
427
|
+
<SortableCol field="conflictStrategy" style={{ flex: 1 }}>Conflict</SortableCol>
|
|
428
|
+
<SortableCol field="executionMode" style={{ flex: 1 }}>Execution</SortableCol>
|
|
268
429
|
<Box style={{ flex: 1 }}><Typography variant="sigma">Sync Scope</Typography></Box>
|
|
269
430
|
<Box style={{ width: 180 }}><Typography variant="sigma">Actions</Typography></Box>
|
|
270
431
|
</Flex>
|
|
271
|
-
{
|
|
432
|
+
{displayedProfiles.length === 0 ? (
|
|
433
|
+
<Box padding={4}>
|
|
434
|
+
<Typography textColor="neutral500">No profiles match the current filters.</Typography>
|
|
435
|
+
</Box>
|
|
436
|
+
) : displayedProfiles.map((p) => (
|
|
272
437
|
<Flex key={p.id} padding={3} borderColor="neutral150" style={{ borderBottom: '1px solid #eee' }} alignItems="center">
|
|
273
438
|
<Box style={{ flex: 2 }}>
|
|
274
439
|
<Flex gap={2} alignItems="center">
|
|
@@ -289,9 +454,30 @@ const MediaTab = () => {
|
|
|
289
454
|
{!p.active && (
|
|
290
455
|
<Button variant="tertiary" size="S" onClick={() => handleActivate(p.id)} startIcon={<Check />}>Activate</Button>
|
|
291
456
|
)}
|
|
292
|
-
{p.active && (
|
|
293
|
-
|
|
294
|
-
|
|
457
|
+
{p.active && (() => {
|
|
458
|
+
const sp = (status?.profiles || []).find((x) => x.id === p.id);
|
|
459
|
+
const isRunning = !!sp?.running;
|
|
460
|
+
const isPaused = !!sp?.paused;
|
|
461
|
+
if (isRunning && !isPaused) {
|
|
462
|
+
return (
|
|
463
|
+
<>
|
|
464
|
+
<Button variant="tertiary" size="S" onClick={() => handlePauseProfile(p.id)}>Pause</Button>
|
|
465
|
+
<IconButton label="Stop" onClick={() => handleCancelProfile(p.id)}><Stop /></IconButton>
|
|
466
|
+
</>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (isPaused) {
|
|
470
|
+
return (
|
|
471
|
+
<>
|
|
472
|
+
<Button variant="secondary" size="S" onClick={() => handleResumeProfile(p.id)} startIcon={<Play />}>Resume</Button>
|
|
473
|
+
<IconButton label="Stop" onClick={() => handleCancelProfile(p.id)}><Stop /></IconButton>
|
|
474
|
+
</>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
return (
|
|
478
|
+
<Button variant="secondary" size="S" onClick={() => handleRunProfile(p.id)} startIcon={<Play />}>Run</Button>
|
|
479
|
+
);
|
|
480
|
+
})()}
|
|
295
481
|
<IconButton label="Edit" onClick={() => { setEditProfile({ ...p }); setEditMode('edit'); }}><Pencil /></IconButton>
|
|
296
482
|
<IconButton label="Delete" onClick={() => handleDelete(p.id)}><Trash /></IconButton>
|
|
297
483
|
</Flex>
|
|
@@ -382,21 +568,52 @@ const MediaTab = () => {
|
|
|
382
568
|
{/* ── Status Tab ──────────────────────────────────────────────── */}
|
|
383
569
|
<Tabs.Content value="status">
|
|
384
570
|
<Box paddingTop={4}>
|
|
385
|
-
<
|
|
386
|
-
|
|
387
|
-
<
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
571
|
+
<Flex justifyContent="space-between" alignItems="center" paddingBottom={3}>
|
|
572
|
+
<Typography variant="delta">Media Sync Status</Typography>
|
|
573
|
+
<Button variant="tertiary" size="S" onClick={refreshStatus}>Refresh</Button>
|
|
574
|
+
</Flex>
|
|
575
|
+
{status?.profiles?.map((sp) => {
|
|
576
|
+
const prog = sp.progress || null;
|
|
577
|
+
const stateLabel = sp.paused ? 'Paused' : sp.running ? 'Running' : 'Idle';
|
|
578
|
+
return (
|
|
579
|
+
<Box key={sp.id} background="neutral0" padding={3} hasRadius shadow="tableShadow" marginBottom={2}>
|
|
580
|
+
<Flex justifyContent="space-between" alignItems="center">
|
|
581
|
+
<Flex gap={2} alignItems="center">
|
|
582
|
+
<Typography variant="omega" fontWeight="bold">{sp.name}</Typography>
|
|
583
|
+
{sp.active && <Badge active>Active</Badge>}
|
|
584
|
+
<Badge>{stateLabel}</Badge>
|
|
585
|
+
{prog?.phase && (sp.running || sp.paused) && (
|
|
586
|
+
<Typography variant="pi" textColor="neutral600">phase: {prog.phase}</Typography>
|
|
587
|
+
)}
|
|
588
|
+
</Flex>
|
|
589
|
+
<Flex gap={1} alignItems="center">
|
|
590
|
+
{sp.running && !sp.paused && (
|
|
591
|
+
<>
|
|
592
|
+
<Button variant="tertiary" size="S" onClick={() => handlePauseProfile(sp.id)}>Pause</Button>
|
|
593
|
+
<Button variant="danger-light" size="S" startIcon={<Stop />} onClick={() => handleCancelProfile(sp.id)}>Stop</Button>
|
|
594
|
+
</>
|
|
595
|
+
)}
|
|
596
|
+
{sp.paused && (
|
|
597
|
+
<>
|
|
598
|
+
<Button variant="secondary" size="S" startIcon={<Play />} onClick={() => handleResumeProfile(sp.id)}>Resume</Button>
|
|
599
|
+
<Button variant="danger-light" size="S" startIcon={<Stop />} onClick={() => handleCancelProfile(sp.id)}>Stop</Button>
|
|
600
|
+
</>
|
|
601
|
+
)}
|
|
602
|
+
<Typography variant="pi" textColor="neutral600" paddingLeft={2}>
|
|
603
|
+
Mode: {(sp.executionMode || '').replace('_', ' ')} | Last: {sp.lastExecutedAt ? new Date(sp.lastExecutedAt).toLocaleString() : 'never'}
|
|
604
|
+
</Typography>
|
|
605
|
+
</Flex>
|
|
393
606
|
</Flex>
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
607
|
+
{(sp.running || sp.paused) && prog && (
|
|
608
|
+
<Box paddingTop={2}>
|
|
609
|
+
<Typography variant="pi" textColor="neutral700">
|
|
610
|
+
pushed: {prog.pushed || 0} · pulled: {prog.pulled || 0} · skipped: {prog.skipped || 0} · errors: {prog.errors || 0}
|
|
611
|
+
</Typography>
|
|
612
|
+
</Box>
|
|
613
|
+
)}
|
|
614
|
+
</Box>
|
|
615
|
+
);
|
|
616
|
+
})}
|
|
400
617
|
{status?.lastResult && (
|
|
401
618
|
<Box paddingTop={3} background="neutral0" padding={4} hasRadius shadow="tableShadow">
|
|
402
619
|
<Typography variant="sigma">Last Run Result</Typography>
|