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.
Files changed (59) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +32 -14
  3. package/admin/src/components/BulkTransferTab.jsx +185 -20
  4. package/admin/src/components/ConfigTab.jsx +81 -3
  5. package/admin/src/components/ContentTypesTab.jsx +28 -1
  6. package/admin/src/components/HelpTab.jsx +34 -0
  7. package/admin/src/components/LogsTab.jsx +66 -8
  8. package/admin/src/components/MediaTab.jsx +253 -36
  9. package/admin/src/components/SyncProfilesTab.jsx +140 -4
  10. package/admin/src/components/SyncTab.jsx +161 -35
  11. package/docs/Screenshot 2026-04-22 183540.png +0 -0
  12. package/docs/Screenshot 2026-04-22 183552.png +0 -0
  13. package/docs/Screenshot 2026-04-23 114332.png +0 -0
  14. package/docs/Screenshot 2026-04-23 114644.png +0 -0
  15. package/docs/Screenshot 2026-04-23 114651.png +0 -0
  16. package/docs/Screenshot 2026-04-23 114737.png +0 -0
  17. package/docs/Screenshot 2026-04-23 114904.png +0 -0
  18. package/docs/Screenshot 2026-04-23 114940.png +0 -0
  19. package/docs/Screenshot 2026-04-23 115003.png +0 -0
  20. package/docs/Screenshot 2026-04-23 115024.png +0 -0
  21. package/docs/Screenshot 2026-04-23 115116.png +0 -0
  22. package/docs/Screenshot 2026-04-23 115141.png +0 -0
  23. package/docs/Screenshot 2026-04-23 115252.png +0 -0
  24. package/docs/Screenshot 2026-04-23 115448.png +0 -0
  25. package/docs/Screenshot 2026-04-23 120534.png +0 -0
  26. package/docs/Screenshot 2026-04-23 122544.png +0 -0
  27. package/docs/Screenshot 2026-04-23 122712.png +0 -0
  28. package/docs/Screenshot 2026-04-23 122730.png +0 -0
  29. package/docs/Screenshot 2026-04-23 122858.png +0 -0
  30. package/docs/Screenshot 2026-04-23 122924.png +0 -0
  31. package/docs/Screenshot 2026-04-23 122937.png +0 -0
  32. package/docs/sync-strategy-approach-review.md +127 -0
  33. package/package.json +1 -1
  34. package/server/src/controllers/config.js +76 -3
  35. package/server/src/controllers/sync-media.js +24 -0
  36. package/server/src/routes/index.js +3 -0
  37. package/server/src/services/bulk-transfer.js +45 -1
  38. package/server/src/services/dependency-resolver.js +37 -0
  39. package/server/src/services/sync-execution.js +21 -9
  40. package/server/src/services/sync-media.js +168 -32
  41. package/server/src/services/sync-profiles.js +36 -15
  42. package/server/src/services/sync.js +234 -134
  43. package/server/src/utils/fetcher.js +7 -0
  44. package/docs/Screenshot 2026-04-20 160506.png +0 -0
  45. package/docs/Screenshot 2026-04-20 160558.png +0 -0
  46. package/docs/Screenshot 2026-04-20 175903.png +0 -0
  47. package/docs/Screenshot 2026-04-20 175931.png +0 -0
  48. package/docs/Screenshot 2026-04-20 180001.png +0 -0
  49. package/docs/Screenshot 2026-04-20 180041.png +0 -0
  50. package/docs/Screenshot 2026-04-20 180116.png +0 -0
  51. package/docs/Screenshot 2026-04-20 180135.png +0 -0
  52. package/docs/Screenshot 2026-04-20 180202.png +0 -0
  53. package/docs/Screenshot 2026-04-20 180228.png +0 -0
  54. package/docs/Screenshot 2026-04-20 180251.png +0 -0
  55. package/docs/Screenshot 2026-04-20 180301.png +0 -0
  56. package/docs/clipchamp-screen-recording-script.md +0 -0
  57. package/docs/production-readiness-status.md +0 -34
  58. package/docs/production-readiness-test-matrix.md +0 -151
  59. 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={4}>
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
- <Th><Typography variant="sigma">Time</Typography></Th>
73
- <Th><Typography variant="sigma">Action</Typography></Th>
74
- <Th><Typography variant="sigma">Content Type</Typography></Th>
75
- <Th><Typography variant="sigma">Status</Typography></Th>
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
- {logs.map((log, i) => (
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
- {logs.length === 0 && (
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
- setRunning(true); setMessage(null);
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}/run`, {});
180
- setMessage({ type: 'success', text: 'Media sync complete.' });
181
- await reload();
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
- } finally { setRunning(false); }
240
+ }
185
241
  };
186
242
 
187
- const handleRunAll = async () => {
188
- setRunning(true); setMessage(null);
243
+ const handleResumeProfile = async (id) => {
189
244
  try {
190
- await post(`/${PLUGIN_ID}/media-sync/run-active`, {});
191
- setMessage({ type: 'success', text: 'All active media profiles synced.' });
192
- await reload();
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
- } finally { setRunning(false); }
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} loading={running} disabled={running}>Sync All Active</Button>
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
- <Box style={{ flex: 2 }}><Typography variant="sigma">Name</Typography></Box>
264
- <Box style={{ flex: 1 }}><Typography variant="sigma">Strategy</Typography></Box>
265
- <Box style={{ flex: 1 }}><Typography variant="sigma">Direction</Typography></Box>
266
- <Box style={{ flex: 1 }}><Typography variant="sigma">Conflict</Typography></Box>
267
- <Box style={{ flex: 1 }}><Typography variant="sigma">Execution</Typography></Box>
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
- {profiles.map((p) => (
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
- <Button variant="secondary" size="S" onClick={() => handleRunProfile(p.id)} loading={running} disabled={running} startIcon={<Play />}>Run</Button>
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
- <Typography variant="delta" paddingBottom={3}>Media Sync Status</Typography>
386
- {status?.profiles?.map((sp) => (
387
- <Box key={sp.id} background="neutral0" padding={3} hasRadius shadow="tableShadow" marginBottom={2}>
388
- <Flex justifyContent="space-between" alignItems="center">
389
- <Flex gap={2} alignItems="center">
390
- <Typography variant="omega" fontWeight="bold">{sp.name}</Typography>
391
- {sp.active && <Badge active>Active</Badge>}
392
- <Badge>{sp.running ? 'Running' : 'Idle'}</Badge>
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
- <Typography variant="pi" textColor="neutral600">
395
- Mode: {(sp.executionMode || '').replace('_', ' ')} | Last: {sp.lastExecutedAt ? new Date(sp.lastExecutedAt).toLocaleString() : 'never'}
396
- </Typography>
397
- </Flex>
398
- </Box>
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>