retold-data-service 2.0.18 → 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.18",
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,6 +497,16 @@ 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
  {
@@ -558,6 +576,59 @@ class DataClonerProvider extends libPictProvider
558
576
  .catch(function() { /* ignore poll errors */ });
559
577
  }
560
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
+
561
632
  renderStatusDetail()
562
633
  {
563
634
  let tmpContainer = document.getElementById('DataCloner-StatusDetail-Container');
@@ -568,6 +639,19 @@ class DataClonerProvider extends libPictProvider
568
639
  let tmpStatusData = tmpAppData.StatusDetailData;
569
640
  let tmpReport = tmpAppData.LastReport;
570
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
+
571
655
  // Determine data source: live during sync, report after sync
572
656
  let tmpTables = {};
573
657
  let tmpThroughputSamples = [];
@@ -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,6 +5321,14 @@
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();
@@ -5370,6 +5382,49 @@
5370
5382
  tmpSelf.renderStatusDetail();
5371
5383
  }).catch(function () {/* ignore poll errors */});
5372
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
+ }
5373
5428
  renderStatusDetail() {
5374
5429
  let tmpContainer = document.getElementById('DataCloner-StatusDetail-Container');
5375
5430
  if (!tmpContainer) return;
@@ -5378,6 +5433,15 @@
5378
5433
  let tmpStatusData = tmpAppData.StatusDetailData;
5379
5434
  let tmpReport = tmpAppData.LastReport;
5380
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
+
5381
5445
  // Determine data source: live during sync, report after sync
5382
5446
  let tmpTables = {};
5383
5447
  let tmpThroughputSamples = [];
@@ -6909,6 +6973,34 @@ select { background: #fff; width: 100%; padding: 8px 12px; border: 1px solid #cc
6909
6973
  font-size: 0.78em; color: #888; margin-top: 4px; padding-left: 18px;
6910
6974
  font-family: monospace; max-height: 80px; overflow-y: auto;
6911
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); } }
6912
7004
  `,
6913
7005
  Templates: [{
6914
7006
  Hash: 'DataCloner-Layout',