Skip to content

Commit 448345b

Browse files
fix(eta): server-authoritative windowed-throughput ETA, consistent across admin + live
admin.html (updateETA) and live.html (cbRender) each computed ETA independently from different inputs (run-start vs batch-start timestamp; status counters vs deduped result rows), so they disagreed. The server now owns one ETA and broadcasts it; both pages just render the same value. server.js: - computeEtaFinishAt(st): windowed real-throughput estimate. Keeps a rolling window (ETA_WINDOW=12) of node-completion epochs, derives rate (nodes/ms) from the first/last span, and returns an absolute finish epoch Date.now() + remaining/rate. Null unless status==='running' with >=2 completions and total>0; Date.now() (finish-now) when remaining<=0. - broadcast(): on 'result' record the completion FIRST (so the just-finished node is in the window), detect a new pass via counter reset, then stamp data.state.etaFinishAtMs onto any state payload (covers both 'state' and direct-pipeline 'result' broadcasts). - /api/events init frame computes etaFinishAtMs so a fresh admin isn't blank. - etaFinishAtMs added to PUBLIC_STATE_KEYS so /live receives it. admin.html + live.html: ETA is now a per-second countdown to state.etaFinishAtMs (HH:MM:SS), replacing the divergent elapsed/tested math. live.html gets a dedicated 1s ETA ticker (gated on _cb.startedAt so it freezes on stop/done). test/eta-estimate.test.js: vm-extracts the real computeEtaFinishAt and asserts the null guards, finish-now path, first/last-span window math (rate 1/sec, remaining 20 -> ~20000ms), and that PUBLIC_STATE_KEYS carries etaFinishAtMs. public-sse-fields.test.js updated for the new 11-key allowlist. Full suite 0.
1 parent 1bde963 commit 448345b

6 files changed

Lines changed: 248 additions & 69 deletions

File tree

admin.html

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1488,25 +1488,20 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
14881488
}
14891489

14901490
function updateETA() {
1491+
// Server-authoritative ETA: the server computes ONE absolute finish epoch
1492+
// (state.etaFinishAtMs) from a windowed real-throughput estimate and
1493+
// broadcasts it. Both admin and live render the same value — we just
1494+
// count down to it. See computeEtaFinishAt() in server.js.
14911495
const el = document.getElementById('etaTime');
1492-
if (state.status !== 'running' || !state.startedAt) {
1493-
if (state.status === 'done') el.textContent = '00:00:00';
1494-
return;
1495-
}
1496-
let done, total;
1497-
if (state.retestMode) {
1498-
done = state.retestTested || 0;
1499-
total = state.retestTotal || 0;
1496+
if (state.status === 'done') { el.textContent = '00:00:00'; return; }
1497+
if (state.status !== 'running') return;
1498+
if (Number.isFinite(state.etaFinishAtMs)) {
1499+
const rem = Math.max(0, state.etaFinishAtMs - Date.now());
1500+
const h = Math.floor(rem / 3600000), m = Math.floor((rem % 3600000) / 60000), s = Math.floor((rem % 60000) / 1000);
1501+
el.textContent = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
15001502
} else {
1501-
done = (state.testedNodes || 0) + (state.failedNodes || 0) + (state.skippedNodes || 0);
1502-
total = state.totalNodes || 0;
1503+
el.textContent = 'Calculating...';
15031504
}
1504-
const remaining = total - done;
1505-
if (done === 0 || remaining <= 0) { el.textContent = 'Calculating...'; return; }
1506-
const elapsed = Date.now() - new Date(state.startedAt).getTime();
1507-
const ms = remaining * (elapsed / done);
1508-
const h = Math.floor(ms / 3600000), m = Math.floor((ms % 3600000) / 60000), s = Math.floor((ms % 60000) / 1000);
1509-
el.textContent = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
15101505
}
15111506

15121507
function applyState() {

live.html

Lines changed: 20 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,17 +1718,18 @@ <h1 class="live-page-title">
17181718

17191719
const etaEl = _cbEl('cbEta');
17201720
if (etaEl) {
1721-
const remaining = snap > 0 ? snap - tested : 0;
1722-
const startedAt = _cb.startedAt ? new Date(_cb.startedAt).getTime() : 0;
1723-
if (snap > 0 && tested > 0 && remaining > 0 && startedAt > 0) {
1724-
const elapsed = Date.now() - startedAt;
1725-
const ms = remaining * (elapsed / tested);
1726-
const h = Math.floor(ms / 3600000);
1727-
const m = Math.floor((ms % 3600000) / 60000);
1728-
const s = Math.floor((ms % 60000) / 1000);
1721+
// Server-authoritative ETA: the server computes ONE absolute finish
1722+
// epoch (_liveState.etaFinishAtMs) from a windowed real-throughput
1723+
// estimate and broadcasts it. We just count down to it — so this page
1724+
// and admin.html always show the same value. The old elapsed/tested
1725+
// per-node math (which diverged from admin's) is gone.
1726+
const finishAt = Number(_liveState && _liveState.etaFinishAtMs);
1727+
if (Number.isFinite(finishAt) && finishAt > 0) {
1728+
const rem = Math.max(0, finishAt - Date.now());
1729+
const h = Math.floor(rem / 3600000);
1730+
const m = Math.floor((rem % 3600000) / 60000);
1731+
const s = Math.floor((rem % 60000) / 1000);
17291732
etaEl.textContent = `ETA ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
1730-
} else if (snap > 0 && remaining <= 0 && tested > 0) {
1731-
etaEl.textContent = 'ETA 00:00:00';
17321733
} else {
17331734
etaEl.textContent = 'ETA —';
17341735
}
@@ -1885,48 +1886,17 @@ <h1 class="live-page-title">
18851886
}
18861887

18871888
setInterval(() => {
1888-
if (_cb.startedAt && _cb.tested >= 0) cbRender();
18891889
renderLiveStats();
18901890
applyHeaderStatsFromState();
18911891
}, 5000);
18921892

1893-
// True only while a run is genuinely in flight. A stopped/done/error/idle
1894-
// status from the server means the run ended — the ETA must freeze and the
1895-
// status label must drop out of "running". Without this gate the ETA timer
1896-
// keeps ticking off `_cb.startedAt` (which is never cleared on stop) so the
1897-
// countdown climbs forever and /live looks like the test is still running.
1898-
function isRunLive() {
1899-
// The ETA ticks ONLY while the run is actively testing. Paused states
1900-
// (the pipeline emits paused_balance / paused_internet — never bare
1901-
// 'paused') freeze the ETA so a long balance/internet pause doesn't inflate
1902-
// the projection; terminal states (stopped/done/error/idle) freeze it too.
1903-
// Resume re-emits status='running' + a fresh batch:start, re-arming the ETA.
1904-
return ((_liveState && _liveState.status) || '') === 'running';
1905-
}
1906-
1907-
// Tick ETA every second so the countdown reads smoothly between
1908-
// node:result events (which can be 10–60s apart on slow nodes).
1893+
// Dedicated 1s ETA ticker. The ETA is now a countdown to the
1894+
// server-broadcast absolute finish epoch (_liveState.etaFinishAtMs), so it
1895+
// must re-render every second to tick down between server events. Gated on
1896+
// _cb.startedAt (cbFreeze nulls it on stop/done/error/idle) so a finished
1897+
// run's ETA freezes instead of marching.
19091898
setInterval(() => {
1910-
if (!_cb.startedAt) return;
1911-
// Freeze the ETA the moment the run is no longer live (stopped/done/etc).
1912-
if (!isRunLive()) return;
1913-
const etaEl = document.getElementById('cbEta');
1914-
if (!etaEl) return;
1915-
// Use the same run-total source cbRender() uses (prefers
1916-
// _liveState.totalNodes, falls back to _cb.snapshotSize) so the ticker
1917-
// and the rendered progress don't diverge on resume → no per-second flicker.
1918-
const snap = runTotalNodes();
1919-
const tested = (Array.isArray(resultsArr) && resultsArr.length > 0) ? resultsArr.length : _cb.tested;
1920-
const remaining = snap > 0 ? snap - tested : 0;
1921-
const startedAt = _cb.startedAt ? new Date(_cb.startedAt).getTime() : 0;
1922-
if (snap > 0 && tested > 0 && remaining > 0 && startedAt > 0) {
1923-
const elapsed = Date.now() - startedAt;
1924-
const ms = remaining * (elapsed / tested);
1925-
const h = Math.floor(ms / 3600000);
1926-
const m = Math.floor((ms % 3600000) / 60000);
1927-
const s = Math.floor((ms % 60000) / 1000);
1928-
etaEl.textContent = `ETA ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
1929-
}
1899+
if (_cb.startedAt) cbRender();
19301900
}, 1000);
19311901

19321902
// ─── Upsert ───
@@ -2477,7 +2447,9 @@ <h1 class="live-page-title">
24772447
} else if (newStatus && newStatus.indexOf('paused') === 0) {
24782448
// paused_balance / paused_internet — show a friendly 'paused' label.
24792449
// Not terminal: don't cbFreeze (keep startedAt so the resumed run's
2480-
// ETA continues); the ETA is frozen meanwhile via isRunLive().
2450+
// ETA continues). The ETA is frozen meanwhile by the server:
2451+
// computeEtaFinishAt returns null when status !== 'running', so the
2452+
// broadcast etaFinishAtMs goes null and cbRender shows "ETA —".
24812453
setStatus('paused');
24822454
}
24832455
renderLiveStats();

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"postinstall": "node scripts/postinstall.js",
3939
"start": "node server.js",
4040
"audit": "node server.js",
41-
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js && node test/public-run-active.test.js && node test/live-single-poll-gate.test.js && node test/public-sse-fields.test.js && node test/admin-sse-diag-trim.test.js",
41+
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js && node test/public-run-active.test.js && node test/live-single-poll-gate.test.js && node test/public-sse-fields.test.js && node test/admin-sse-diag-trim.test.js && node test/eta-estimate.test.js",
4242
"test:ui": "node test/ui.smoke.test.js",
4343
"test:integration": "node test/continuous.audit-integration.test.js && node test/continuous.live-db-write.test.js && node test/node-detail.smoke.test.js && node test/resume-results-seed.test.js",
4444
"verify:prod-db": "node scripts/verify-prod-db.mjs",

server.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,36 @@ function appendEventLog(msg) {
348348
}
349349
}
350350

351+
// ─── Server-authoritative ETA (windowed real-throughput) ───────────────────
352+
// Both admin.html and live.html used to compute ETA independently from
353+
// different inputs, so they disagreed. The server now owns the single source
354+
// of truth: it keeps a rolling window of the last ETA_WINDOW node-completion
355+
// timestamps, derives the current completion RATE (nodes/ms), and broadcasts
356+
// an ABSOLUTE finish epoch (`etaFinishAtMs`). Clients render
357+
// max(0, etaFinishAtMs - Date.now()) as HH:MM:SS, ticking every second. This
358+
// is throughput-based (parallelism-aware), not per-node-duration based.
359+
const ETA_WINDOW = 12;
360+
let _etaCompletions = []; // recent node-completion epoch ms (rolling, current pass)
361+
let _etaLastDone = -1; // last seen completed-count, to detect a new pass
362+
363+
function computeEtaFinishAt(st) {
364+
if (!st || typeof st !== 'object') return null;
365+
// Only meaningful while a pass is actively running. done/idle/paused_* → null.
366+
if (st.status !== 'running') return null;
367+
const done = (st.testedNodes || 0) + (st.failedNodes || 0) + (st.skippedNodes || 0);
368+
const total = st.totalNodes || 0;
369+
if (total <= 0) return null;
370+
const remaining = total - done;
371+
if (remaining <= 0) return Date.now(); // finish now → renders 00:00:00
372+
if (_etaCompletions.length < 2) return null; // not enough data → client shows "Calculating…"
373+
const span = _etaCompletions[_etaCompletions.length - 1] - _etaCompletions[0];
374+
if (span <= 0) return null;
375+
const n = _etaCompletions.length - 1; // intervals across the window
376+
const ratePerMs = n / span;
377+
const etaMs = remaining / ratePerMs;
378+
return Date.now() + Math.round(etaMs);
379+
}
380+
351381
function broadcast(type, data = {}) {
352382
if (type === 'log' && data.msg) {
353383
// Tag the live SSE 'log' event with a category so admin/live can filter
@@ -364,6 +394,30 @@ function broadcast(type, data = {}) {
364394
if (data.cat === 'events') appendEventLog(data.msg);
365395
}
366396
if (type === 'state' || type === 'result') saveStateSnapshot();
397+
// ─── ETA bookkeeping ──────────────────────────────────────────────────────
398+
// Record the just-finished node FIRST (so it's in the window), then compute
399+
// the absolute finish epoch and stamp it onto any state payload so admin
400+
// (which ticks on 'result' events) and live (which ticks on 'state') both
401+
// render the same value.
402+
if (type === 'result') {
403+
_etaCompletions.push(Date.now());
404+
if (_etaCompletions.length > ETA_WINDOW) {
405+
_etaCompletions = _etaCompletions.slice(-ETA_WINDOW);
406+
}
407+
}
408+
if (data && data.state && typeof data.state === 'object') {
409+
const s = data.state;
410+
const done = (s.testedNodes || 0) + (s.failedNodes || 0) + (s.skippedNodes || 0);
411+
if (done < _etaLastDone) _etaCompletions = []; // counters reset → new pass
412+
_etaLastDone = done;
413+
// NOTE: for most callers data.state IS the long-lived global `state` object,
414+
// so this assignment MUTATES the global in place — etaFinishAtMs is not a
415+
// per-payload-only field. That's fine: it's recomputed on every broadcast,
416+
// and computeEtaFinishAt SELF-CLEARS it to null whenever status !== 'running'
417+
// (done/idle/paused_*), so a stale value can never linger on the global.
418+
// saveStateSnapshot's allowlist also excludes it, so it never persists.
419+
data.state.etaFinishAtMs = computeEtaFinishAt(data.state);
420+
}
367421
// NOTE: spread `data` FIRST so a payload field named `type` (e.g. the node's
368422
// service-type like 'wireguard') cannot clobber the SSE event type. The
369423
// event type is the dispatch key — clients switch on d.type — so it must win.
@@ -1900,9 +1954,13 @@ function _redactPublicError(v, max = 200) {
19001954
// the Server-Baseline header tile.
19011955
// - activeSDK ('js' | 'tkd' | 'csharp') surfaces next to the run-mode label;
19021956
// display name + version are joined client-side via /api/public/sdk-info.
1957+
// - etaFinishAtMs is the server-authoritative absolute finish epoch (ms);
1958+
// live.html renders max(0, etaFinishAtMs - Date.now()) as a ticking ETA so
1959+
// it matches admin.html exactly.
19031960
const PUBLIC_STATE_KEYS = [
19041961
'status',
19051962
'totalNodes',
1963+
'etaFinishAtMs',
19061964
'baselineMbps',
19071965
'baselineHistory',
19081966
'testRun',
@@ -2316,6 +2374,10 @@ app.get('/api/events', adminOnly, rlAdminSse, (req, res) => {
23162374
// Strip wallet + balance internals AND runGranter (subscription granter
23172375
// address — operator-internal, never needs to leave the server).
23182376
const { walletAddress, balance, balanceUdvpn, spentUdvpn, runGranter, ...stateForSse } = state;
2377+
// The global `state` doesn't carry a fresh etaFinishAtMs (it's stamped onto
2378+
// broadcast payloads, not the global), so a freshly-connected admin would be
2379+
// blank until the next event. Compute it once here for the init frame.
2380+
stateForSse.etaFinishAtMs = computeEtaFinishAt(state);
23192381
// Trim each result row's diag to the 4 fields the dashboard reads (drop the
23202382
// credential/config/stdout blob). New array of clones — getResults() returns
23212383
// the shared in-memory state rows; never mutate them.

0 commit comments

Comments
 (0)