retold-data-service 2.0.17 → 2.0.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retold-data-service",
3
- "version": "2.0.17",
3
+ "version": "2.0.19",
4
4
  "description": "Serve up a whole model!",
5
5
  "main": "source/Retold-Data-Service.js",
6
6
  "bin": {
@@ -72,7 +72,7 @@
72
72
  "meadow": "^2.0.33",
73
73
  "meadow-connection-mysql": "^1.0.14",
74
74
  "meadow-endpoints": "^4.0.14",
75
- "meadow-integration": "^1.0.14",
75
+ "meadow-integration": "^1.0.16",
76
76
  "meadow-migrationmanager": "^0.0.4",
77
77
  "orator": "^6.0.4",
78
78
  "orator-http-proxy": "^1.0.5",
@@ -137,6 +137,8 @@ class RetoldDataServiceMeadowEndpoints extends libFableServiceProviderBase
137
137
  {
138
138
  let tmpDALEntityName = tmpEntityList[i];
139
139
 
140
+ let tmpRoutesAlreadyConnected = this._MeadowEndpoints.hasOwnProperty(tmpDALEntityName);
141
+
140
142
  if (this._DAL.hasOwnProperty(tmpDALEntityName))
141
143
  {
142
144
  this.fable.log.warn(`Entity [${tmpDALEntityName}] already exists in the DAL (from another model); overwriting.`);
@@ -152,8 +154,16 @@ class RetoldDataServiceMeadowEndpoints extends libFableServiceProviderBase
152
154
  this._DAL[tmpDALEntityName].setProvider(tmpStorageProvider);
153
155
  this.fable.log.info(`...initializing the ${tmpDALEntityName} Meadow Endpoints`);
154
156
  this._MeadowEndpoints[tmpDALEntityName] = libMeadowEndpoints.new(this._DAL[tmpDALEntityName]);
155
- this.fable.log.info(`...mapping the ${tmpDALEntityName} Meadow Endpoints to Orator`);
156
- this._MeadowEndpoints[tmpDALEntityName].connectRoutes(this.fable.OratorServiceServer);
157
+
158
+ if (!tmpRoutesAlreadyConnected)
159
+ {
160
+ this.fable.log.info(`...mapping the ${tmpDALEntityName} Meadow Endpoints to Orator`);
161
+ this._MeadowEndpoints[tmpDALEntityName].connectRoutes(this.fable.OratorServiceServer);
162
+ }
163
+ else
164
+ {
165
+ this.fable.log.info(`...routes for ${tmpDALEntityName} already registered; skipping connectRoutes.`);
166
+ }
157
167
  }
158
168
  catch (pError)
159
169
  {
@@ -274,10 +274,11 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
274
274
  // Extract the entity name from the URL for error tracking
275
275
  let tmpEntityHint = pURL.split('/')[0].replace(/s$/, '');
276
276
 
277
- // Use the longer timeout for MAX(column) queries only
278
- let tmpIsMaxRequest = (pURL.indexOf('/Max/') > -1);
277
+ // Use the longer timeout for Max and Count queries (which can be
278
+ // slow on large tables with millions of rows)
279
+ let tmpIsHeavyQuery = (pURL.indexOf('/Max/') > -1) || (pURL.match(/\/Count(\/|$)/) !== null);
279
280
  let tmpPreviousTimeout = libHttps.globalAgent.options.timeout;
280
- libHttps.globalAgent.options.timeout = tmpIsMaxRequest
281
+ libHttps.globalAgent.options.timeout = tmpIsHeavyQuery
281
282
  ? tmpFable.MeadowCloneRestClient.maxRequestTimeout
282
283
  : tmpFable.MeadowCloneRestClient.requestTimeout;
283
284
 
@@ -207,6 +207,13 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
207
207
  let tmpSnap = { Status: tmpP.Status, Total: tmpP.Total || 0, Synced: tmpP.Synced || 0, Errors: tmpP.Errors || 0 };
208
208
  if (tmpP.ErrorMessage) tmpSnap.ErrorMessage = tmpP.ErrorMessage;
209
209
 
210
+ // Include per-record breakdown fields if available (set after entity sync completes)
211
+ if (tmpP.hasOwnProperty('New')) tmpSnap.New = tmpP.New;
212
+ if (tmpP.hasOwnProperty('Updated')) tmpSnap.Updated = tmpP.Updated;
213
+ if (tmpP.hasOwnProperty('Unchanged')) tmpSnap.Unchanged = tmpP.Unchanged;
214
+ if (tmpP.hasOwnProperty('Deleted')) tmpSnap.Deleted = tmpP.Deleted;
215
+ if (tmpP.hasOwnProperty('ServerTotal')) tmpSnap.ServerTotal = tmpP.ServerTotal;
216
+
210
217
  if ((tmpP.Status === 'Syncing' || tmpP.Status === 'Pending') && tmpFable.MeadowSync && tmpFable.MeadowSync.MeadowSyncEntities)
211
218
  {
212
219
  let tmpSyncEntity = tmpFable.MeadowSync.MeadowSyncEntities[tmpName];
@@ -300,9 +307,15 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
300
307
  // Check for pre-count phase
301
308
  if (tmpCloneState.SyncPhase === 'counting')
302
309
  {
303
- let tmpPC = tmpCloneState.PreCountProgress || { Counted: 0, TotalTables: 0 };
310
+ let tmpPC = tmpCloneState.PreCountProgress || { Counted: 0, TotalTables: 0, Tables: [] };
304
311
  tmpMessage = `Analyzing tables: counted ${tmpPC.Counted} / ${tmpPC.TotalTables}...`;
305
312
 
313
+ let tmpCountElapsed = null;
314
+ if (tmpPC.StartTime)
315
+ {
316
+ tmpCountElapsed = fFormatDuration(Date.now() - tmpPC.StartTime);
317
+ }
318
+
306
319
  pResponse.send(200,
307
320
  {
308
321
  Phase: tmpPhase,
@@ -315,10 +328,10 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
315
328
  TotalTables: tmpPC.TotalTables,
316
329
  TotalSynced: 0,
317
330
  TotalRecords: 0,
318
- Elapsed: null,
331
+ Elapsed: tmpCountElapsed,
319
332
  SyncMode: tmpCloneState.SyncMode,
320
333
  ETA: null,
321
- PreCountGrandTotal: 0,
334
+ PreCountGrandTotal: tmpCloneState.PreCountGrandTotal || 0,
322
335
  PreCountProgress: tmpPC,
323
336
  ThroughputSamples: []
324
337
  });
@@ -10,7 +10,7 @@ const _ProviderRegistry = {
10
10
  SQLite: { moduleName: 'meadow-connection-sqlite', serviceName: 'MeadowSQLiteProvider', configKey: 'SQLite' },
11
11
  MySQL: { moduleName: 'meadow-connection-mysql', serviceName: 'MeadowMySQLProvider', configKey: 'MySQL' },
12
12
  MSSQL: { moduleName: 'meadow-connection-mssql', serviceName: 'MeadowMSSQLProvider', configKey: 'MSSQL' },
13
- PostgreSQL: { moduleName: 'meadow-connection-postgresql', serviceName: 'MeadowConnectionPostgreSQL', configKey: 'PostgreSQL' },
13
+ PostgreSQL: { moduleName: 'meadow-connection-postgresql', serviceName: 'MeadowPostgreSQLProvider', configKey: 'PostgreSQL' },
14
14
  Solr: { moduleName: 'meadow-connection-solr', serviceName: 'MeadowConnectionSolr', configKey: 'Solr' },
15
15
  MongoDB: { moduleName: 'meadow-connection-mongodb', serviceName: 'MeadowConnectionMongoDB', configKey: 'MongoDB' },
16
16
  RocksDB: { moduleName: 'meadow-connection-rocksdb', serviceName: 'MeadowConnectionRocksDB', configKey: 'RocksDB' },
@@ -642,7 +642,12 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
642
642
  preCountTables(pTables, fCallback)
643
643
  {
644
644
  this._cloneState.SyncPhase = 'counting';
645
- this._cloneState.PreCountProgress = { Counted: 0, TotalTables: pTables.length };
645
+ this._cloneState.PreCountProgress = {
646
+ Counted: 0,
647
+ TotalTables: pTables.length,
648
+ StartTime: Date.now(),
649
+ Tables: []
650
+ };
646
651
  this._cloneState.PreCountGrandTotal = 0;
647
652
 
648
653
  this.fable.log.info(`Data Cloner: Pre-counting records for ${pTables.length} tables...`);
@@ -656,6 +661,7 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
656
661
  return fNext();
657
662
  }
658
663
 
664
+ let tmpTableStartTime = Date.now();
659
665
  let tmpCountURL = `${pTableName}s/Count`;
660
666
  this.fable.MeadowCloneRestClient.getJSON(tmpCountURL,
661
667
  (pError, pResponse, pBody) =>
@@ -669,7 +675,14 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
669
675
  {
670
676
  this._cloneState.SyncProgress[pTableName].Total = tmpCount;
671
677
  }
678
+ let tmpElapsedMs = Date.now() - tmpTableStartTime;
672
679
  this._cloneState.PreCountProgress.Counted++;
680
+ this._cloneState.PreCountProgress.Tables.push({
681
+ Name: pTableName,
682
+ Count: tmpCount,
683
+ ElapsedMs: tmpElapsedMs,
684
+ Error: pError ? true : false
685
+ });
673
686
  this._cloneState.PreCountGrandTotal += tmpCount;
674
687
  return fNext();
675
688
  });
@@ -866,15 +879,42 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
866
879
  if (this._cloneState.SyncLogFileLogger)
867
880
  {
868
881
  let tmpLogPath = this._cloneState.SyncLogFilePath || '';
869
- this._cloneState.SyncLogFileLogger.flushBufferToLogFile(() =>
882
+ let tmpLogger = this._cloneState.SyncLogFileLogger;
883
+
884
+ // Log before closing so the message is captured in the file
885
+ this.fable.log.info(`Data Cloner: Log file closing — ${tmpLogPath}`);
886
+
887
+ // Remove the logger from fable.log stream arrays so no writes
888
+ // are dispatched to it after the underlying stream is closed.
889
+ let tmpStreamArrays = [
890
+ 'logStreams', 'logStreamsTrace', 'logStreamsDebug',
891
+ 'logStreamsInfo', 'logStreamsWarn', 'logStreamsError', 'logStreamsFatal'
892
+ ];
893
+ for (let s = 0; s < tmpStreamArrays.length; s++)
870
894
  {
871
- this._cloneState.SyncLogFileLogger.closeWriter(() =>
895
+ let tmpArr = this.fable.log[tmpStreamArrays[s]];
896
+ if (Array.isArray(tmpArr))
872
897
  {
873
- this.fable.log.info(`Data Cloner: Log file closed — ${tmpLogPath}`);
874
- });
875
- });
898
+ let tmpIdx = tmpArr.indexOf(tmpLogger);
899
+ if (tmpIdx > -1) tmpArr.splice(tmpIdx, 1);
900
+ }
901
+ }
902
+
876
903
  this._cloneState.SyncLogFileLogger = null;
877
904
  this._cloneState.SyncLogFilePath = null;
905
+
906
+ // Now flush and close safely — no more writes can reach this stream
907
+ try
908
+ {
909
+ tmpLogger.flushBufferToLogFile(() =>
910
+ {
911
+ tmpLogger.closeWriter(() => {});
912
+ });
913
+ }
914
+ catch (pCloseErr)
915
+ {
916
+ // Ignore close errors
917
+ }
878
918
  }
879
919
 
880
920
  return;
@@ -915,7 +955,7 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
915
955
  {
916
956
  let tmpResults = tmpSyncEntity.syncResults;
917
957
  tmpProgress.New = tmpResults.Created || 0;
918
- tmpProgress.Updated = 0;
958
+ tmpProgress.Updated = tmpResults.Updated || 0;
919
959
  tmpProgress.Unchanged = tmpResults.LocalRecordCount || 0;
920
960
  tmpProgress.Deleted = tmpResults.Deleted || 0;
921
961
  tmpProgress.ServerTotal = tmpResults.ServerRecordCount || 0;
@@ -73,7 +73,8 @@ class DataClonerApplication extends libPictApplication
73
73
  'solrHost', 'solrPort', 'solrCore', 'solrPath',
74
74
  'mongodbHost', 'mongodbPort', 'mongodbUser', 'mongodbPassword', 'mongodbDatabase', 'mongodbConnectionLimit',
75
75
  'rocksdbFolder',
76
- 'bibliographFolder'
76
+ 'bibliographFolder',
77
+ 'syncMaxRecords'
77
78
  ]
78
79
  };
79
80
 
@@ -447,7 +447,10 @@ class DataClonerProvider extends libPictProvider
447
447
  }
448
448
  if (pData.PreCountProgress && pData.PreCountProgress.Counted < pData.PreCountProgress.TotalTables)
449
449
  {
450
- tmpMetaParts.push('<span class="live-status-meta-item">counting: ' + pData.PreCountProgress.Counted + ' / ' + pData.PreCountProgress.TotalTables + '</span>');
450
+ let tmpCountedSoFar = pData.PreCountGrandTotal > 0
451
+ ? ' (' + pData.PreCountGrandTotal.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') + ' records found)'
452
+ : '';
453
+ tmpMetaParts.push('<span class="live-status-meta-item">counting: ' + pData.PreCountProgress.Counted + ' / ' + pData.PreCountProgress.TotalTables + ' tables' + tmpCountedSoFar + '</span>');
451
454
  }
452
455
  if (pData.Errors > 0)
453
456
  {
@@ -466,7 +469,12 @@ class DataClonerProvider extends libPictProvider
466
469
 
467
470
  // Update progress bar
468
471
  let tmpPct = 0;
469
- if (pData.Phase === 'syncing' && pData.PreCountGrandTotal > 0 && pData.TotalSynced > 0)
472
+ if (pData.Phase === 'syncing' && pData.PreCountProgress && pData.PreCountProgress.Counted < pData.PreCountProgress.TotalTables)
473
+ {
474
+ // During counting phase, show table counting progress
475
+ tmpPct = Math.min((pData.PreCountProgress.Counted / pData.PreCountProgress.TotalTables) * 100, 99);
476
+ }
477
+ else if (pData.Phase === 'syncing' && pData.PreCountGrandTotal > 0 && pData.TotalSynced > 0)
470
478
  {
471
479
  tmpPct = Math.min((pData.TotalSynced / pData.PreCountGrandTotal) * 100, 99.9);
472
480
  }
@@ -489,11 +497,40 @@ class DataClonerProvider extends libPictProvider
489
497
  }
490
498
  tmpProgressFill.style.width = Math.min(100, Math.round(tmpPct)) + '%';
491
499
 
500
+ // Auto-expand the detail view when sync starts so users see counting progress
501
+ if ((pData.Phase === 'syncing' || pData.Phase === 'stopping') && !this.pict.AppData.DataCloner.StatusDetailExpanded)
502
+ {
503
+ let tmpLayoutView = this.pict.views['DataCloner-Layout'];
504
+ if (tmpLayoutView && typeof tmpLayoutView.toggleStatusDetail === 'function')
505
+ {
506
+ tmpLayoutView.toggleStatusDetail();
507
+ }
508
+ }
509
+
492
510
  // If the detail view is expanded, re-render it with fresh data
493
511
  if (this.pict.AppData.DataCloner.StatusDetailExpanded)
494
512
  {
495
513
  this.renderStatusDetail();
496
514
  }
515
+
516
+ // Auto-fetch the sync report when we detect a completed sync but haven't loaded the report yet
517
+ if (pData.Phase === 'complete' && !this.pict.AppData.DataCloner.LastReport)
518
+ {
519
+ let tmpSelf = this;
520
+ this.api('GET', '/clone/sync/report')
521
+ .then(function(pReportData)
522
+ {
523
+ if (pReportData && pReportData.ReportVersion)
524
+ {
525
+ tmpSelf.pict.AppData.DataCloner.LastReport = pReportData;
526
+ if (tmpSelf.pict.AppData.DataCloner.StatusDetailExpanded)
527
+ {
528
+ tmpSelf.renderStatusDetail();
529
+ }
530
+ }
531
+ })
532
+ .catch(function() { /* ignore fetch errors */ });
533
+ }
497
534
  }
498
535
 
499
536
  // ================================================================
@@ -539,6 +576,59 @@ class DataClonerProvider extends libPictProvider
539
576
  .catch(function() { /* ignore poll errors */ });
540
577
  }
541
578
 
579
+ renderCountingPhaseDetail(pContainer, pPreCountProgress)
580
+ {
581
+ let tmpTables = pPreCountProgress.Tables || [];
582
+ let tmpCounted = pPreCountProgress.Counted || 0;
583
+ let tmpTotal = pPreCountProgress.TotalTables || 0;
584
+ let tmpRunningTotal = 0;
585
+
586
+ let tmpHtml = '<div class="status-detail-section">';
587
+ tmpHtml += '<div class="status-detail-section-title">Counting Records (' + tmpCounted + ' / ' + tmpTotal + ' tables)</div>';
588
+
589
+ if (tmpTables.length > 0)
590
+ {
591
+ tmpHtml += '<table class="precount-table">';
592
+ tmpHtml += '<thead><tr><th>Table</th><th style="text-align:right">Records</th><th style="text-align:right">Time</th></tr></thead>';
593
+ tmpHtml += '<tbody>';
594
+ for (let i = 0; i < tmpTables.length; i++)
595
+ {
596
+ let tmpT = tmpTables[i];
597
+ tmpRunningTotal += tmpT.Count;
598
+ let tmpCountFmt = this.formatNumber(tmpT.Count);
599
+ let tmpTimeFmt = tmpT.ElapsedMs < 1000
600
+ ? tmpT.ElapsedMs + 'ms'
601
+ : (tmpT.ElapsedMs / 1000).toFixed(1) + 's';
602
+ let tmpRowClass = tmpT.Error ? ' class="precount-error"' : '';
603
+ tmpHtml += '<tr' + tmpRowClass + '>';
604
+ tmpHtml += '<td>' + this.escapeHtml(tmpT.Name) + '</td>';
605
+ tmpHtml += '<td style="text-align:right; font-variant-numeric:tabular-nums">' + tmpCountFmt + '</td>';
606
+ tmpHtml += '<td style="text-align:right; font-variant-numeric:tabular-nums; color:#888">' + tmpTimeFmt + '</td>';
607
+ tmpHtml += '</tr>';
608
+ }
609
+ tmpHtml += '</tbody>';
610
+ tmpHtml += '<tfoot><tr>';
611
+ tmpHtml += '<td><strong>Total</strong></td>';
612
+ tmpHtml += '<td style="text-align:right; font-variant-numeric:tabular-nums"><strong>' + this.formatNumber(tmpRunningTotal) + '</strong></td>';
613
+ tmpHtml += '<td></td>';
614
+ tmpHtml += '</tr></tfoot>';
615
+ tmpHtml += '</table>';
616
+ }
617
+
618
+ // Show pending indicator for remaining tables
619
+ let tmpRemaining = tmpTotal - tmpCounted;
620
+ if (tmpRemaining > 0)
621
+ {
622
+ tmpHtml += '<div class="precount-pending">';
623
+ tmpHtml += '<span class="precount-spinner"></span> ';
624
+ tmpHtml += tmpRemaining + ' table' + (tmpRemaining === 1 ? '' : 's') + ' remaining…';
625
+ tmpHtml += '</div>';
626
+ }
627
+
628
+ tmpHtml += '</div>';
629
+ pContainer.innerHTML = tmpHtml;
630
+ }
631
+
542
632
  renderStatusDetail()
543
633
  {
544
634
  let tmpContainer = document.getElementById('DataCloner-StatusDetail-Container');
@@ -549,6 +639,19 @@ class DataClonerProvider extends libPictProvider
549
639
  let tmpStatusData = tmpAppData.StatusDetailData;
550
640
  let tmpReport = tmpAppData.LastReport;
551
641
 
642
+ // During the counting phase, show per-table counts as they arrive
643
+ if (tmpLiveStatus && tmpLiveStatus.PreCountProgress
644
+ && tmpLiveStatus.PreCountProgress.Tables
645
+ && tmpLiveStatus.Phase === 'syncing'
646
+ && tmpLiveStatus.PreCountProgress.Counted < tmpLiveStatus.PreCountProgress.TotalTables)
647
+ {
648
+ this.renderCountingPhaseDetail(tmpContainer, tmpLiveStatus.PreCountProgress);
649
+ // Hide histogram during counting
650
+ let tmpHistContainer = document.getElementById('DataCloner-Throughput-Histogram');
651
+ if (tmpHistContainer) tmpHistContainer.style.display = 'none';
652
+ return;
653
+ }
654
+
552
655
  // Determine data source: live during sync, report after sync
553
656
  let tmpTables = {};
554
657
  let tmpThroughputSamples = [];
@@ -575,6 +678,11 @@ class DataClonerProvider extends libPictProvider
575
678
  else if (tmpStatusData && tmpStatusData.Tables)
576
679
  {
577
680
  tmpTables = tmpStatusData.Tables;
681
+ // Use throughput samples from live status if available (e.g. after page reload with completed sync)
682
+ if (tmpLiveStatus && tmpLiveStatus.ThroughputSamples)
683
+ {
684
+ tmpThroughputSamples = tmpLiveStatus.ThroughputSamples;
685
+ }
578
686
  }
579
687
 
580
688
  // Categorize tables
@@ -339,6 +339,34 @@ select { background: #fff; width: 100%; padding: 8px 12px; border: 1px solid #cc
339
339
  font-size: 0.78em; color: #888; margin-top: 4px; padding-left: 18px;
340
340
  font-family: monospace; max-height: 80px; overflow-y: auto;
341
341
  }
342
+
343
+ /* Pre-count Table */
344
+ .precount-table {
345
+ width: 100%; border-collapse: collapse; font-size: 0.88em;
346
+ }
347
+ .precount-table thead th {
348
+ text-align: left; font-weight: 600; color: #555; font-size: 0.85em;
349
+ text-transform: uppercase; letter-spacing: 0.3px;
350
+ padding: 4px 8px 6px; border-bottom: 2px solid #e9ecef;
351
+ }
352
+ .precount-table tbody td {
353
+ padding: 4px 8px; border-bottom: 1px solid #f0f0f0;
354
+ }
355
+ .precount-table tbody tr:last-child td { border-bottom: 1px solid #e9ecef; }
356
+ .precount-table tfoot td {
357
+ padding: 6px 8px 2px; font-size: 0.95em;
358
+ }
359
+ .precount-error td { color: #dc3545; }
360
+ .precount-pending {
361
+ color: #888; font-size: 0.85em; font-style: italic; padding: 8px 0 2px;
362
+ display: flex; align-items: center; gap: 6px;
363
+ }
364
+ .precount-spinner {
365
+ display: inline-block; width: 12px; height: 12px;
366
+ border: 2px solid #ddd; border-top-color: #4a90d9;
367
+ border-radius: 50%; animation: precount-spin 0.8s linear infinite;
368
+ }
369
+ @keyframes precount-spin { to { transform: rotate(360deg); } }
342
370
  `,
343
371
  Templates:
344
372
  [
@@ -4892,7 +4892,7 @@
4892
4892
  StatusDetailTimer: null,
4893
4893
  StatusDetailData: null,
4894
4894
  LastLiveStatus: null,
4895
- PersistFields: ['serverURL', 'authMethod', 'authURI', 'checkURI', 'cookieName', 'cookieValueAddr', 'cookieValueTemplate', 'loginMarker', 'userName', 'password', 'schemaURL', 'pageSize', 'dateTimePrecisionMS', 'connProvider', 'sqliteFilePath', 'mysqlServer', 'mysqlPort', 'mysqlUser', 'mysqlPassword', 'mysqlDatabase', 'mysqlConnectionLimit', 'mssqlServer', 'mssqlPort', 'mssqlUser', 'mssqlPassword', 'mssqlDatabase', 'mssqlConnectionLimit', 'postgresqlHost', 'postgresqlPort', 'postgresqlUser', 'postgresqlPassword', 'postgresqlDatabase', 'postgresqlConnectionLimit', 'solrHost', 'solrPort', 'solrCore', 'solrPath', 'mongodbHost', 'mongodbPort', 'mongodbUser', 'mongodbPassword', 'mongodbDatabase', 'mongodbConnectionLimit', 'rocksdbFolder', 'bibliographFolder']
4895
+ PersistFields: ['serverURL', 'authMethod', 'authURI', 'checkURI', 'cookieName', 'cookieValueAddr', 'cookieValueTemplate', 'loginMarker', 'userName', 'password', 'schemaURL', 'pageSize', 'dateTimePrecisionMS', 'connProvider', 'sqliteFilePath', 'mysqlServer', 'mysqlPort', 'mysqlUser', 'mysqlPassword', 'mysqlDatabase', 'mysqlConnectionLimit', 'mssqlServer', 'mssqlPort', 'mssqlUser', 'mssqlPassword', 'mssqlDatabase', 'mssqlConnectionLimit', 'postgresqlHost', 'postgresqlPort', 'postgresqlUser', 'postgresqlPassword', 'postgresqlDatabase', 'postgresqlConnectionLimit', 'solrHost', 'solrPort', 'solrCore', 'solrPath', 'mongodbHost', 'mongodbPort', 'mongodbUser', 'mongodbPassword', 'mongodbDatabase', 'mongodbConnectionLimit', 'rocksdbFolder', 'bibliographFolder', 'syncMaxRecords']
4896
4896
  };
4897
4897
 
4898
4898
  // Make pict available for inline onclick handlers
@@ -5287,7 +5287,8 @@
5287
5287
  tmpMetaParts.push('<span class="live-status-meta-item">' + tmpGrandTotal + ' records to sync</span>');
5288
5288
  }
5289
5289
  if (pData.PreCountProgress && pData.PreCountProgress.Counted < pData.PreCountProgress.TotalTables) {
5290
- tmpMetaParts.push('<span class="live-status-meta-item">counting: ' + pData.PreCountProgress.Counted + ' / ' + pData.PreCountProgress.TotalTables + '</span>');
5290
+ let tmpCountedSoFar = pData.PreCountGrandTotal > 0 ? ' (' + pData.PreCountGrandTotal.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') + ' records found)' : '';
5291
+ tmpMetaParts.push('<span class="live-status-meta-item">counting: ' + pData.PreCountProgress.Counted + ' / ' + pData.PreCountProgress.TotalTables + ' tables' + tmpCountedSoFar + '</span>');
5291
5292
  }
5292
5293
  if (pData.Errors > 0) {
5293
5294
  tmpMetaParts.push('<span class="live-status-meta-item" style="color:#dc3545"><strong>' + pData.Errors + '</strong> error' + (pData.Errors === 1 ? '' : 's') + '</span>');
@@ -5302,7 +5303,10 @@
5302
5303
 
5303
5304
  // Update progress bar
5304
5305
  let tmpPct = 0;
5305
- if (pData.Phase === 'syncing' && pData.PreCountGrandTotal > 0 && pData.TotalSynced > 0) {
5306
+ if (pData.Phase === 'syncing' && pData.PreCountProgress && pData.PreCountProgress.Counted < pData.PreCountProgress.TotalTables) {
5307
+ // During counting phase, show table counting progress
5308
+ tmpPct = Math.min(pData.PreCountProgress.Counted / pData.PreCountProgress.TotalTables * 100, 99);
5309
+ } else if (pData.Phase === 'syncing' && pData.PreCountGrandTotal > 0 && pData.TotalSynced > 0) {
5306
5310
  tmpPct = Math.min(pData.TotalSynced / pData.PreCountGrandTotal * 100, 99.9);
5307
5311
  } else if (pData.Phase === 'syncing' && pData.TotalTables > 0) {
5308
5312
  let tmpTablePct = pData.Completed / pData.TotalTables * 100;
@@ -5317,10 +5321,31 @@
5317
5321
  }
5318
5322
  tmpProgressFill.style.width = Math.min(100, Math.round(tmpPct)) + '%';
5319
5323
 
5324
+ // Auto-expand the detail view when sync starts so users see counting progress
5325
+ if ((pData.Phase === 'syncing' || pData.Phase === 'stopping') && !this.pict.AppData.DataCloner.StatusDetailExpanded) {
5326
+ let tmpLayoutView = this.pict.views['DataCloner-Layout'];
5327
+ if (tmpLayoutView && typeof tmpLayoutView.toggleStatusDetail === 'function') {
5328
+ tmpLayoutView.toggleStatusDetail();
5329
+ }
5330
+ }
5331
+
5320
5332
  // If the detail view is expanded, re-render it with fresh data
5321
5333
  if (this.pict.AppData.DataCloner.StatusDetailExpanded) {
5322
5334
  this.renderStatusDetail();
5323
5335
  }
5336
+
5337
+ // Auto-fetch the sync report when we detect a completed sync but haven't loaded the report yet
5338
+ if (pData.Phase === 'complete' && !this.pict.AppData.DataCloner.LastReport) {
5339
+ let tmpSelf = this;
5340
+ this.api('GET', '/clone/sync/report').then(function (pReportData) {
5341
+ if (pReportData && pReportData.ReportVersion) {
5342
+ tmpSelf.pict.AppData.DataCloner.LastReport = pReportData;
5343
+ if (tmpSelf.pict.AppData.DataCloner.StatusDetailExpanded) {
5344
+ tmpSelf.renderStatusDetail();
5345
+ }
5346
+ }
5347
+ }).catch(function () {/* ignore fetch errors */});
5348
+ }
5324
5349
  }
5325
5350
 
5326
5351
  // ================================================================
@@ -5357,6 +5382,49 @@
5357
5382
  tmpSelf.renderStatusDetail();
5358
5383
  }).catch(function () {/* ignore poll errors */});
5359
5384
  }
5385
+ renderCountingPhaseDetail(pContainer, pPreCountProgress) {
5386
+ let tmpTables = pPreCountProgress.Tables || [];
5387
+ let tmpCounted = pPreCountProgress.Counted || 0;
5388
+ let tmpTotal = pPreCountProgress.TotalTables || 0;
5389
+ let tmpRunningTotal = 0;
5390
+ let tmpHtml = '<div class="status-detail-section">';
5391
+ tmpHtml += '<div class="status-detail-section-title">Counting Records (' + tmpCounted + ' / ' + tmpTotal + ' tables)</div>';
5392
+ if (tmpTables.length > 0) {
5393
+ tmpHtml += '<table class="precount-table">';
5394
+ tmpHtml += '<thead><tr><th>Table</th><th style="text-align:right">Records</th><th style="text-align:right">Time</th></tr></thead>';
5395
+ tmpHtml += '<tbody>';
5396
+ for (let i = 0; i < tmpTables.length; i++) {
5397
+ let tmpT = tmpTables[i];
5398
+ tmpRunningTotal += tmpT.Count;
5399
+ let tmpCountFmt = this.formatNumber(tmpT.Count);
5400
+ let tmpTimeFmt = tmpT.ElapsedMs < 1000 ? tmpT.ElapsedMs + 'ms' : (tmpT.ElapsedMs / 1000).toFixed(1) + 's';
5401
+ let tmpRowClass = tmpT.Error ? ' class="precount-error"' : '';
5402
+ tmpHtml += '<tr' + tmpRowClass + '>';
5403
+ tmpHtml += '<td>' + this.escapeHtml(tmpT.Name) + '</td>';
5404
+ tmpHtml += '<td style="text-align:right; font-variant-numeric:tabular-nums">' + tmpCountFmt + '</td>';
5405
+ tmpHtml += '<td style="text-align:right; font-variant-numeric:tabular-nums; color:#888">' + tmpTimeFmt + '</td>';
5406
+ tmpHtml += '</tr>';
5407
+ }
5408
+ tmpHtml += '</tbody>';
5409
+ tmpHtml += '<tfoot><tr>';
5410
+ tmpHtml += '<td><strong>Total</strong></td>';
5411
+ tmpHtml += '<td style="text-align:right; font-variant-numeric:tabular-nums"><strong>' + this.formatNumber(tmpRunningTotal) + '</strong></td>';
5412
+ tmpHtml += '<td></td>';
5413
+ tmpHtml += '</tr></tfoot>';
5414
+ tmpHtml += '</table>';
5415
+ }
5416
+
5417
+ // Show pending indicator for remaining tables
5418
+ let tmpRemaining = tmpTotal - tmpCounted;
5419
+ if (tmpRemaining > 0) {
5420
+ tmpHtml += '<div class="precount-pending">';
5421
+ tmpHtml += '<span class="precount-spinner"></span> ';
5422
+ tmpHtml += tmpRemaining + ' table' + (tmpRemaining === 1 ? '' : 's') + ' remaining…';
5423
+ tmpHtml += '</div>';
5424
+ }
5425
+ tmpHtml += '</div>';
5426
+ pContainer.innerHTML = tmpHtml;
5427
+ }
5360
5428
  renderStatusDetail() {
5361
5429
  let tmpContainer = document.getElementById('DataCloner-StatusDetail-Container');
5362
5430
  if (!tmpContainer) return;
@@ -5365,6 +5433,15 @@
5365
5433
  let tmpStatusData = tmpAppData.StatusDetailData;
5366
5434
  let tmpReport = tmpAppData.LastReport;
5367
5435
 
5436
+ // During the counting phase, show per-table counts as they arrive
5437
+ if (tmpLiveStatus && tmpLiveStatus.PreCountProgress && tmpLiveStatus.PreCountProgress.Tables && tmpLiveStatus.Phase === 'syncing' && tmpLiveStatus.PreCountProgress.Counted < tmpLiveStatus.PreCountProgress.TotalTables) {
5438
+ this.renderCountingPhaseDetail(tmpContainer, tmpLiveStatus.PreCountProgress);
5439
+ // Hide histogram during counting
5440
+ let tmpHistContainer = document.getElementById('DataCloner-Throughput-Histogram');
5441
+ if (tmpHistContainer) tmpHistContainer.style.display = 'none';
5442
+ return;
5443
+ }
5444
+
5368
5445
  // Determine data source: live during sync, report after sync
5369
5446
  let tmpTables = {};
5370
5447
  let tmpThroughputSamples = [];
@@ -5384,6 +5461,10 @@
5384
5461
  tmpEventLog = tmpReport.EventLog || [];
5385
5462
  } else if (tmpStatusData && tmpStatusData.Tables) {
5386
5463
  tmpTables = tmpStatusData.Tables;
5464
+ // Use throughput samples from live status if available (e.g. after page reload with completed sync)
5465
+ if (tmpLiveStatus && tmpLiveStatus.ThroughputSamples) {
5466
+ tmpThroughputSamples = tmpLiveStatus.ThroughputSamples;
5467
+ }
5387
5468
  }
5388
5469
 
5389
5470
  // Categorize tables
@@ -6892,6 +6973,34 @@ select { background: #fff; width: 100%; padding: 8px 12px; border: 1px solid #cc
6892
6973
  font-size: 0.78em; color: #888; margin-top: 4px; padding-left: 18px;
6893
6974
  font-family: monospace; max-height: 80px; overflow-y: auto;
6894
6975
  }
6976
+
6977
+ /* Pre-count Table */
6978
+ .precount-table {
6979
+ width: 100%; border-collapse: collapse; font-size: 0.88em;
6980
+ }
6981
+ .precount-table thead th {
6982
+ text-align: left; font-weight: 600; color: #555; font-size: 0.85em;
6983
+ text-transform: uppercase; letter-spacing: 0.3px;
6984
+ padding: 4px 8px 6px; border-bottom: 2px solid #e9ecef;
6985
+ }
6986
+ .precount-table tbody td {
6987
+ padding: 4px 8px; border-bottom: 1px solid #f0f0f0;
6988
+ }
6989
+ .precount-table tbody tr:last-child td { border-bottom: 1px solid #e9ecef; }
6990
+ .precount-table tfoot td {
6991
+ padding: 6px 8px 2px; font-size: 0.95em;
6992
+ }
6993
+ .precount-error td { color: #dc3545; }
6994
+ .precount-pending {
6995
+ color: #888; font-size: 0.85em; font-style: italic; padding: 8px 0 2px;
6996
+ display: flex; align-items: center; gap: 6px;
6997
+ }
6998
+ .precount-spinner {
6999
+ display: inline-block; width: 12px; height: 12px;
7000
+ border: 2px solid #ddd; border-top-color: #4a90d9;
7001
+ border-radius: 50%; animation: precount-spin 0.8s linear infinite;
7002
+ }
7003
+ @keyframes precount-spin { to { transform: rotate(360deg); } }
6895
7004
  `,
6896
7005
  Templates: [{
6897
7006
  Hash: 'DataCloner-Layout',