retold-data-service 2.0.16 → 2.0.18

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 (28) hide show
  1. package/.claude/launch.json +2 -2
  2. package/.quackage.json +19 -0
  3. package/package.json +13 -6
  4. package/source/services/data-cloner/DataCloner-Command-Sync.js +83 -50
  5. package/source/services/data-cloner/DataCloner-Command-WebUI.js +27 -10
  6. package/source/services/data-cloner/Retold-Data-Service-DataCloner.js +281 -4
  7. package/source/services/data-cloner/pict-app/Pict-Application-DataCloner-Configuration.json +9 -0
  8. package/source/services/data-cloner/pict-app/Pict-Application-DataCloner.js +102 -0
  9. package/source/services/data-cloner/pict-app/Pict-DataCloner-Bundle.js +6 -0
  10. package/source/services/data-cloner/pict-app/providers/Pict-Provider-DataCloner.js +998 -0
  11. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Connection.js +407 -0
  12. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Deploy.js +126 -0
  13. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Export.js +483 -0
  14. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Layout.js +390 -0
  15. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Schema.js +241 -0
  16. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Session.js +268 -0
  17. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Sync.js +575 -0
  18. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-ViewData.js +176 -0
  19. package/source/services/data-cloner/web/data-cloner.js +7952 -0
  20. package/source/services/data-cloner/web/data-cloner.js.map +1 -0
  21. package/source/services/data-cloner/web/data-cloner.min.js +2 -0
  22. package/source/services/data-cloner/web/data-cloner.min.js.map +1 -0
  23. package/source/services/data-cloner/web/index.html +17 -0
  24. package/test/DataCloner-Integration_tests.js +1205 -0
  25. package/test/DataCloner-Puppeteer_tests.js +502 -0
  26. package/test/integration-report.json +311 -0
  27. package/test/run-integration-tests.js +501 -0
  28. package/source/services/data-cloner/data-cloner-web.html +0 -2706
@@ -0,0 +1,575 @@
1
+ const libPictView = require('pict-view');
2
+
3
+ class DataClonerSyncView extends libPictView
4
+ {
5
+ constructor(pFable, pOptions, pServiceHash)
6
+ {
7
+ super(pFable, pOptions, pServiceHash);
8
+ }
9
+
10
+ startSync()
11
+ {
12
+ let tmpSelectedTables = this.pict.views['DataCloner-Schema'].getSelectedTables();
13
+ let tmpPageSize = parseInt(document.getElementById('pageSize').value, 10) || 100;
14
+ let tmpDateTimePrecisionMS = parseInt(document.getElementById('dateTimePrecisionMS').value, 10);
15
+ if (isNaN(tmpDateTimePrecisionMS)) tmpDateTimePrecisionMS = 1000;
16
+ let tmpSyncDeletedRecords = document.getElementById('syncDeletedRecords').checked;
17
+ let tmpSyncMode = document.querySelector('input[name="syncMode"]:checked').value;
18
+ let tmpMaxRecords = parseInt(document.getElementById('syncMaxRecords').value, 10) || 0;
19
+ let tmpLogToFile = document.getElementById('syncLogFile').checked;
20
+
21
+ if (tmpSelectedTables.length === 0)
22
+ {
23
+ this.pict.providers.DataCloner.setStatus('syncStatus', 'No tables selected for sync.', 'error');
24
+ this.pict.providers.DataCloner.setSectionPhase(5, 'error');
25
+ return;
26
+ }
27
+
28
+ this.pict.providers.DataCloner.setSectionPhase(5, 'busy');
29
+ this.pict.providers.DataCloner.setStatus('syncStatus', 'Starting ' + tmpSyncMode.toLowerCase() + ' sync...', 'info');
30
+
31
+ let tmpSelf = this;
32
+ let tmpPostBody = { Tables: tmpSelectedTables, PageSize: tmpPageSize, DateTimePrecisionMS: tmpDateTimePrecisionMS, SyncDeletedRecords: tmpSyncDeletedRecords, SyncMode: tmpSyncMode };
33
+ if (tmpMaxRecords > 0) tmpPostBody.MaxRecordsPerEntity = tmpMaxRecords;
34
+ if (tmpLogToFile) tmpPostBody.LogToFile = true;
35
+ this.pict.providers.DataCloner.api('POST', '/clone/sync/start', tmpPostBody)
36
+ .then(function(pData)
37
+ {
38
+ if (pData.Success)
39
+ {
40
+ let tmpMsg = pData.SyncMode + ' sync started for ' + pData.Tables.length + ' tables.';
41
+ if (pData.SyncDeletedRecords) tmpMsg += ' (including deleted records)';
42
+ tmpSelf.pict.providers.DataCloner.setStatus('syncStatus', tmpMsg, 'ok');
43
+ tmpSelf.startPolling();
44
+ }
45
+ else
46
+ {
47
+ tmpSelf.pict.providers.DataCloner.setStatus('syncStatus', 'Sync start failed: ' + (pData.Error || 'Unknown error'), 'error');
48
+ tmpSelf.pict.providers.DataCloner.setSectionPhase(5, 'error');
49
+ }
50
+ })
51
+ .catch(function(pError)
52
+ {
53
+ tmpSelf.pict.providers.DataCloner.setStatus('syncStatus', 'Request failed: ' + pError.message, 'error');
54
+ tmpSelf.pict.providers.DataCloner.setSectionPhase(5, 'error');
55
+ });
56
+ }
57
+
58
+ stopSync()
59
+ {
60
+ let tmpSelf = this;
61
+ this.pict.providers.DataCloner.api('POST', '/clone/sync/stop')
62
+ .then(function(pData)
63
+ {
64
+ tmpSelf.pict.providers.DataCloner.setStatus('syncStatus', 'Sync stop requested.', 'warn');
65
+ })
66
+ .catch(function(pError)
67
+ {
68
+ tmpSelf.pict.providers.DataCloner.setStatus('syncStatus', 'Request failed: ' + pError.message, 'error');
69
+ });
70
+ }
71
+
72
+ startPolling()
73
+ {
74
+ if (this.pict.AppData.DataCloner.SyncPollTimer) clearInterval(this.pict.AppData.DataCloner.SyncPollTimer);
75
+ let tmpSelf = this;
76
+ this.pict.AppData.DataCloner.SyncPollTimer = setInterval(function() { tmpSelf.pollSyncStatus(); }, 2000);
77
+ this.pollSyncStatus();
78
+ }
79
+
80
+ stopPolling()
81
+ {
82
+ if (this.pict.AppData.DataCloner.SyncPollTimer)
83
+ {
84
+ clearInterval(this.pict.AppData.DataCloner.SyncPollTimer);
85
+ this.pict.AppData.DataCloner.SyncPollTimer = null;
86
+ }
87
+ }
88
+
89
+ pollSyncStatus()
90
+ {
91
+ let tmpSelf = this;
92
+ this.pict.providers.DataCloner.api('GET', '/clone/sync/status')
93
+ .then(function(pData)
94
+ {
95
+ tmpSelf.renderSyncProgress(pData);
96
+
97
+ if (!pData.Running && !pData.Stopping)
98
+ {
99
+ tmpSelf.stopPolling();
100
+ if (Object.keys(pData.Tables || {}).length > 0)
101
+ {
102
+ // Check if any tables had errors or partial sync
103
+ let tmpTables = pData.Tables || {};
104
+ let tmpHasErrors = false;
105
+ let tmpHasPartial = false;
106
+ let tmpNames = Object.keys(tmpTables);
107
+ for (let i = 0; i < tmpNames.length; i++)
108
+ {
109
+ if (tmpTables[tmpNames[i]].Status === 'Error') tmpHasErrors = true;
110
+ if (tmpTables[tmpNames[i]].Status === 'Partial') tmpHasPartial = true;
111
+ }
112
+
113
+ if (tmpHasErrors)
114
+ {
115
+ tmpSelf.pict.providers.DataCloner.setStatus('syncStatus', 'Sync finished with errors. Check the table below for details.', 'error');
116
+ tmpSelf.pict.providers.DataCloner.setSectionPhase(5, 'error');
117
+ }
118
+ else if (tmpHasPartial)
119
+ {
120
+ tmpSelf.pict.providers.DataCloner.setStatus('syncStatus', 'Sync finished. Some records were skipped (GUID conflicts or permission issues).', 'warn');
121
+ tmpSelf.pict.providers.DataCloner.setSectionPhase(5, 'ok');
122
+ }
123
+ else
124
+ {
125
+ tmpSelf.pict.providers.DataCloner.setStatus('syncStatus', 'Sync complete.', 'ok');
126
+ tmpSelf.pict.providers.DataCloner.setSectionPhase(5, 'ok');
127
+ }
128
+
129
+ // Fetch the structured report
130
+ tmpSelf.fetchSyncReport();
131
+ }
132
+ }
133
+ })
134
+ .catch(function(pError)
135
+ {
136
+ // Silently ignore poll errors
137
+ });
138
+ }
139
+
140
+ fetchSyncReport()
141
+ {
142
+ let tmpSelf = this;
143
+ this.pict.providers.DataCloner.api('GET', '/clone/sync/report')
144
+ .then(function(pData)
145
+ {
146
+ if (pData && pData.ReportVersion)
147
+ {
148
+ tmpSelf.pict.AppData.DataCloner.LastReport = pData;
149
+ tmpSelf.renderSyncReport(pData);
150
+ }
151
+ })
152
+ .catch(function(pError)
153
+ {
154
+ // Ignore report fetch errors
155
+ });
156
+ }
157
+
158
+ renderSyncReport(pReport)
159
+ {
160
+ let tmpSection = document.getElementById('syncReportSection');
161
+ tmpSection.style.display = '';
162
+
163
+ // --- Summary Cards ---
164
+ let tmpCardsContainer = document.getElementById('reportSummaryCards');
165
+ let tmpOutcomeClass = 'outcome-' + pReport.Outcome.toLowerCase();
166
+ let tmpOutcomeColor = { Success: '#28a745', Partial: '#ffc107', Error: '#dc3545', Stopped: '#6c757d' }[pReport.Outcome] || '#666';
167
+
168
+ let tmpDurationSec = pReport.RunTimestamps.DurationSeconds || 0;
169
+ let tmpDurationStr = tmpDurationSec < 60 ? tmpDurationSec + 's' : Math.floor(tmpDurationSec / 60) + 'm ' + (tmpDurationSec % 60) + 's';
170
+
171
+ let tmpTotalSynced = pReport.Summary.TotalSynced.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
172
+ let tmpTotalRecords = pReport.Summary.TotalRecords.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
173
+
174
+ tmpCardsContainer.innerHTML = ''
175
+ + '<div class="report-card ' + tmpOutcomeClass + '">'
176
+ + ' <div class="card-label">Outcome</div>'
177
+ + ' <div class="card-value" style="color:' + tmpOutcomeColor + '">' + pReport.Outcome + '</div>'
178
+ + '</div>'
179
+ + '<div class="report-card">'
180
+ + ' <div class="card-label">Mode</div>'
181
+ + ' <div class="card-value">' + pReport.Config.SyncMode + '</div>'
182
+ + '</div>'
183
+ + '<div class="report-card">'
184
+ + ' <div class="card-label">Duration</div>'
185
+ + ' <div class="card-value">' + tmpDurationStr + '</div>'
186
+ + '</div>'
187
+ + '<div class="report-card">'
188
+ + ' <div class="card-label">Tables</div>'
189
+ + ' <div class="card-value">' + pReport.Summary.Complete + ' / ' + pReport.Summary.TotalTables + '</div>'
190
+ + '</div>'
191
+ + '<div class="report-card">'
192
+ + ' <div class="card-label">Records</div>'
193
+ + ' <div class="card-value">' + tmpTotalSynced + '</div>'
194
+ + ' <div style="font-size:0.75em; color:#888">of ' + tmpTotalRecords + '</div>'
195
+ + '</div>';
196
+
197
+ // --- Anomalies ---
198
+ let tmpAnomalyContainer = document.getElementById('reportAnomalies');
199
+ if (pReport.Anomalies.length === 0)
200
+ {
201
+ tmpAnomalyContainer.innerHTML = '<div style="color:#28a745; font-weight:600; font-size:0.9em">No anomalies detected.</div>';
202
+ }
203
+ else
204
+ {
205
+ let tmpHtml = '<h4 style="margin:0 0 8px; color:#dc3545; font-size:0.95em">Anomalies (' + pReport.Anomalies.length + ')</h4>';
206
+ tmpHtml += '<table class="progress-table">';
207
+ tmpHtml += '<tr><th>Table</th><th>Type</th><th>Message</th></tr>';
208
+ for (let i = 0; i < pReport.Anomalies.length; i++)
209
+ {
210
+ let tmpAnomaly = pReport.Anomalies[i];
211
+ let tmpTypeColor = tmpAnomaly.Type === 'Error' ? '#dc3545' : (tmpAnomaly.Type === 'Partial' ? '#ffc107' : '#6c757d');
212
+ tmpHtml += '<tr>';
213
+ tmpHtml += '<td><strong>' + this.pict.providers.DataCloner.escapeHtml(tmpAnomaly.Table) + '</strong></td>';
214
+ tmpHtml += '<td style="color:' + tmpTypeColor + '">' + tmpAnomaly.Type + '</td>';
215
+ tmpHtml += '<td>' + this.pict.providers.DataCloner.escapeHtml(tmpAnomaly.Message) + '</td>';
216
+ tmpHtml += '</tr>';
217
+ }
218
+ tmpHtml += '</table>';
219
+ tmpAnomalyContainer.innerHTML = tmpHtml;
220
+ }
221
+
222
+ // --- Top Tables by Duration ---
223
+ let tmpTopContainer = document.getElementById('reportTopTables');
224
+ let tmpTopCount = Math.min(10, pReport.Tables.length);
225
+ if (tmpTopCount > 0)
226
+ {
227
+ let tmpHtml = '<h4 style="margin:0 0 8px; font-size:0.95em; color:#444">Top Tables by Duration</h4>';
228
+ tmpHtml += '<table class="progress-table">';
229
+ tmpHtml += '<tr><th>Table</th><th>Duration</th><th>Records</th><th>Status</th></tr>';
230
+ for (let i = 0; i < tmpTopCount; i++)
231
+ {
232
+ let tmpTable = pReport.Tables[i];
233
+ let tmpDur = tmpTable.DurationSeconds < 60 ? tmpTable.DurationSeconds + 's' : Math.floor(tmpTable.DurationSeconds / 60) + 'm ' + (tmpTable.DurationSeconds % 60) + 's';
234
+ let tmpRecs = tmpTable.Total.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
235
+ let tmpStatusColor = { Complete: '#28a745', Error: '#dc3545', Partial: '#ffc107' }[tmpTable.Status] || '#666';
236
+ tmpHtml += '<tr>';
237
+ tmpHtml += '<td><strong>' + this.pict.providers.DataCloner.escapeHtml(tmpTable.Name) + '</strong></td>';
238
+ tmpHtml += '<td>' + tmpDur + '</td>';
239
+ tmpHtml += '<td>' + tmpRecs + '</td>';
240
+ tmpHtml += '<td style="color:' + tmpStatusColor + '">' + tmpTable.Status + '</td>';
241
+ tmpHtml += '</tr>';
242
+ }
243
+ tmpHtml += '</table>';
244
+ tmpTopContainer.innerHTML = tmpHtml;
245
+ }
246
+ }
247
+
248
+ downloadReport()
249
+ {
250
+ if (!this.pict.AppData.DataCloner.LastReport)
251
+ {
252
+ this.pict.providers.DataCloner.setStatus('reportStatus', 'No report available.', 'warn');
253
+ return;
254
+ }
255
+ let tmpJson = JSON.stringify(this.pict.AppData.DataCloner.LastReport, null, '\t');
256
+ let tmpBlob = new Blob([tmpJson], { type: 'application/json' });
257
+ let tmpAnchor = document.createElement('a');
258
+ tmpAnchor.href = URL.createObjectURL(tmpBlob);
259
+ let tmpTimestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
260
+ tmpAnchor.download = 'DataCloner-Report-' + tmpTimestamp + '.json';
261
+ tmpAnchor.click();
262
+ URL.revokeObjectURL(tmpAnchor.href);
263
+ this.pict.providers.DataCloner.setStatus('reportStatus', 'Report downloaded.', 'ok');
264
+ }
265
+
266
+ copyReport()
267
+ {
268
+ if (!this.pict.AppData.DataCloner.LastReport)
269
+ {
270
+ this.pict.providers.DataCloner.setStatus('reportStatus', 'No report available.', 'warn');
271
+ return;
272
+ }
273
+ let tmpJson = JSON.stringify(this.pict.AppData.DataCloner.LastReport, null, '\t');
274
+ let tmpSelf = this;
275
+ navigator.clipboard.writeText(tmpJson).then(function()
276
+ {
277
+ tmpSelf.pict.providers.DataCloner.setStatus('reportStatus', 'Report copied to clipboard.', 'ok');
278
+ });
279
+ }
280
+
281
+ renderSyncProgress(pData)
282
+ {
283
+ let tmpContainer = document.getElementById('syncProgress');
284
+ let tmpTables = pData.Tables || {};
285
+ let tmpTableNames = Object.keys(tmpTables);
286
+
287
+ if (tmpTableNames.length === 0)
288
+ {
289
+ tmpContainer.innerHTML = '';
290
+ return;
291
+ }
292
+
293
+ // Categorize tables into sections, preserving original order for pending
294
+ let tmpSyncing = [];
295
+ let tmpPending = [];
296
+ let tmpCompleted = [];
297
+ let tmpErrors = [];
298
+
299
+ for (let i = 0; i < tmpTableNames.length; i++)
300
+ {
301
+ let tmpName = tmpTableNames[i];
302
+ let tmpTable = tmpTables[tmpName];
303
+
304
+ if (tmpTable.Status === 'Syncing')
305
+ {
306
+ tmpSyncing.push({ Name: tmpName, Data: tmpTable });
307
+ }
308
+ else if (tmpTable.Status === 'Pending')
309
+ {
310
+ tmpPending.push({ Name: tmpName, Data: tmpTable });
311
+ }
312
+ else if (tmpTable.Status === 'Complete')
313
+ {
314
+ tmpCompleted.push({ Name: tmpName, Data: tmpTable });
315
+ }
316
+ else
317
+ {
318
+ // Error, Partial, or anything else
319
+ tmpErrors.push({ Name: tmpName, Data: tmpTable });
320
+ }
321
+ }
322
+
323
+ let tmpHtml = '';
324
+ let tmpSelf = this;
325
+ let fRenderRow = (pName, pTable) =>
326
+ {
327
+ // Calculate percentage
328
+ let tmpPct = 0;
329
+ if (pTable.Total === 0 && (pTable.Status === 'Complete' || pTable.Status === 'Error'))
330
+ {
331
+ tmpPct = 100;
332
+ }
333
+ else if (pTable.Total > 0)
334
+ {
335
+ tmpPct = Math.round((pTable.Synced / pTable.Total) * 100);
336
+ }
337
+
338
+ // Color the progress bar based on status
339
+ let tmpBarColor = '#28a745'; // green
340
+ if (pTable.Status === 'Error') tmpBarColor = '#dc3545';
341
+ else if (pTable.Status === 'Partial') tmpBarColor = '#ffc107';
342
+ else if (pTable.Status === 'Syncing') tmpBarColor = '#4a90d9';
343
+ else if (pTable.Status === 'Pending') tmpBarColor = '#adb5bd';
344
+
345
+ // Status badge
346
+ let tmpStatusBadge = pTable.Status;
347
+ if (pTable.Status === 'Complete' && pTable.Total === 0) tmpStatusBadge = 'Complete (empty)';
348
+ if (pTable.Status === 'Partial') tmpStatusBadge = 'Partial \u26A0';
349
+ if (pTable.Status === 'Error') tmpStatusBadge = 'Error \u2716';
350
+
351
+ // Details column
352
+ let tmpDetails = '';
353
+ if (pTable.ErrorMessage) tmpDetails = pTable.ErrorMessage;
354
+ else if (pTable.Skipped > 0) tmpDetails = pTable.Skipped + ' record(s) skipped';
355
+ else if ((pTable.Errors || 0) > 0) tmpDetails = pTable.Errors + ' error(s)';
356
+ else if (pTable.Status === 'Complete' && pTable.Total === 0) tmpDetails = 'No records on server';
357
+ else if (pTable.Status === 'Complete') tmpDetails = '\u2714 OK';
358
+
359
+ let tmpRow = '<tr>';
360
+ tmpRow += '<td><strong>' + pName + '</strong></td>';
361
+ tmpRow += '<td>' + tmpStatusBadge + '</td>';
362
+ tmpRow += '<td>';
363
+ tmpRow += '<div class="progress-bar-container"><div class="progress-bar-fill" style="width:' + tmpPct + '%; background:' + tmpBarColor + '"></div></div>';
364
+ tmpRow += ' ' + tmpPct + '%';
365
+ tmpRow += '</td>';
366
+ tmpRow += '<td>' + pTable.Synced + ' / ' + pTable.Total + '</td>';
367
+ tmpRow += '<td>' + tmpDetails + '</td>';
368
+ tmpRow += '</tr>';
369
+ return tmpRow;
370
+ };
371
+
372
+ // === SYNCING — currently active ===
373
+ if (tmpSyncing.length > 0)
374
+ {
375
+ tmpHtml += '<div class="sync-section-header">Syncing</div>';
376
+ tmpHtml += '<table class="progress-table">';
377
+ tmpHtml += '<tr><th>Table</th><th>Status</th><th>Progress</th><th>Synced</th><th>Details</th></tr>';
378
+ for (let i = 0; i < tmpSyncing.length; i++)
379
+ {
380
+ tmpHtml += fRenderRow(tmpSyncing[i].Name, tmpSyncing[i].Data);
381
+ }
382
+ tmpHtml += '</table>';
383
+ }
384
+
385
+ // === NEXT UP — pending tables in queue order ===
386
+ if (tmpPending.length > 0)
387
+ {
388
+ tmpHtml += '<div class="sync-section-header">Next Up <span class="sync-section-count">' + tmpPending.length + '</span></div>';
389
+ // Show at most 8 upcoming; collapse the rest
390
+ let tmpShowCount = Math.min(8, tmpPending.length);
391
+ tmpHtml += '<table class="progress-table progress-table-muted">';
392
+ for (let i = 0; i < tmpShowCount; i++)
393
+ {
394
+ tmpHtml += '<tr><td>' + tmpPending[i].Name + '</td>';
395
+ if (tmpPending[i].Data.Total > 0)
396
+ {
397
+ tmpHtml += '<td class="sync-pending-count">' + tmpPending[i].Data.Total.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') + ' records</td>';
398
+ }
399
+ else
400
+ {
401
+ tmpHtml += '<td class="sync-pending-count">—</td>';
402
+ }
403
+ tmpHtml += '</tr>';
404
+ }
405
+ tmpHtml += '</table>';
406
+ if (tmpPending.length > tmpShowCount)
407
+ {
408
+ tmpHtml += '<div class="sync-section-overflow">+ ' + (tmpPending.length - tmpShowCount) + ' more table' + (tmpPending.length - tmpShowCount === 1 ? '' : 's') + '</div>';
409
+ }
410
+ }
411
+
412
+ // === ERRORS — failed or partial ===
413
+ if (tmpErrors.length > 0)
414
+ {
415
+ tmpHtml += '<div class="sync-section-header sync-section-header-error">Errors <span class="sync-section-count">' + tmpErrors.length + '</span></div>';
416
+ tmpHtml += '<table class="progress-table">';
417
+ tmpHtml += '<tr><th>Table</th><th>Status</th><th>Progress</th><th>Synced</th><th>Details</th></tr>';
418
+ for (let i = 0; i < tmpErrors.length; i++)
419
+ {
420
+ tmpHtml += fRenderRow(tmpErrors[i].Name, tmpErrors[i].Data);
421
+ }
422
+ tmpHtml += '</table>';
423
+ }
424
+
425
+ // === COMPLETED — successful tables ===
426
+ if (tmpCompleted.length > 0)
427
+ {
428
+ tmpHtml += '<div class="sync-section-header sync-section-header-ok">Completed <span class="sync-section-count">' + tmpCompleted.length + '</span></div>';
429
+ tmpHtml += '<table class="progress-table">';
430
+ tmpHtml += '<tr><th>Table</th><th>Status</th><th>Progress</th><th>Synced</th><th>Details</th></tr>';
431
+ for (let i = 0; i < tmpCompleted.length; i++)
432
+ {
433
+ tmpHtml += fRenderRow(tmpCompleted[i].Name, tmpCompleted[i].Data);
434
+ }
435
+ tmpHtml += '</table>';
436
+ }
437
+
438
+ tmpContainer.innerHTML = tmpHtml;
439
+ }
440
+ }
441
+
442
+ module.exports = DataClonerSyncView;
443
+
444
+ module.exports.default_configuration =
445
+ {
446
+ ViewIdentifier: 'DataCloner-Sync',
447
+ DefaultRenderable: 'DataCloner-Sync',
448
+ DefaultDestinationAddress: '#DataCloner-Section-Sync',
449
+ CSS: /*css*/`
450
+ .progress-table { width: 100%; border-collapse: collapse; margin-top: 4px; margin-bottom: 4px; }
451
+ .progress-table th, .progress-table td { text-align: left; padding: 6px 12px; border-bottom: 1px solid #eee; font-size: 0.9em; }
452
+ .progress-table th { background: #f8f9fa; font-weight: 600; }
453
+ .progress-table-muted td { color: #888; padding: 3px 12px; font-size: 0.85em; border-bottom: 1px solid #f4f5f6; }
454
+ .progress-bar-container { width: 120px; height: 16px; background: #e9ecef; border-radius: 8px; overflow: hidden; display: inline-block; vertical-align: middle; }
455
+ .progress-bar-fill { height: 100%; background: #28a745; transition: width 0.3s; }
456
+ .sync-section-header { font-size: 0.8em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: #4a90d9; padding: 10px 0 2px 0; margin-top: 6px; border-top: 1px solid #e0e0e0; }
457
+ .sync-section-header:first-child { border-top: none; margin-top: 10px; }
458
+ .sync-section-header-error { color: #dc3545; }
459
+ .sync-section-header-ok { color: #28a745; }
460
+ .sync-section-count { font-weight: 400; color: #999; font-size: 0.95em; }
461
+ .sync-section-overflow { font-size: 0.8em; color: #aaa; padding: 2px 12px 6px; }
462
+ .sync-pending-count { text-align: right; color: #aaa; font-size: 0.85em; }
463
+ .report-card { background: #f8f9fa; border-radius: 8px; padding: 12px 16px; min-width: 140px; text-align: center; border: 1px solid #e9ecef; }
464
+ .report-card .card-label { font-size: 0.8em; color: #666; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
465
+ .report-card .card-value { font-size: 1.4em; font-weight: 700; }
466
+ .report-card.outcome-success { border-left: 4px solid #28a745; }
467
+ .report-card.outcome-partial { border-left: 4px solid #ffc107; }
468
+ .report-card.outcome-error { border-left: 4px solid #dc3545; }
469
+ .report-card.outcome-stopped { border-left: 4px solid #6c757d; }
470
+ `,
471
+ Templates:
472
+ [
473
+ {
474
+ Hash: 'DataCloner-Sync',
475
+ Template: /*html*/`
476
+ <div class="accordion-row">
477
+ <div class="accordion-number">5</div>
478
+ <div class="accordion-card" id="section5" data-section="5">
479
+ <div class="accordion-header" onclick="pict.views['DataCloner-Layout'].toggleSection('section5')">
480
+ <div class="accordion-title">Synchronize Data</div>
481
+ <span class="accordion-phase" id="phase5"></span>
482
+ <div class="accordion-preview" id="preview5">Initial sync, page size 100</div>
483
+ <div class="accordion-actions">
484
+ <span class="accordion-go" onclick="event.stopPropagation(); pict.views['DataCloner-Sync'].startSync()">go</span>
485
+ <label class="accordion-auto" onclick="event.stopPropagation()"><input type="checkbox" id="auto5"> <span class="auto-label">auto</span></label>
486
+ </div>
487
+ <div class="accordion-toggle">&#9660;</div>
488
+ </div>
489
+ <div class="accordion-body">
490
+ <div style="display:flex; gap:8px; align-items:flex-end; margin-bottom:4px">
491
+ <div style="flex:0 0 150px">
492
+ <label for="pageSize">Page Size</label>
493
+ <input type="number" id="pageSize" value="100" min="1" max="10000" style="margin-bottom:0">
494
+ </div>
495
+ <div style="flex:0 0 220px">
496
+ <label for="dateTimePrecisionMS">Timestamp Precision (ms)</label>
497
+ <input type="number" id="dateTimePrecisionMS" value="1000" min="0" max="60000" style="margin-bottom:0">
498
+ </div>
499
+ <div style="flex:0 0 auto; display:flex; gap:8px">
500
+ <button class="success" style="margin:0" onclick="pict.views['DataCloner-Sync'].startSync()">Start Sync</button>
501
+ <button class="danger" style="margin:0" onclick="pict.views['DataCloner-Sync'].stopSync()">Stop Sync</button>
502
+ </div>
503
+ </div>
504
+ <div style="font-size:0.8em; color:#888; margin-bottom:10px; padding-left:158px">Cross-DB tolerance for date comparison (default: 1000ms)</div>
505
+
506
+ <div style="margin-bottom:10px">
507
+ <label style="margin-bottom:6px">Sync Mode</label>
508
+ <div style="display:flex; gap:16px; align-items:center">
509
+ <label style="font-weight:normal; margin:0; cursor:pointer">
510
+ <input type="radio" name="syncMode" id="syncModeInitial" value="Initial" checked> Initial
511
+ <span style="color:#888; font-size:0.85em">(full clone — download all records)</span>
512
+ </label>
513
+ <label style="font-weight:normal; margin:0; cursor:pointer">
514
+ <input type="radio" name="syncMode" id="syncModeOngoing" value="Ongoing"> Ongoing
515
+ <span style="color:#888; font-size:0.85em">(delta — only new/updated records since last sync)</span>
516
+ </label>
517
+ </div>
518
+ </div>
519
+
520
+ <div class="checkbox-row">
521
+ <input type="checkbox" id="syncDeletedRecords">
522
+ <label for="syncDeletedRecords">Sync deleted records (fetch records marked Deleted=1 on source and mirror locally)</label>
523
+ </div>
524
+
525
+ <div class="inline-group" style="margin-top:8px; margin-bottom:4px">
526
+ <div style="flex:0 0 200px">
527
+ <label for="syncMaxRecords">Max Records per Entity</label>
528
+ <input type="number" id="syncMaxRecords" value="" min="0" placeholder="0 = unlimited" style="margin-bottom:0">
529
+ </div>
530
+ <div style="flex:0 0 auto; display:flex; align-items:flex-end; padding-bottom:2px">
531
+ <div class="checkbox-row" style="margin-bottom:0">
532
+ <input type="checkbox" id="syncLogFile" checked>
533
+ <label for="syncLogFile">Write log file</label>
534
+ </div>
535
+ </div>
536
+ </div>
537
+
538
+ <div id="syncStatus"></div>
539
+ <div id="syncProgress"></div>
540
+
541
+ <!-- Sync Report (appears after sync completes) -->
542
+ <div id="syncReportSection" style="display:none; margin-top:16px; padding-top:16px; border-top:2px solid #ddd">
543
+ <h3 style="margin:0 0 12px; font-size:1.1em">Sync Report</h3>
544
+
545
+ <!-- Summary cards -->
546
+ <div id="reportSummaryCards" style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:16px"></div>
547
+
548
+ <!-- Anomalies -->
549
+ <div id="reportAnomalies" style="margin-bottom:16px"></div>
550
+
551
+ <!-- Top tables by duration -->
552
+ <div id="reportTopTables" style="margin-bottom:16px"></div>
553
+
554
+ <!-- Buttons -->
555
+ <div style="display:flex; gap:8px">
556
+ <button class="secondary" onclick="pict.views['DataCloner-Sync'].downloadReport()">Download Report JSON</button>
557
+ <button class="secondary" onclick="pict.views['DataCloner-Sync'].copyReport()">Copy Report</button>
558
+ </div>
559
+ <div id="reportStatus"></div>
560
+ </div>
561
+ </div>
562
+ </div>
563
+ </div>
564
+ `
565
+ }
566
+ ],
567
+ Renderables:
568
+ [
569
+ {
570
+ RenderableHash: 'DataCloner-Sync',
571
+ TemplateHash: 'DataCloner-Sync',
572
+ DestinationAddress: '#DataCloner-Section-Sync'
573
+ }
574
+ ]
575
+ };