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,998 @@
1
+ const libPictProvider = require('pict-provider');
2
+
3
+ class DataClonerProvider extends libPictProvider
4
+ {
5
+ constructor(pFable, pOptions, pServiceHash)
6
+ {
7
+ super(pFable, pOptions, pServiceHash);
8
+ }
9
+
10
+ // ================================================================
11
+ // API Helper
12
+ // ================================================================
13
+
14
+ api(pMethod, pPath, pBody)
15
+ {
16
+ let tmpOpts = { method: pMethod, headers: {} };
17
+ if (pBody)
18
+ {
19
+ tmpOpts.headers['Content-Type'] = 'application/json';
20
+ tmpOpts.body = JSON.stringify(pBody);
21
+ }
22
+ return fetch(pPath, tmpOpts).then(function(pResponse) { return pResponse.json(); });
23
+ }
24
+
25
+ setStatus(pElementId, pMessage, pType)
26
+ {
27
+ let tmpEl = document.getElementById(pElementId);
28
+ if (!tmpEl) return;
29
+ tmpEl.className = 'status ' + (pType || 'info');
30
+ tmpEl.textContent = pMessage;
31
+ tmpEl.style.display = 'block';
32
+ }
33
+
34
+ escapeHtml(pStr)
35
+ {
36
+ let tmpDiv = document.createElement('div');
37
+ tmpDiv.appendChild(document.createTextNode(pStr));
38
+ return tmpDiv.innerHTML;
39
+ }
40
+
41
+ // ================================================================
42
+ // Phase status indicators
43
+ // ================================================================
44
+
45
+ setSectionPhase(pSection, pState)
46
+ {
47
+ let tmpEl = document.getElementById('phase' + pSection);
48
+ if (!tmpEl) return;
49
+
50
+ tmpEl.className = 'accordion-phase';
51
+
52
+ if (pState === 'ok')
53
+ {
54
+ tmpEl.innerHTML = '✓';
55
+ tmpEl.classList.add('visible', 'accordion-phase-ok');
56
+ }
57
+ else if (pState === 'error')
58
+ {
59
+ tmpEl.innerHTML = '✗';
60
+ tmpEl.classList.add('visible', 'accordion-phase-error');
61
+ }
62
+ else if (pState === 'busy')
63
+ {
64
+ tmpEl.innerHTML = '<span class="phase-spinner"></span>';
65
+ tmpEl.classList.add('visible', 'accordion-phase-busy');
66
+ }
67
+ else
68
+ {
69
+ tmpEl.innerHTML = '';
70
+ }
71
+ }
72
+
73
+ // ================================================================
74
+ // Accordion Previews
75
+ // ================================================================
76
+
77
+ updateAllPreviews()
78
+ {
79
+ // Section 1 — Database Connection
80
+ let tmpProvider = document.getElementById('connProvider');
81
+ if (!tmpProvider) return;
82
+ tmpProvider = tmpProvider.value;
83
+ let tmpPreview1 = tmpProvider;
84
+ if (tmpProvider === 'SQLite')
85
+ {
86
+ let tmpPath = document.getElementById('sqliteFilePath').value || 'data/cloned.sqlite';
87
+ tmpPreview1 = 'SQLite at ' + tmpPath;
88
+ }
89
+ else if (tmpProvider === 'MySQL')
90
+ {
91
+ let tmpHost = document.getElementById('mysqlServer').value || '127.0.0.1';
92
+ let tmpPort = document.getElementById('mysqlPort').value || '3306';
93
+ let tmpUser = document.getElementById('mysqlUser').value || 'root';
94
+ tmpPreview1 = 'MySQL on ' + tmpHost + ':' + tmpPort + ' as ' + tmpUser;
95
+ }
96
+ else if (tmpProvider === 'MSSQL')
97
+ {
98
+ let tmpHost = document.getElementById('mssqlServer').value || '127.0.0.1';
99
+ let tmpPort = document.getElementById('mssqlPort').value || '1433';
100
+ let tmpUser = document.getElementById('mssqlUser').value || 'sa';
101
+ tmpPreview1 = 'MSSQL on ' + tmpHost + ':' + tmpPort + ' as ' + tmpUser;
102
+ }
103
+ else if (tmpProvider === 'PostgreSQL')
104
+ {
105
+ let tmpHost = document.getElementById('postgresqlHost').value || '127.0.0.1';
106
+ let tmpPort = document.getElementById('postgresqlPort').value || '5432';
107
+ let tmpUser = document.getElementById('postgresqlUser').value || 'postgres';
108
+ tmpPreview1 = 'PostgreSQL on ' + tmpHost + ':' + tmpPort + ' as ' + tmpUser;
109
+ }
110
+ else if (tmpProvider === 'MongoDB')
111
+ {
112
+ let tmpHost = document.getElementById('mongodbHost').value || '127.0.0.1';
113
+ let tmpPort = document.getElementById('mongodbPort').value || '27017';
114
+ tmpPreview1 = 'MongoDB on ' + tmpHost + ':' + tmpPort;
115
+ }
116
+ else if (tmpProvider === 'Solr')
117
+ {
118
+ let tmpHost = document.getElementById('solrHost').value || '127.0.0.1';
119
+ let tmpPort = document.getElementById('solrPort').value || '8983';
120
+ tmpPreview1 = 'Solr on ' + tmpHost + ':' + tmpPort;
121
+ }
122
+ else if (tmpProvider === 'RocksDB')
123
+ {
124
+ let tmpFolder = document.getElementById('rocksdbFolder').value || 'data/rocksdb';
125
+ tmpPreview1 = 'RocksDB at ' + tmpFolder;
126
+ }
127
+ else if (tmpProvider === 'Bibliograph')
128
+ {
129
+ let tmpFolder = document.getElementById('bibliographFolder').value || 'data/bibliograph';
130
+ tmpPreview1 = 'Bibliograph at ' + tmpFolder;
131
+ }
132
+ document.getElementById('preview1').textContent = tmpPreview1;
133
+
134
+ // Section 2 — Remote Session
135
+ let tmpServerURL = document.getElementById('serverURL').value;
136
+ let tmpUserName = document.getElementById('userName').value;
137
+ if (tmpServerURL)
138
+ {
139
+ let tmpPreview2 = tmpServerURL;
140
+ if (tmpUserName) tmpPreview2 += ' as ' + tmpUserName;
141
+ document.getElementById('preview2').textContent = tmpPreview2;
142
+ }
143
+ else
144
+ {
145
+ document.getElementById('preview2').textContent = 'Configure remote server URL and credentials';
146
+ }
147
+
148
+ // Section 3 — Remote Schema
149
+ let tmpTableChecks = document.querySelectorAll('#tableList input[type="checkbox"]:checked');
150
+ if (tmpTableChecks.length > 0)
151
+ {
152
+ document.getElementById('preview3').textContent = tmpTableChecks.length + ' table' + (tmpTableChecks.length === 1 ? '' : 's') + ' selected';
153
+ }
154
+ else
155
+ {
156
+ let tmpSchemaURL = document.getElementById('schemaURL').value;
157
+ if (tmpSchemaURL)
158
+ {
159
+ document.getElementById('preview3').textContent = 'Schema from ' + tmpSchemaURL;
160
+ }
161
+ else
162
+ {
163
+ document.getElementById('preview3').textContent = 'Fetch and select tables from the remote server';
164
+ }
165
+ }
166
+
167
+ // Section 4 — Deploy Schema
168
+ let tmpDeployedEl = document.getElementById('deployStatus');
169
+ let tmpDeployedText = tmpDeployedEl ? tmpDeployedEl.textContent : '';
170
+ if (tmpDeployedText && tmpDeployedText.indexOf('deployed') !== -1)
171
+ {
172
+ document.getElementById('preview4').textContent = tmpDeployedText;
173
+ }
174
+ else
175
+ {
176
+ document.getElementById('preview4').textContent = 'Create selected tables in the local database';
177
+ }
178
+
179
+ // Section 5 — Synchronize Data
180
+ let tmpSyncMode = document.querySelector('input[name="syncMode"]:checked');
181
+ let tmpModeName = tmpSyncMode ? tmpSyncMode.value : 'Initial';
182
+ let tmpPageSize = document.getElementById('pageSize').value || '100';
183
+ let tmpSyncPreview = tmpModeName + ' sync, page size ' + tmpPageSize;
184
+ let tmpDeleted = document.getElementById('syncDeletedRecords').checked;
185
+ if (tmpDeleted) tmpSyncPreview += ', including deleted';
186
+ document.getElementById('preview5').textContent = tmpSyncPreview;
187
+
188
+ // Section 6 — Export Configuration
189
+ let tmpMaxRecords = document.getElementById('syncMaxRecords').value;
190
+ let tmpLogFile = document.getElementById('syncLogFile').checked;
191
+ let tmpExportParts = [];
192
+ if (tmpMaxRecords && parseInt(tmpMaxRecords, 10) > 0) tmpExportParts.push('max ' + tmpMaxRecords + ' records');
193
+ if (tmpLogFile) tmpExportParts.push('log enabled');
194
+ else tmpExportParts.push('log disabled');
195
+ document.getElementById('preview6').textContent = tmpExportParts.length > 0 ? 'Export: ' + tmpExportParts.join(', ') : 'Generate JSON config for headless cloning';
196
+
197
+ // Section 7 — View Data
198
+ let tmpViewTable = document.getElementById('viewTable').value;
199
+ if (tmpViewTable)
200
+ {
201
+ document.getElementById('preview7').textContent = 'Viewing ' + tmpViewTable;
202
+ }
203
+ else
204
+ {
205
+ document.getElementById('preview7').textContent = 'Browse synced table data';
206
+ }
207
+ }
208
+
209
+ initAccordionPreviews()
210
+ {
211
+ let tmpSelf = this;
212
+
213
+ let tmpPreviewFields = [
214
+ 'connProvider', 'sqliteFilePath',
215
+ 'mysqlServer', 'mysqlPort', 'mysqlUser',
216
+ 'mssqlServer', 'mssqlPort', 'mssqlUser',
217
+ 'postgresqlHost', 'postgresqlPort', 'postgresqlUser',
218
+ 'mongodbHost', 'mongodbPort',
219
+ 'solrHost', 'solrPort',
220
+ 'rocksdbFolder', 'bibliographFolder',
221
+ 'serverURL', 'userName',
222
+ 'schemaURL',
223
+ 'pageSize', 'dateTimePrecisionMS',
224
+ 'syncMaxRecords',
225
+ 'viewTable', 'viewLimit'
226
+ ];
227
+
228
+ let tmpHandler = function() { tmpSelf.updateAllPreviews(); };
229
+
230
+ for (let i = 0; i < tmpPreviewFields.length; i++)
231
+ {
232
+ let tmpEl = document.getElementById(tmpPreviewFields[i]);
233
+ if (tmpEl)
234
+ {
235
+ tmpEl.addEventListener('input', tmpHandler);
236
+ tmpEl.addEventListener('change', tmpHandler);
237
+ }
238
+ }
239
+
240
+ // Checkboxes and radios
241
+ let tmpCheckboxes = ['syncDeletedRecords', 'syncLogFile'];
242
+ for (let i = 0; i < tmpCheckboxes.length; i++)
243
+ {
244
+ let tmpEl = document.getElementById(tmpCheckboxes[i]);
245
+ if (tmpEl) tmpEl.addEventListener('change', tmpHandler);
246
+ }
247
+
248
+ document.querySelectorAll('input[name="syncMode"]').forEach(function(pEl)
249
+ {
250
+ pEl.addEventListener('change', tmpHandler);
251
+ });
252
+ }
253
+
254
+ // ================================================================
255
+ // LocalStorage Persistence
256
+ // ================================================================
257
+
258
+ saveField(pFieldId)
259
+ {
260
+ let tmpEl = document.getElementById(pFieldId);
261
+ if (tmpEl)
262
+ {
263
+ localStorage.setItem('dataCloner_' + pFieldId, tmpEl.value);
264
+ }
265
+ }
266
+
267
+ restoreFields()
268
+ {
269
+ let tmpPersistFields = this.pict.AppData.DataCloner.PersistFields;
270
+ for (let i = 0; i < tmpPersistFields.length; i++)
271
+ {
272
+ let tmpId = tmpPersistFields[i];
273
+ let tmpSaved = localStorage.getItem('dataCloner_' + tmpId);
274
+ if (tmpSaved !== null)
275
+ {
276
+ let tmpEl = document.getElementById(tmpId);
277
+ if (tmpEl) tmpEl.value = tmpSaved;
278
+ }
279
+ }
280
+
281
+ // Restore checkbox state
282
+ let tmpSyncDeleted = localStorage.getItem('dataCloner_syncDeletedRecords');
283
+ if (tmpSyncDeleted !== null)
284
+ {
285
+ document.getElementById('syncDeletedRecords').checked = tmpSyncDeleted === 'true';
286
+ }
287
+ // Restore sync mode
288
+ let tmpSyncMode = localStorage.getItem('dataCloner_syncMode');
289
+ if (tmpSyncMode === 'Ongoing')
290
+ {
291
+ document.getElementById('syncModeOngoing').checked = true;
292
+ }
293
+ let tmpSolrSecure = localStorage.getItem('dataCloner_solrSecure');
294
+ if (tmpSolrSecure !== null)
295
+ {
296
+ document.getElementById('solrSecure').checked = tmpSolrSecure === 'true';
297
+ }
298
+ }
299
+
300
+ initPersistence()
301
+ {
302
+ let tmpSelf = this;
303
+ this.restoreFields();
304
+
305
+ let tmpPersistFields = this.pict.AppData.DataCloner.PersistFields;
306
+ for (let i = 0; i < tmpPersistFields.length; i++)
307
+ {
308
+ (function(pId)
309
+ {
310
+ let tmpEl = document.getElementById(pId);
311
+ if (tmpEl)
312
+ {
313
+ tmpEl.addEventListener('input', function() { tmpSelf.saveField(pId); });
314
+ tmpEl.addEventListener('change', function() { tmpSelf.saveField(pId); });
315
+ }
316
+ })(tmpPersistFields[i]);
317
+ }
318
+
319
+ // Persist sync deleted checkbox
320
+ let tmpSyncDeletedEl = document.getElementById('syncDeletedRecords');
321
+ if (tmpSyncDeletedEl)
322
+ {
323
+ tmpSyncDeletedEl.addEventListener('change', function()
324
+ {
325
+ localStorage.setItem('dataCloner_syncDeletedRecords', this.checked);
326
+ });
327
+ }
328
+
329
+ // Persist sync mode radio
330
+ document.querySelectorAll('input[name="syncMode"]').forEach(function(pEl)
331
+ {
332
+ pEl.addEventListener('change', function()
333
+ {
334
+ localStorage.setItem('dataCloner_syncMode', this.value);
335
+ });
336
+ });
337
+
338
+ // Persist solr secure checkbox
339
+ let tmpSolrSecureEl = document.getElementById('solrSecure');
340
+ if (tmpSolrSecureEl)
341
+ {
342
+ tmpSolrSecureEl.addEventListener('change', function()
343
+ {
344
+ localStorage.setItem('dataCloner_solrSecure', this.checked);
345
+ });
346
+ }
347
+
348
+ // Persist auto-process checkboxes
349
+ let tmpAutoIds = ['auto1', 'auto2', 'auto3', 'auto4', 'auto5'];
350
+ for (let a = 0; a < tmpAutoIds.length; a++)
351
+ {
352
+ (function(pId)
353
+ {
354
+ let tmpEl = document.getElementById(pId);
355
+ if (tmpEl)
356
+ {
357
+ let tmpSaved = localStorage.getItem('dataCloner_' + pId);
358
+ if (tmpSaved !== null) tmpEl.checked = tmpSaved === 'true';
359
+ tmpEl.addEventListener('change', function()
360
+ {
361
+ localStorage.setItem('dataCloner_' + pId, this.checked);
362
+ });
363
+ }
364
+ })(tmpAutoIds[a]);
365
+ }
366
+ }
367
+
368
+ // ================================================================
369
+ // Live Status Indicator
370
+ // ================================================================
371
+
372
+ startLiveStatusPolling()
373
+ {
374
+ let tmpAppData = this.pict.AppData.DataCloner;
375
+ if (tmpAppData.LiveStatusTimer) clearInterval(tmpAppData.LiveStatusTimer);
376
+ this.pollLiveStatus();
377
+ let tmpSelf = this;
378
+ tmpAppData.LiveStatusTimer = setInterval(function() { tmpSelf.pollLiveStatus(); }, 1500);
379
+ }
380
+
381
+ pollLiveStatus()
382
+ {
383
+ let tmpSelf = this;
384
+ this.api('GET', '/clone/sync/live-status')
385
+ .then(function(pData)
386
+ {
387
+ tmpSelf.renderLiveStatus(pData);
388
+ })
389
+ .catch(function()
390
+ {
391
+ tmpSelf.renderLiveStatus({ Phase: 'disconnected', Message: 'Cannot reach server', TotalSynced: 0, TotalRecords: 0 });
392
+ });
393
+ }
394
+
395
+ renderLiveStatus(pData)
396
+ {
397
+ // Cache the live status data for the detail view
398
+ this.pict.AppData.DataCloner.LastLiveStatus = pData;
399
+
400
+ let tmpBar = document.getElementById('liveStatusBar');
401
+ let tmpMsg = document.getElementById('liveStatusMessage');
402
+ let tmpMeta = document.getElementById('liveStatusMeta');
403
+ let tmpProgressFill = document.getElementById('liveStatusProgressFill');
404
+ if (!tmpBar) return;
405
+
406
+ // Update phase class (preserve expanded class if present)
407
+ let tmpWasExpanded = tmpBar.classList.contains('expanded');
408
+ tmpBar.className = 'live-status-bar phase-' + (pData.Phase || 'idle');
409
+ if (tmpWasExpanded) tmpBar.classList.add('expanded');
410
+
411
+ // Update message
412
+ tmpMsg.textContent = pData.Message || 'Idle';
413
+
414
+ // Update meta info
415
+ let tmpMetaParts = [];
416
+ if (pData.Phase === 'syncing' || pData.Phase === 'stopping')
417
+ {
418
+ if (pData.Elapsed)
419
+ {
420
+ tmpMetaParts.push('<span class="live-status-meta-item">\u23F1 ' + pData.Elapsed + '</span>');
421
+ }
422
+ if (pData.ETA)
423
+ {
424
+ tmpMetaParts.push('<span class="live-status-meta-item">~' + pData.ETA + ' remaining</span>');
425
+ }
426
+ if (pData.TotalTables > 0)
427
+ {
428
+ tmpMetaParts.push('<span class="live-status-meta-item"><strong>' + pData.Completed + '</strong> / ' + pData.TotalTables + ' tables</span>');
429
+ }
430
+ if (pData.TotalSynced > 0)
431
+ {
432
+ let tmpSynced = pData.TotalSynced.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
433
+ if (pData.PreCountGrandTotal > 0)
434
+ {
435
+ let tmpGrandTotal = pData.PreCountGrandTotal.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
436
+ tmpMetaParts.push('<span class="live-status-meta-item"><strong>' + tmpSynced + '</strong> / ' + tmpGrandTotal + ' records</span>');
437
+ }
438
+ else
439
+ {
440
+ tmpMetaParts.push('<span class="live-status-meta-item"><strong>' + tmpSynced + '</strong> records</span>');
441
+ }
442
+ }
443
+ else if (pData.PreCountGrandTotal > 0)
444
+ {
445
+ let tmpGrandTotal = pData.PreCountGrandTotal.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
446
+ tmpMetaParts.push('<span class="live-status-meta-item">' + tmpGrandTotal + ' records to sync</span>');
447
+ }
448
+ if (pData.PreCountProgress && pData.PreCountProgress.Counted < pData.PreCountProgress.TotalTables)
449
+ {
450
+ tmpMetaParts.push('<span class="live-status-meta-item">counting: ' + pData.PreCountProgress.Counted + ' / ' + pData.PreCountProgress.TotalTables + '</span>');
451
+ }
452
+ if (pData.Errors > 0)
453
+ {
454
+ tmpMetaParts.push('<span class="live-status-meta-item" style="color:#dc3545"><strong>' + pData.Errors + '</strong> error' + (pData.Errors === 1 ? '' : 's') + '</span>');
455
+ }
456
+ }
457
+ else if (pData.Phase === 'complete')
458
+ {
459
+ if (pData.TotalSynced > 0)
460
+ {
461
+ let tmpSynced = pData.TotalSynced.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
462
+ tmpMetaParts.push('<span class="live-status-meta-item"><strong>' + tmpSynced + '</strong> records synced</span>');
463
+ }
464
+ }
465
+ tmpMeta.innerHTML = tmpMetaParts.join('');
466
+
467
+ // Update progress bar
468
+ let tmpPct = 0;
469
+ if (pData.Phase === 'syncing' && pData.PreCountGrandTotal > 0 && pData.TotalSynced > 0)
470
+ {
471
+ tmpPct = Math.min((pData.TotalSynced / pData.PreCountGrandTotal) * 100, 99.9);
472
+ }
473
+ else if (pData.Phase === 'syncing' && pData.TotalTables > 0)
474
+ {
475
+ let tmpTablePct = (pData.Completed / pData.TotalTables) * 100;
476
+ if (pData.ActiveProgress && pData.ActiveProgress.Total > 0)
477
+ {
478
+ let tmpEntityPct = (pData.ActiveProgress.Synced / pData.ActiveProgress.Total) * (100 / pData.TotalTables);
479
+ tmpPct = tmpTablePct + tmpEntityPct;
480
+ }
481
+ else
482
+ {
483
+ tmpPct = tmpTablePct;
484
+ }
485
+ }
486
+ else if (pData.Phase === 'complete')
487
+ {
488
+ tmpPct = 100;
489
+ }
490
+ tmpProgressFill.style.width = Math.min(100, Math.round(tmpPct)) + '%';
491
+
492
+ // If the detail view is expanded, re-render it with fresh data
493
+ if (this.pict.AppData.DataCloner.StatusDetailExpanded)
494
+ {
495
+ this.renderStatusDetail();
496
+ }
497
+
498
+ // Auto-fetch the sync report when we detect a completed sync but haven't loaded the report yet
499
+ if (pData.Phase === 'complete' && !this.pict.AppData.DataCloner.LastReport)
500
+ {
501
+ let tmpSelf = this;
502
+ this.api('GET', '/clone/sync/report')
503
+ .then(function(pReportData)
504
+ {
505
+ if (pReportData && pReportData.ReportVersion)
506
+ {
507
+ tmpSelf.pict.AppData.DataCloner.LastReport = pReportData;
508
+ if (tmpSelf.pict.AppData.DataCloner.StatusDetailExpanded)
509
+ {
510
+ tmpSelf.renderStatusDetail();
511
+ }
512
+ }
513
+ })
514
+ .catch(function() { /* ignore fetch errors */ });
515
+ }
516
+ }
517
+
518
+ // ================================================================
519
+ // Status Detail Expansion
520
+ // ================================================================
521
+
522
+ onStatusDetailExpanded()
523
+ {
524
+ let tmpAppData = this.pict.AppData.DataCloner;
525
+ tmpAppData.StatusDetailExpanded = true;
526
+
527
+ // Immediate render from whatever data we have
528
+ this.renderStatusDetail();
529
+
530
+ // Start detail polling (poll /sync/status for per-table data)
531
+ if (tmpAppData.StatusDetailTimer) clearInterval(tmpAppData.StatusDetailTimer);
532
+ let tmpSelf = this;
533
+ tmpAppData.StatusDetailTimer = setInterval(function() { tmpSelf.pollStatusDetail(); }, 2000);
534
+ this.pollStatusDetail();
535
+ }
536
+
537
+ onStatusDetailCollapsed()
538
+ {
539
+ let tmpAppData = this.pict.AppData.DataCloner;
540
+ tmpAppData.StatusDetailExpanded = false;
541
+
542
+ if (tmpAppData.StatusDetailTimer)
543
+ {
544
+ clearInterval(tmpAppData.StatusDetailTimer);
545
+ tmpAppData.StatusDetailTimer = null;
546
+ }
547
+ }
548
+
549
+ pollStatusDetail()
550
+ {
551
+ let tmpSelf = this;
552
+ this.api('GET', '/clone/sync/status')
553
+ .then(function(pData)
554
+ {
555
+ tmpSelf.pict.AppData.DataCloner.StatusDetailData = pData;
556
+ tmpSelf.renderStatusDetail();
557
+ })
558
+ .catch(function() { /* ignore poll errors */ });
559
+ }
560
+
561
+ renderStatusDetail()
562
+ {
563
+ let tmpContainer = document.getElementById('DataCloner-StatusDetail-Container');
564
+ if (!tmpContainer) return;
565
+
566
+ let tmpAppData = this.pict.AppData.DataCloner;
567
+ let tmpLiveStatus = tmpAppData.LastLiveStatus;
568
+ let tmpStatusData = tmpAppData.StatusDetailData;
569
+ let tmpReport = tmpAppData.LastReport;
570
+
571
+ // Determine data source: live during sync, report after sync
572
+ let tmpTables = {};
573
+ let tmpThroughputSamples = [];
574
+ let tmpEventLog = [];
575
+ let tmpIsLive = false;
576
+
577
+ if (tmpLiveStatus && (tmpLiveStatus.Phase === 'syncing' || tmpLiveStatus.Phase === 'stopping'))
578
+ {
579
+ tmpIsLive = true;
580
+ if (tmpStatusData && tmpStatusData.Tables) tmpTables = tmpStatusData.Tables;
581
+ if (tmpLiveStatus.ThroughputSamples) tmpThroughputSamples = tmpLiveStatus.ThroughputSamples;
582
+ }
583
+ else if (tmpReport && tmpReport.ReportVersion)
584
+ {
585
+ // Build tables object from report
586
+ for (let i = 0; i < tmpReport.Tables.length; i++)
587
+ {
588
+ let tmpT = tmpReport.Tables[i];
589
+ tmpTables[tmpT.Name] = tmpT;
590
+ }
591
+ tmpThroughputSamples = tmpReport.ThroughputSamples || [];
592
+ tmpEventLog = tmpReport.EventLog || [];
593
+ }
594
+ else if (tmpStatusData && tmpStatusData.Tables)
595
+ {
596
+ tmpTables = tmpStatusData.Tables;
597
+ // Use throughput samples from live status if available (e.g. after page reload with completed sync)
598
+ if (tmpLiveStatus && tmpLiveStatus.ThroughputSamples)
599
+ {
600
+ tmpThroughputSamples = tmpLiveStatus.ThroughputSamples;
601
+ }
602
+ }
603
+
604
+ // Categorize tables
605
+ let tmpRunning = [];
606
+ let tmpPending = [];
607
+ let tmpCompleted = [];
608
+ let tmpErrors = [];
609
+ let tmpTableNames = Object.keys(tmpTables);
610
+
611
+ for (let i = 0; i < tmpTableNames.length; i++)
612
+ {
613
+ let tmpName = tmpTableNames[i];
614
+ let tmpT = tmpTables[tmpName];
615
+ if (tmpT.Status === 'Syncing')
616
+ {
617
+ tmpRunning.push({ Name: tmpName, Data: tmpT });
618
+ }
619
+ else if (tmpT.Status === 'Pending')
620
+ {
621
+ tmpPending.push(tmpName);
622
+ }
623
+ else if (tmpT.Status === 'Complete')
624
+ {
625
+ tmpCompleted.push({ Name: tmpName, Data: tmpT });
626
+ }
627
+ else if (tmpT.Status === 'Error' || tmpT.Status === 'Partial')
628
+ {
629
+ tmpErrors.push({ Name: tmpName, Data: tmpT });
630
+ }
631
+ }
632
+
633
+ let tmpHtml = '';
634
+
635
+ // === Section 1: Running Operations ===
636
+ if (tmpRunning.length > 0 || tmpPending.length > 0)
637
+ {
638
+ tmpHtml += '<div class="status-detail-section">';
639
+ tmpHtml += '<div class="status-detail-section-title">Running</div>';
640
+ for (let i = 0; i < tmpRunning.length; i++)
641
+ {
642
+ let tmpOp = tmpRunning[i];
643
+ let tmpPct = tmpOp.Data.Total > 0 ? Math.round((tmpOp.Data.Synced / tmpOp.Data.Total) * 100) : 0;
644
+ let tmpSyncedFmt = this.formatNumber(tmpOp.Data.Synced || 0);
645
+ let tmpTotalFmt = this.formatNumber(tmpOp.Data.Total || 0);
646
+ tmpHtml += '<div class="running-op-row">';
647
+ tmpHtml += ' <div class="running-op-name">' + this.escapeHtml(tmpOp.Name) + '</div>';
648
+ tmpHtml += ' <div class="running-op-bar"><div class="running-op-bar-fill" style="width:' + tmpPct + '%"></div></div>';
649
+ tmpHtml += ' <div class="running-op-count">' + tmpSyncedFmt + ' / ' + tmpTotalFmt + ' (' + tmpPct + '%)</div>';
650
+ tmpHtml += '</div>';
651
+ }
652
+ if (tmpPending.length > 0)
653
+ {
654
+ tmpHtml += '<div class="running-op-pending">' + tmpPending.length + ' table' + (tmpPending.length === 1 ? '' : 's') + ' waiting</div>';
655
+ }
656
+ tmpHtml += '</div>';
657
+ }
658
+
659
+ // === Section 2: Completed Successful Operations ===
660
+ if (tmpCompleted.length > 0)
661
+ {
662
+ tmpHtml += '<div class="status-detail-section">';
663
+ tmpHtml += '<div class="status-detail-section-title">Completed (' + tmpCompleted.length + ')</div>';
664
+
665
+ for (let i = 0; i < tmpCompleted.length; i++)
666
+ {
667
+ tmpHtml += this.renderCompletedRow(tmpCompleted[i]);
668
+ }
669
+ tmpHtml += '</div>';
670
+ }
671
+
672
+ // === Section 3: Unsuccessful Operations ===
673
+ if (tmpErrors.length > 0)
674
+ {
675
+ tmpHtml += '<div class="status-detail-section">';
676
+ tmpHtml += '<div class="status-detail-section-title">Errors (' + tmpErrors.length + ')</div>';
677
+ for (let i = 0; i < tmpErrors.length; i++)
678
+ {
679
+ tmpHtml += this.renderErrorRow(tmpErrors[i], tmpEventLog);
680
+ }
681
+ tmpHtml += '</div>';
682
+ }
683
+
684
+ if (tmpHtml === '')
685
+ {
686
+ if (tmpIsLive)
687
+ {
688
+ tmpHtml = '<div style="font-size:0.9em; color:#888; padding:8px 0">Sync in progress, waiting for table data\u2026</div>';
689
+ }
690
+ else
691
+ {
692
+ tmpHtml = '<div style="font-size:0.9em; color:#888; padding:8px 0">No sync data available. Run a sync to see operation details here.</div>';
693
+ }
694
+ }
695
+
696
+ tmpContainer.innerHTML = tmpHtml;
697
+
698
+ // Update the throughput histogram via pict-section-histogram
699
+ this.updateThroughputHistogram(tmpThroughputSamples);
700
+ }
701
+
702
+ updateThroughputHistogram(pSamples)
703
+ {
704
+ let tmpHistContainer = document.getElementById('DataCloner-Throughput-Histogram');
705
+ if (!tmpHistContainer) return;
706
+
707
+ if (!pSamples || pSamples.length < 2)
708
+ {
709
+ tmpHistContainer.style.display = 'none';
710
+ return;
711
+ }
712
+
713
+ // --- Step 1: Compute raw deltas per 10s interval ---
714
+ let tmpRawDeltas = [];
715
+ for (let i = 1; i < pSamples.length; i++)
716
+ {
717
+ let tmpDelta = pSamples[i].synced - pSamples[i - 1].synced;
718
+ if (tmpDelta < 0) tmpDelta = 0;
719
+ tmpRawDeltas.push({ delta: tmpDelta, t: pSamples[i].t });
720
+ }
721
+
722
+ // --- Step 2: Downsample if there are too many bars ---
723
+ let tmpContainerWidth = tmpHistContainer.clientWidth || 800;
724
+ let tmpMaxBars = Math.max(20, Math.floor(tmpContainerWidth / 6));
725
+ let tmpAggregated = tmpRawDeltas;
726
+
727
+ if (tmpRawDeltas.length > tmpMaxBars)
728
+ {
729
+ let tmpBucketSize = Math.ceil(tmpRawDeltas.length / tmpMaxBars);
730
+ tmpAggregated = [];
731
+ for (let i = 0; i < tmpRawDeltas.length; i += tmpBucketSize)
732
+ {
733
+ let tmpSum = 0;
734
+ let tmpLastT = 0;
735
+ for (let j = i; j < Math.min(i + tmpBucketSize, tmpRawDeltas.length); j++)
736
+ {
737
+ tmpSum += tmpRawDeltas[j].delta;
738
+ tmpLastT = tmpRawDeltas[j].t;
739
+ }
740
+ tmpAggregated.push({ delta: tmpSum, t: tmpLastT });
741
+ }
742
+ }
743
+
744
+ // --- Step 3: Check for data ---
745
+ let tmpHasData = false;
746
+ for (let i = 0; i < tmpAggregated.length; i++)
747
+ {
748
+ if (tmpAggregated[i].delta > 0) { tmpHasData = true; break; }
749
+ }
750
+ if (!tmpHasData)
751
+ {
752
+ tmpHistContainer.style.display = 'none';
753
+ return;
754
+ }
755
+
756
+ // --- Step 4: Build bins for the histogram library ---
757
+ let tmpStartT = pSamples[0].t;
758
+ let tmpBins = [];
759
+ for (let i = 0; i < tmpAggregated.length; i++)
760
+ {
761
+ let tmpElapsedSec = Math.round((tmpAggregated[i].t - tmpStartT) / 1000);
762
+ tmpBins.push({
763
+ Label: this.formatElapsed(tmpElapsedSec),
764
+ Value: tmpAggregated[i].delta
765
+ });
766
+ }
767
+
768
+ // --- Step 5: Update the histogram view via the library ---
769
+ tmpHistContainer.style.display = '';
770
+ let tmpHistView = this.pict.views['DataCloner-StatusHistogram'];
771
+ if (tmpHistView)
772
+ {
773
+ tmpHistView.setBins(tmpBins);
774
+ tmpHistView.renderHistogram();
775
+ }
776
+ }
777
+
778
+ formatElapsed(pSec)
779
+ {
780
+ if (pSec < 60) return pSec + 's';
781
+ if (pSec < 3600)
782
+ {
783
+ let tmpM = Math.floor(pSec / 60);
784
+ let tmpS = pSec % 60;
785
+ return tmpM + ':' + (tmpS < 10 ? '0' : '') + tmpS;
786
+ }
787
+ let tmpH = Math.floor(pSec / 3600);
788
+ let tmpM = Math.floor((pSec % 3600) / 60);
789
+ return tmpH + 'h' + (tmpM < 10 ? '0' : '') + tmpM;
790
+ }
791
+
792
+ formatCompact(pNum)
793
+ {
794
+ if (pNum >= 1000000) return (pNum / 1000000).toFixed(1) + 'M';
795
+ if (pNum >= 10000) return (pNum / 1000).toFixed(0) + 'K';
796
+ if (pNum >= 1000) return (pNum / 1000).toFixed(1) + 'K';
797
+ return pNum.toString();
798
+ }
799
+
800
+ formatNumber(pNum)
801
+ {
802
+ return pNum.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
803
+ }
804
+
805
+ renderCompletedRow(pOp)
806
+ {
807
+ let tmpNew = pOp.Data.New || 0;
808
+ let tmpUpdated = pOp.Data.Updated || 0;
809
+ let tmpUnchanged = pOp.Data.Unchanged || 0;
810
+ let tmpDeleted = pOp.Data.Deleted || 0;
811
+ let tmpServerTotal = pOp.Data.ServerTotal || 0;
812
+
813
+ // Grand total for the ratio bar: all records the adapter dealt with
814
+ let tmpGrandTotal = tmpUnchanged + tmpNew + tmpUpdated + tmpDeleted;
815
+ if (tmpGrandTotal === 0 && tmpServerTotal > 0)
816
+ {
817
+ tmpGrandTotal = tmpServerTotal;
818
+ tmpUnchanged = tmpServerTotal;
819
+ }
820
+
821
+ let tmpHtml = '<div class="completed-op-row">';
822
+ tmpHtml += '<div class="completed-op-header">';
823
+ tmpHtml += ' <span class="completed-op-checkmark">\u2713</span>';
824
+ tmpHtml += ' <span class="completed-op-name">' + this.escapeHtml(pOp.Name) + '</span>';
825
+ tmpHtml += '</div>';
826
+
827
+ // Ratio bar: Unchanged / New / Updated / Deleted
828
+ if (tmpGrandTotal > 0)
829
+ {
830
+ let tmpUnchangedPct = Math.round((tmpUnchanged / tmpGrandTotal) * 100);
831
+ let tmpNewPct = Math.round((tmpNew / tmpGrandTotal) * 100);
832
+ let tmpUpdatedPct = Math.round((tmpUpdated / tmpGrandTotal) * 100);
833
+ let tmpDeletedPct = Math.round((tmpDeleted / tmpGrandTotal) * 100);
834
+
835
+ // Ensure percentages sum to 100
836
+ let tmpPctSum = tmpUnchangedPct + tmpNewPct + tmpUpdatedPct + tmpDeletedPct;
837
+ if (tmpPctSum !== 100 && tmpPctSum > 0)
838
+ {
839
+ tmpUnchangedPct += (100 - tmpPctSum);
840
+ if (tmpUnchangedPct < 0) tmpUnchangedPct = 0;
841
+ }
842
+
843
+ tmpHtml += '<div class="ratio-bar-container">';
844
+ if (tmpUnchangedPct > 0) tmpHtml += '<div class="ratio-bar-segment unchanged" style="width:' + tmpUnchangedPct + '%" title="Unchanged: ' + this.formatNumber(tmpUnchanged) + '"></div>';
845
+ if (tmpNewPct > 0) tmpHtml += '<div class="ratio-bar-segment new-records" style="width:' + tmpNewPct + '%" title="New: ' + this.formatNumber(tmpNew) + '"></div>';
846
+ if (tmpUpdatedPct > 0) tmpHtml += '<div class="ratio-bar-segment updated" style="width:' + tmpUpdatedPct + '%" title="Updated: ' + this.formatNumber(tmpUpdated) + '"></div>';
847
+ if (tmpDeletedPct > 0) tmpHtml += '<div class="ratio-bar-segment deleted" style="width:' + tmpDeletedPct + '%" title="Deleted: ' + this.formatNumber(tmpDeleted) + '"></div>';
848
+ tmpHtml += '</div>';
849
+
850
+ tmpHtml += '<div class="ratio-bar-legend">';
851
+ if (tmpUnchanged > 0) tmpHtml += '<span class="ratio-bar-legend-item"><span class="ratio-bar-legend-dot unchanged-dot"></span> Unchanged (' + this.formatNumber(tmpUnchanged) + ')</span>';
852
+ if (tmpNew > 0) tmpHtml += '<span class="ratio-bar-legend-item"><span class="ratio-bar-legend-dot new-dot"></span> New (' + this.formatNumber(tmpNew) + ')</span>';
853
+ if (tmpUpdated > 0) tmpHtml += '<span class="ratio-bar-legend-item"><span class="ratio-bar-legend-dot updated-dot"></span> Updated (' + this.formatNumber(tmpUpdated) + ')</span>';
854
+ if (tmpDeleted > 0) tmpHtml += '<span class="ratio-bar-legend-item"><span class="ratio-bar-legend-dot deleted-dot"></span> Deleted (' + this.formatNumber(tmpDeleted) + ')</span>';
855
+ tmpHtml += '</div>';
856
+ }
857
+
858
+ tmpHtml += '</div>';
859
+ return tmpHtml;
860
+ }
861
+
862
+ renderErrorRow(pOp, pEventLog)
863
+ {
864
+ let tmpSynced = pOp.Data.Synced || 0;
865
+ let tmpTotal = pOp.Data.Total || 0;
866
+ let tmpSyncedFmt = this.formatNumber(tmpSynced);
867
+ let tmpTotalFmt = this.formatNumber(tmpTotal);
868
+
869
+ let tmpHtml = '<div class="error-op-row">';
870
+ tmpHtml += '<div class="error-op-header">';
871
+ tmpHtml += ' <span style="color:#dc3545">\u2717</span>';
872
+ tmpHtml += ' <span class="error-op-name">' + this.escapeHtml(pOp.Name) + '</span>';
873
+ tmpHtml += ' <span class="error-op-status">' + pOp.Data.Status + ' \u2014 ' + tmpSyncedFmt + ' / ' + tmpTotalFmt + '</span>';
874
+ tmpHtml += '</div>';
875
+
876
+ if (pOp.Data.ErrorMessage)
877
+ {
878
+ tmpHtml += '<div class="error-op-message">' + this.escapeHtml(pOp.Data.ErrorMessage) + '</div>';
879
+ }
880
+
881
+ // Extract relevant log entries from EventLog
882
+ if (pEventLog && pEventLog.length > 0)
883
+ {
884
+ let tmpRelevantLogs = [];
885
+ for (let j = 0; j < pEventLog.length; j++)
886
+ {
887
+ let tmpLog = pEventLog[j];
888
+ if (tmpLog.Data && tmpLog.Data.Table === pOp.Name &&
889
+ (tmpLog.Type === 'TableError' || tmpLog.Type === 'TablePartial'))
890
+ {
891
+ tmpRelevantLogs.push(tmpLog);
892
+ }
893
+ }
894
+ if (tmpRelevantLogs.length > 0)
895
+ {
896
+ tmpHtml += '<div class="error-op-log-entries">';
897
+ for (let j = 0; j < tmpRelevantLogs.length; j++)
898
+ {
899
+ let tmpTimestamp = tmpRelevantLogs[j].Timestamp.replace('T', ' ').replace(/\.\d+Z$/, '');
900
+ tmpHtml += '<div>' + this.escapeHtml(tmpTimestamp + ' ' + tmpRelevantLogs[j].Message) + '</div>';
901
+ }
902
+ tmpHtml += '</div>';
903
+ }
904
+ }
905
+
906
+ tmpHtml += '</div>';
907
+ return tmpHtml;
908
+ }
909
+
910
+ // ================================================================
911
+ // Deployed Tables Persistence
912
+ // ================================================================
913
+
914
+ saveDeployedTables()
915
+ {
916
+ localStorage.setItem('dataCloner_deployedTables', JSON.stringify(this.pict.AppData.DataCloner.DeployedTables));
917
+ }
918
+
919
+ restoreDeployedTables()
920
+ {
921
+ try
922
+ {
923
+ let tmpRaw = localStorage.getItem('dataCloner_deployedTables');
924
+ if (tmpRaw)
925
+ {
926
+ this.pict.AppData.DataCloner.DeployedTables = JSON.parse(tmpRaw);
927
+ this.pict.views['DataCloner-ViewData'].populateViewTableDropdown();
928
+ }
929
+ }
930
+ catch (pError) { /* ignore */ }
931
+ }
932
+
933
+ // ================================================================
934
+ // Auto-Process
935
+ // ================================================================
936
+
937
+ initAutoProcess()
938
+ {
939
+ let tmpSelf = this;
940
+ this.api('GET', '/clone/sync/live-status')
941
+ .then(function(pData)
942
+ {
943
+ if (pData.Phase === 'syncing' || pData.Phase === 'stopping')
944
+ {
945
+ tmpSelf.pict.AppData.DataCloner.ServerBusyAtLoad = true;
946
+ tmpSelf.setSectionPhase(5, 'busy');
947
+ tmpSelf.pict.views['DataCloner-Sync'].startPolling();
948
+ return;
949
+ }
950
+ tmpSelf.runAutoProcessChain();
951
+ })
952
+ .catch(function()
953
+ {
954
+ // Server unreachable — don't auto-process
955
+ });
956
+ }
957
+
958
+ runAutoProcessChain()
959
+ {
960
+ let tmpSelf = this;
961
+ let tmpDelay = 0;
962
+ let tmpStepDelay = 2000;
963
+
964
+ if (document.getElementById('auto1') && document.getElementById('auto1').checked)
965
+ {
966
+ setTimeout(function() { tmpSelf.pict.views['DataCloner-Connection'].connectProvider(); }, tmpDelay);
967
+ tmpDelay += tmpStepDelay;
968
+ }
969
+ if (document.getElementById('auto2') && document.getElementById('auto2').checked)
970
+ {
971
+ setTimeout(function() { tmpSelf.pict.views['DataCloner-Session'].goAction(); }, tmpDelay);
972
+ tmpDelay += tmpStepDelay + 1500;
973
+ }
974
+ if (document.getElementById('auto3') && document.getElementById('auto3').checked)
975
+ {
976
+ setTimeout(function() { tmpSelf.pict.views['DataCloner-Schema'].fetchSchema(); }, tmpDelay);
977
+ tmpDelay += tmpStepDelay;
978
+ }
979
+ if (document.getElementById('auto4') && document.getElementById('auto4').checked)
980
+ {
981
+ setTimeout(function() { tmpSelf.pict.views['DataCloner-Deploy'].deploySchema(); }, tmpDelay);
982
+ tmpDelay += tmpStepDelay;
983
+ }
984
+ if (document.getElementById('auto5') && document.getElementById('auto5').checked)
985
+ {
986
+ setTimeout(function() { tmpSelf.pict.views['DataCloner-Sync'].startSync(); }, tmpDelay);
987
+ }
988
+ }
989
+ }
990
+
991
+ module.exports = DataClonerProvider;
992
+
993
+ module.exports.default_configuration =
994
+ {
995
+ ProviderIdentifier: 'DataCloner',
996
+ AutoInitialize: true,
997
+ AutoInitializeOrdinal: 0
998
+ };