SENTINEL NODE TEST
} catch {} window.location.href = '/'; } - let state = { status: 'idle', dryRun: false, totalNodes: 0, testedNodes: 0, failedNodes: 0, retryCount: 0, passed15: 0, passedBaseline: 0, baselineMbps: null, baselineHistory: [], nodeSpeedHistory: [], startedAt: null, pauseReason: null }; + let state = { status: 'idle', testRun: false, totalNodes: 0, testedNodes: 0, failedNodes: 0, retryCount: 0, passed15: 0, passedBaseline: 0, baselineMbps: null, baselineHistory: [], nodeSpeedHistory: [], startedAt: null, pauseReason: null }; let resultsArr = []; // Full country map — 80+ countries from Sentinel SDK (core/countries.js) const _CC = { @@ -1199,7 +1219,7 @@SENTINEL NODE TEST
done = state.retestTested || 0; total = state.retestTotal || 0; } else { - done = (state.testedNodes || 0) + (state.failedNodes || 0); + done = (state.testedNodes || 0) + (state.failedNodes || 0) + (state.skippedNodes || 0); total = state.totalNodes || 0; } const remaining = total - done; @@ -1216,6 +1236,47 @@SENTINEL NODE TEST
const active = running || paused; document.getElementById('headerDot').className = 'pulse-dot' + (running ? ' running' : paused ? ' paused' : ''); + // ─── Mode badge: clear "what's running right now" ────────────────── + // Three states the operator and viewer must distinguish at a glance: + // • Plan #N — testing only nodes inside a subscription plan + // • P2P — testing every online node, paying each session ourselves + // • Test Run — sample/skip data, no real measurements + const modeBadge = document.getElementById('modeBadge'); + if (modeBadge) { + if (!active) { + modeBadge.style.display = 'none'; + } else { + const lbl = document.getElementById('modeBadgeLabel'); + const det = document.getElementById('modeBadgeDetail'); + const dot = document.getElementById('modeBadgeDot'); + let label, detail, color; + if (state.testRun || state.runMode === 'test') { + label = 'Test Run'; + detail = 'sample data — no real measurements, no payments'; + color = 'var(--accent)'; + } else if (state.runMode === 'subscription' && state.runPlanId) { + label = 'Plan #' + state.runPlanId; + detail = 'subscription-allocated sessions, plan-scoped node set'; + color = 'var(--green)'; + } else { + label = 'P2P'; + detail = 'all online nodes, per-session payments from this wallet'; + color = 'var(--accent-bright, var(--accent))'; + } + modeBadge.style.display = ''; + modeBadge.style.borderColor = color; + modeBadge.style.color = color; + if (dot) dot.style.background = color; + if (lbl) lbl.textContent = label; + if (det) det.textContent = detail; + } + } + + // Mode/pay/loop UI lives in the New Test chooser popup now — the toolbar + // stubs are display:none. Mode is locked by virtue of the chooser only + // opening when the operator clicks New Test, which is itself disabled + // while a run is active. + const btnStart = document.getElementById('btnStart'); const btnResume = document.getElementById('btnResume'); const btnRetest = document.getElementById('btnRetestFails'); @@ -1229,13 +1290,13 @@SENTINEL NODE TEST
const label = paused ? 'Paused' : 'Running...'; btnStart.textContent = label; btnStart.classList.add('loading'); - btnStop.textContent = 'Stop Audit'; + btnStop.textContent = 'Stop Test'; btnStop.classList.remove('loading'); } else { btnStart.textContent = 'New Test'; btnStart.classList.remove('loading'); if (btnResume) { btnResume.textContent = 'Resume'; btnResume.classList.remove('loading'); btnResume.disabled = false; } - btnStop.textContent = 'Stop Audit'; + btnStop.textContent = 'Stop Test'; btnStop.classList.remove('loading'); } @@ -1274,34 +1335,52 @@SENTINEL NODE TEST
} const t = state.totalNodes || 0; - const done = state.testedNodes || 0; const fail = state.failedNodes || 0; + const skipped = state.skippedNodes || 0; const p10 = state.passed10 || 0; - const processed = done + fail; - - document.getElementById('statTested').textContent = processed; + // The SSE `result` event delivers every row once and we upsert by + // address — `resultsArr.length` is the authoritative processed count. + // Don't sum state counters; they double-count whenever the pipeline + // increments a counter before the next recomputeCounters pass. + const processed = Array.isArray(resultsArr) ? resultsArr.length : 0; + const done = resultsArr ? resultsArr.filter(r => r && r.actualMbps != null).length : (state.testedNodes || 0); + if (t > 0) { + document.getElementById('statTested').textContent = `${processed.toLocaleString()} / ${t.toLocaleString()}`; + } else { + document.getElementById('statTested').textContent = processed.toLocaleString(); + } if (t > 0 && t > processed) { const remaining = t - processed; - document.getElementById('statTestedSub').textContent = `of ${t.toLocaleString()} total | ${remaining.toLocaleString()} remaining`; + document.getElementById('statTestedSub').textContent = `${remaining.toLocaleString()} remaining`; } else if (t > 0 && t <= processed) { - document.getElementById('statTestedSub').textContent = `of ${t.toLocaleString()} total | complete`; + document.getElementById('statTestedSub').textContent = 'complete'; } else { document.getElementById('statTestedSub').textContent = processed > 0 ? `${processed} tested | hit Rescan for total` : 'no data yet'; } + // TEST RUN detection — mirrors /live's logic so SLA-derived tiles can + // dash out (those metrics are not measured during a skip-only demo). + const isDryRun = !!state.testRun || + resultsArr.some(r => r && r.errorCode === 'TEST_RUN_SKIP'); + const DASH = '—'; + // Total Failed - document.getElementById('statTotalFailed').textContent = fail; + document.getElementById('statTotalFailed').textContent = isDryRun ? DASH : fail; const failPct = processed > 0 ? (fail / processed * 100).toFixed(1) : 0; - document.getElementById('statTotalFailedPct').textContent = failPct + '% failure rate'; + document.getElementById('statTotalFailedPct').textContent = isDryRun + ? 'not measured in test run' + : failPct + '% failure rate'; // 10 Mbps SLA + pass rate combined - document.getElementById('statPassed').textContent = p10; + document.getElementById('statPassed').textContent = isDryRun ? DASH : p10; const passRate = done > 0 ? (p10 / done * 100).toFixed(1) : 0; - document.getElementById('statPassedPct').textContent = passRate + '% of connected'; + document.getElementById('statPassedPct').textContent = isDryRun + ? 'not measured in test run' + : passRate + '% of connected'; // Pass Rate = successful connections / total tested const connRate = processed > 0 ? (done / processed * 100).toFixed(1) : 0; - document.getElementById('statPassRate').textContent = connRate + '%'; + document.getElementById('statPassRate').textContent = isDryRun ? DASH : connRate + '%'; // Not Online: failed nodes with 0 peers or null peers const notOnline = resultsArr.filter(r => r.actualMbps == null && (r.peers === 0 || r.peers == null)).length; @@ -1330,11 +1409,13 @@SENTINEL NODE TEST
const currentSDK = state.activeSDK || 'js'; const currentRunResults = resultsArr.filter(r => !r.sdk || r.sdk === currentSDK); const deadPlan = currentRunResults.filter(r => r.inPlan && r.actualMbps == null).length; - document.getElementById('statDeadPlan').textContent = deadPlan; + document.getElementById('statDeadPlan').textContent = isDryRun ? DASH : deadPlan; const totalPlanTested = currentRunResults.filter(r => r.inPlan).length; - document.getElementById('statDeadPlanPct').textContent = totalPlanTested > 0 - ? `${deadPlan}/${totalPlanTested} plan nodes failed` - : 'No plan failures'; + document.getElementById('statDeadPlanPct').textContent = isDryRun + ? 'not measured in test run' + : (totalPlanTested > 0 + ? `${deadPlan}/${totalPlanTested} plan nodes failed` + : 'No plan failures'); const retryCount = state.retryCount || 0; const fill = document.getElementById('progressFill'); @@ -1553,7 +1634,8 @@SENTINEL NODE TEST
const isFail = r.actualMbps == null && !r.skipped; const copyCell = isFail && addrFull - ? `` + ? ` + ` : '—'; tr.innerHTML = ` @@ -1684,11 +1766,94 @@SENTINEL NODE TEST
} }).catch(() => {}); - async function devStart(dryRun) { + let _pricingMode = 'gigabytes'; + function setPricingMode(mode) { + _pricingMode = mode === 'hours' ? 'hours' : 'gigabytes'; + const gb = document.getElementById('priceModeGB'); + const hr = document.getElementById('priceModeHr'); + const active = 'background:var(--accent);color:var(--bg-card-solid);border:0;padding:0 12px;height:100%;cursor:pointer;font:inherit;letter-spacing:inherit;'; + const idle = 'background:transparent;color:var(--text-dim);border:0;padding:0 12px;height:100%;cursor:pointer;font:inherit;letter-spacing:inherit;'; + const idleR = idle + 'border-left:1px solid var(--border);'; + if (gb && hr) { + gb.setAttribute('style', _pricingMode === 'gigabytes' ? active : idle); + hr.setAttribute('style', _pricingMode === 'hours' ? active + 'border-left:1px solid var(--border);' : idleR); + } + try { localStorage.setItem('sntPricingMode', _pricingMode); } catch {} + } + try { + const saved = localStorage.getItem('sntPricingMode'); + if (saved === 'hours') setPricingMode('hours'); + } catch {} + + // ─── Testing Mode (PLAN / P2P / TEST RUN) ──────────────────────────────── + // One mutually-exclusive mode drives Start Test, Resume, and Retest Failed. + // ∞ LOOP is orthogonal — it applies to whichever mode is active. + // _selectedPlanId: when PLAN mode is active and a plan has been picked from + // the modal, the MODE box shows "PLAN #N" instead of just "PLAN". + let _testingMode = 'p2p'; + let _selectedPlanId = null; + let _selectedSubscriptionId = null; + let _selectedGranter = null; + function _renderPlanLabel() { + const lbl = document.getElementById('modePlanLabel'); + if (!lbl) return; + lbl.textContent = (_testingMode === 'plan' && _selectedPlanId) ? ('PLAN #' + _selectedPlanId) : 'PLAN'; + } + function setTestingMode(mode, opts) { + const prev = _testingMode; + _testingMode = (mode === 'plan' || mode === 'testrun') ? mode : 'p2p'; + const ids = { plan: 'modePlan', p2p: 'modeP2P', testrun: 'modeTestRun' }; + Object.entries(ids).forEach(([m, id]) => { + const el = document.getElementById(id); + if (!el) return; + if (m === _testingMode) { + el.style.background = 'var(--accent)'; + el.style.color = 'var(--bg-card-solid)'; + } else { + el.style.background = 'transparent'; + el.style.color = 'var(--text-dim)'; + } + }); + // Pricing toggle is meaningless in PLAN mode (subscription-allocated sessions) + const priceWrap = document.getElementById('pricingModeWrap'); + if (priceWrap) priceWrap.style.opacity = _testingMode === 'plan' ? '0.45' : '1'; + // Switching away from PLAN clears the previously-picked plan id. + if (_testingMode !== 'plan') { + _selectedPlanId = null; + _selectedSubscriptionId = null; + _selectedGranter = null; + } + _renderPlanLabel(); + try { localStorage.setItem('sntTestingMode', _testingMode); } catch {} + } + + // Clicking PLAN should open the picker WITHOUT pre-committing the mode. + // If the operator dismisses the modal without picking a plan, the toggle + // stays on whatever was previously active (P2P, TEST RUN, or a previously + // chosen plan). Only runSubPlanTest() (called when the operator actually + // picks a plan from the modal) commits the mode via setTestingMode('plan', + // { fromPick: true }). + function onClickPlanMode() { + try { openSubPlanModal(); } catch {} + } + try { + const savedMode = localStorage.getItem('sntTestingMode'); + if (savedMode) setTestingMode(savedMode, { fromPick: true }); + } catch {} + + function isTestRunMode() { return _testingMode === 'testrun'; } + function isPlanMode() { return _testingMode === 'plan'; } + + async function devStart(testRun) { + const loopEl = document.getElementById('infiniteLoop'); + const infiniteLoop = !!(loopEl && loopEl.checked); + const body = { pricingMode: _pricingMode }; + if (testRun) body.testRun = true; + if (infiniteLoop) body.infiniteLoop = true; const res = await fetch('/api/start', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Request': '1' }, - body: JSON.stringify(dryRun ? { dryRun: true } : {}), + body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); if (data.error) { appendLog('Start failed: ' + data.error); return false; } @@ -1697,8 +1862,118 @@SENTINEL NODE TEST
async function startAudit() { if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; } - const dryEl = document.getElementById('dryRunDev'); - const isDryRun = !!(dryEl && dryEl.checked); + // Open the mode chooser — operator picks Subscription Plan / Peer to Peer / Test Run. + // Each tile commits the testing mode and then calls _startAuditAfterChoice(). + openModeChooser(); + } + + function openModeChooser() { + // Tear down any previous chooser + const existing = document.getElementById('modeChooserBackdrop'); + if (existing) existing.remove(); + + const backdrop = document.createElement('div'); + backdrop.id = 'modeChooserBackdrop'; + backdrop.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9998;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);'; + + const modal = document.createElement('div'); + modal.style.cssText = 'background:var(--bg-card-solid);border:1px solid var(--border-hover);border-radius:10px;padding:28px;max-width:920px;width:92%;max-height:90vh;overflow:auto;color:var(--text);font-family:var(--font-display);'; + + const close = () => backdrop.remove(); + backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); }); + const onKey = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } }; + document.addEventListener('keydown', onKey); + + modal.innerHTML = ` +TEST_RUN_SKIP.SENTINEL NODE TEST
try { if (!await devStart(isDryRun)) { btn.classList.remove('loading'); - btn.textContent = 'New Test'; + btn.textContent = 'Start Test'; btn.disabled = false; return; } loadRunsList(); } catch (e) { btn.classList.remove('loading'); - btn.textContent = 'New Test'; + btn.textContent = 'Start Test'; btn.disabled = false; } } async function resumeAudit() { - const dryEl = document.getElementById('dryRunDev'); - if (dryEl && dryEl.checked) { + if (isTestRunMode()) { if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; } await devStart(true); return; } + // Plan mode: server-side resume is mode-aware and uses the persisted + // planId/subId/granter from the run that was paused — DO NOT re-prompt. const btn = document.getElementById('btnResume'); btn.classList.add('loading'); btn.textContent = 'Resuming...'; @@ -1773,8 +2049,7 @@SENTINEL NODE TEST
} async function retestFails() { - const dryEl = document.getElementById('dryRunDev'); - if (dryEl && dryEl.checked) { + if (isTestRunMode()) { if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; } await devStart(true); return; @@ -1829,6 +2104,118 @@SENTINEL NODE TEST
} } + // Sentinel Audit CLI reference popup. Mirrors the bundled `bin/cli.js` + // (npm bin: `sentinel-audit`). Closeable via × button, Escape, or + // backdrop click — same UX as showInfoPopup(). + function showCliPopup() { + const existing = document.getElementById('cliOverlay'); + if (existing) { existing.remove(); return; } + + const overlay = document.createElement('div'); + overlay.id = 'cliOverlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:var(--glass-bg);backdrop-filter:blur(8px);z-index:9999;display:flex;align-items:center;justify-content:center;'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + const popup = document.createElement('div'); + popup.style.cssText = 'background:var(--bg-card-solid);border:1px solid var(--border-hover);border-radius:16px;padding:28px 32px;max-width:760px;width:92%;max-height:86vh;overflow-y:auto;color:var(--text);font-family:var(--font-display);line-height:1.55;box-shadow:var(--shadow-lg)'; + + const codeStyle = 'font-family:var(--font-mono,ui-monospace,Menlo,Consolas,monospace);background:var(--bg-input);color:var(--text);padding:2px 6px;border-radius:4px;font-size:12px;border:1px solid var(--border);'; + const blockStyle = 'font-family:var(--font-mono,ui-monospace,Menlo,Consolas,monospace);background:var(--bg-input);color:var(--text);padding:10px 12px;border-radius:6px;font-size:12px;border:1px solid var(--border);white-space:pre;overflow-x:auto;line-height:1.6;'; + + popup.innerHTML = ` +Sentinel Audit CLI
+
- Stop Audit
+ Stop Test
Gracefully stops the current scan after the active node finishes. Results are saved. You can Resume later.
- Subscription Plan
- Test Sub. Plan
+ Test Subscription Plan
×
Test Sub. Plan
+Test Subscription Plan
FEE GRANT ACTIVE'
: 'NO FEE GRANT';
+ const allocBadge = p.viaAllocation
+ ? 'ALLOCATION'
+ : '';
const disabled = !p.feeGrantActive || p.nodeCount === 0 || !p.ownerSentAddr;
const reason = !p.ownerSentAddr ? 'Plan owner unknown'
: p.nodeCount === 0 ? 'No active nodes in this plan'
@@ -1976,6 +2365,7 @@
Plan ${p.planId}
${badge}
+ ${allocBadge}
{ btn.innerHTML = original; btn.disabled = false; }, 1500);
}
+ async function showRowFailurePopup(ev) {
+ ev.stopPropagation();
+ const btn = ev.currentTarget;
+ const addr = btn.dataset.addr;
+ const moniker = btn.dataset.moniker || '';
+ if (!addr) { nsShowToast('No address'); return; }
+
+ const existing = document.getElementById('errorPopupOverlay');
+ if (existing) existing.remove();
+
+ const overlay = document.createElement('div');
+ overlay.id = 'errorPopupOverlay';
+ overlay.style.cssText = 'position:fixed;inset:0;background:var(--glass-bg);backdrop-filter:blur(8px);z-index:9999;display:flex;align-items:center;justify-content:center;';
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
+
+ const popup = document.createElement('div');
+ popup.style.cssText = 'background:var(--bg-card-solid);border:1px solid var(--border);border-radius:16px;padding:24px 28px;max-width:780px;width:92%;max-height:85vh;overflow-y:auto;color:var(--text);font-family:var(--font-display);line-height:1.55';
+ popup.innerHTML = `
+
+ Node Error Details
+ ×
+
+ Loading…
+ `;
+ overlay.appendChild(popup);
+ document.body.appendChild(overlay);
+
+ try {
+ const res = await fetch('/api/public/node/' + encodeURIComponent(addr) + '/errors?limit=1', {
+ credentials: 'same-origin',
+ });
+ const data = await res.json();
+ const errs = (data && data.errors) || [];
+ const body = document.getElementById('errorPopupBody');
+ if (!body) return;
+ if (!errs.length) {
+ body.innerHTML = 'No stored failure log for this node yet.';
+ return;
+ }
+ const er = errs[0];
+ const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
+ const captured = er.captured_at ? new Date(typeof er.captured_at === 'number' ? er.captured_at : Number(er.captured_at) || er.captured_at).toLocaleString() : '—';
+ const row = (label, value) => `
+
+ ${label}
+ ${value || '—'}
+ `;
+ body.innerHTML = `
+ ${row('Node', escapeHtml(moniker))}
+ ${row('Address', `${escapeHtml(addr)}`)}
+ ${row('Stage', escapeHtml(er.stage))}
+ ${row('Error Code', `${escapeHtml(er.error_code)}`)}
+ ${row('Captured', escapeHtml(captured))}
+ ${row('Message', `${escapeHtml(er.error_message)}`)}
+ ${er.log_snippet ? `
+
+ Log Snippet
+ ${escapeHtml(er.log_snippet)}
+ ` : ''}
+
+
+
+
+ `;
+ } catch (err) {
+ const body = document.getElementById('errorPopupBody');
+ if (body) body.innerHTML = 'Failed to load: ' + (err.message || 'unknown') + '';
+ }
+ }
+
// ═══════════════════════════════════════════════════════════════════════
// ─── Broadcast Live ──────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════
diff --git a/audit/continuous.js b/audit/continuous.js
index 7a423fb..fda5129 100644
--- a/audit/continuous.js
+++ b/audit/continuous.js
@@ -243,9 +243,27 @@ async function _runOnePass(loopState, batchId, frozenNodes = null) {
};
function batchBroadcast(type, data) {
+ // Forward live log lines + state snapshots through the scoped emitter so
+ // the admin dashboard AND /live (when broadcastLive=true) see real-time
+ // activity during continuous-loop runs — not just batch checkpoints.
+ if (type === 'log') {
+ _emitScoped('log', data || {});
+ return;
+ }
+ if (type === 'state') {
+ _emitScoped('state', data || {});
+ return;
+ }
+ if (type === 'progress') {
+ _emitScoped('progress', data || {});
+ return;
+ }
if (type !== 'result') return; // only care about per-node results
const raw = data?.result;
if (!raw) return;
+ // Forward the full per-node result to the global stream so the live
+ // dashboard upserts rows identically to admin. Public SSE will sanitize.
+ _emitScoped('result', { result: raw, batchId });
const payload = _sanitizeBatchNodeResult(raw, batchId);
_emitScoped('batch:node:result', payload);
// Persist to batch_results (non-blocking, non-fatal)
diff --git a/audit/node-test.js b/audit/node-test.js
index 96b752e..55b395b 100644
--- a/audit/node-test.js
+++ b/audit/node-test.js
@@ -64,6 +64,30 @@ function logFailure(nodeAddr, error, context = {}) {
appendFileSync(FAILURE_LOG, JSON.stringify(entry) + '\n', 'utf8');
}
+// Sleep but break out within ~250ms when state.stopRequested flips.
+async function stopAwareSleep(ms, state) {
+ const tick = 250;
+ const deadline = Date.now() + ms;
+ while (Date.now() < deadline) {
+ if (state?.stopRequested) throw new Error('Stop requested');
+ await sleep(Math.min(tick, deadline - Date.now()));
+ }
+ if (state?.stopRequested) throw new Error('Stop requested');
+}
+
+// Race a promise against state.stopRequested polling so long awaits abort fast.
+async function withStopGuard(promise, state) {
+ let cancelled = false;
+ const stopPoll = new Promise((_, reject) => {
+ const iv = setInterval(() => {
+ if (cancelled) { clearInterval(iv); return; }
+ if (state?.stopRequested) { clearInterval(iv); reject(new Error('Stop requested')); }
+ }, 250);
+ });
+ try { return await Promise.race([promise, stopPoll]); }
+ finally { cancelled = true; }
+}
+
/**
* Test a single node. Returns a TestResult or null if fundamentally untestable.
* With the zero-skip system, null is only returned for truly untestable cases
@@ -164,15 +188,29 @@ export async function testNode(client, account, privkey, node, opts, preSessionI
}
// ─── Price check ──────────────────────────────────────────────────────────
- const priceEntry = (node.gigabyte_prices || []).find(p => p.denom === denom);
- if (!priceEntry) {
+ // Pricing mode: 'gigabytes' (default) or 'hours'.
+ // In subscription-plan flows the pipeline supplies its own messages — this
+ // toggle only affects the P2P MsgStartSession path below.
+ const pricingMode = state.pricingMode === 'hours' ? 'hours' : 'gigabytes';
+ const gigabytePrice = (node.gigabyte_prices || []).find(p => p.denom === denom);
+ const hourlyPrice = (node.hourly_prices || []).find(p => p.denom === denom);
+
+ if (pricingMode === 'hours' && !hourlyPrice) {
+ throw new Error('No hourly udvpn pricing available (node has no hourly_prices entry)');
+ }
+ if (pricingMode === 'gigabytes' && !gigabytePrice) {
throw new Error('No udvpn pricing available');
}
+ const priceEntry = pricingMode === 'hours' ? hourlyPrice : gigabytePrice;
+ const sessionGigabytes = pricingMode === 'hours' ? 0 : gigabytes;
+ const sessionHours = pricingMode === 'hours' ? 1 : 0;
+ const priceUnits = pricingMode === 'hours' ? sessionHours : sessionGigabytes;
+
const nodePriceUdvpn = Math.round(parseFloat(priceEntry.quote_value) || 0);
- const thisCostUdvpn = nodePriceUdvpn * gigabytes;
+ const thisCostUdvpn = nodePriceUdvpn * priceUnits;
- if (state.dryRun) {
+ if (state.testRun) {
if (broadcast) broadcast('log', { msg: ' 🧪 TEST RUN — skipping payment + handshake + speedtest.' });
const _reportedDownloadMbps = status.bandwidth.download * 8 / 1_000_000;
return {
@@ -241,7 +279,8 @@ export async function testNode(client, account, privkey, node, opts, preSessionI
if (broadcast) broadcast('log', { msg: `⚠ LOW BALANCE: ${(remainingBalance / 1_000_000).toFixed(4)} P2P` });
}
- const costLabel = preSessionId ? 'pre-paid' : sessionId ? '0 (reuse)' : thisCostUdvpn + ' udvpn';
+ const unitLabel = pricingMode === 'hours' ? `${sessionHours}h` : `${sessionGigabytes}GB`;
+ const costLabel = preSessionId ? 'pre-paid' : sessionId ? '0 (reuse)' : `${thisCostUdvpn} udvpn (${unitLabel})`;
if (broadcast) broadcast('log', {
msg: `→ ${typeName} | ${status.location.city}, ${status.location.country} | ${reportedDownloadMbps.toFixed(1)} Mbps | Cost: ${costLabel}`,
});
@@ -257,7 +296,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI
typeUrl: V3_MSG_TYPE,
value: {
from: account.address, node_address: node.address,
- gigabytes, hours: 0,
+ gigabytes: sessionGigabytes, hours: sessionHours,
max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value },
},
}], fee, broadcast);
@@ -280,7 +319,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI
typeUrl: V3_MSG_TYPE,
value: {
from: account.address, node_address: node.address,
- gigabytes, hours: 0,
+ gigabytes: sessionGigabytes, hours: sessionHours,
max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value },
},
}], fee, broadcast);
@@ -295,7 +334,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI
typeUrl: V3_MSG_TYPE,
value: {
from: account.address, node_address: node.address,
- gigabytes, hours: 0,
+ gigabytes: sessionGigabytes, hours: sessionHours,
max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value },
},
}], fee, broadcast);
@@ -315,7 +354,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI
typeUrl: V3_MSG_TYPE,
value: {
from: account.address, node_address: node.address,
- gigabytes, hours: 0,
+ gigabytes: sessionGigabytes, hours: sessionHours,
},
}], fee, broadcast);
assertIsDeliverTxSuccess(txResult);
@@ -338,11 +377,11 @@ export async function testNode(client, account, privkey, node, opts, preSessionI
markPaid(node.address);
if (broadcast) broadcast('log', { msg: ` Session ${sessionId} — polling for chain confirmation…` });
- await waitForSessionActive(node.address, account.address, 20_000, sessionId);
+ await withStopGuard(waitForSessionActive(node.address, account.address, 20_000, sessionId), state);
// Extra delay: node needs time to index the session into its own DB
// Without this, handshake races with node indexing → 409 "already exists"
if (broadcast) broadcast('log', { msg: ` Waiting 5s for node to index session…` });
- await sleep(5_000);
+ await stopAwareSleep(5_000, state);
}
// ─── Handshake + Connect ──────────────────────────────────────────────────
@@ -360,7 +399,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI
typeUrl: V3_MSG_TYPE,
value: {
from: account.address, node_address: node.address,
- gigabytes, hours: 0,
+ gigabytes: sessionGigabytes, hours: sessionHours,
max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value },
},
}], fee, broadcast);
@@ -370,7 +409,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI
if (broadcast) broadcast('log', { msg: ` ⚠ "invalid price" — retrying fresh session without max_price...` });
txResult = await signAndBroadcastRetry(client, account.address, [{
typeUrl: V3_MSG_TYPE,
- value: { from: account.address, node_address: node.address, gigabytes, hours: 0 },
+ value: { from: account.address, node_address: node.address, gigabytes: sessionGigabytes, hours: sessionHours },
}], fee, broadcast);
assertIsDeliverTxSuccess(txResult);
} else {
@@ -388,8 +427,8 @@ export async function testNode(client, account, privkey, node, opts, preSessionI
state.estimatedTotalCost = `${(state.spentUdvpn / 1_000_000).toFixed(4)} P2P`;
if (broadcast) broadcast('state', { state });
if (broadcast) broadcast('log', { msg: ` Fresh session ${sessionId} — waiting for chain + node indexing...` });
- await waitForSessionActive(node.address, account.address, 20_000, sessionId);
- await sleep(5_000);
+ await withStopGuard(waitForSessionActive(node.address, account.address, 20_000, sessionId), state);
+ await stopAwareSleep(5_000, state);
return sessionId;
}
diff --git a/audit/pipeline.js b/audit/pipeline.js
index bd5565c..5b8ec26 100644
--- a/audit/pipeline.js
+++ b/audit/pipeline.js
@@ -20,7 +20,48 @@ import {
clearPoisonedSessions, clearPaidNodes, clearAllCredentials, invalidateSessionCache, parseNodePriceUdvpn,
} from '../core/session.js';
import { nodeStatusV3 } from '../protocol/v3protocol.js';
-import { speedtestDirect, sleep, resolveCfHost } from '../protocol/speedtest.js';
+import { speedtestDirect, sleep as _rawSleep, resolveCfHost } from '../protocol/speedtest.js';
+
+// ─── Stop-aware sleep ────────────────────────────────────────────────────────
+// Every sleep in the pipeline races against a module-scope stop signal.
+// When `triggerPipelineStop()` is called (from /api/stop), every pending
+// sleep resolves instantly so the loop drops back to its `if (state.stopRequested) break`
+// check on the next tick — cutting Stop latency from "until next timer fires"
+// (up to 5 minutes for sleep(5*60_000)) to single-digit ms.
+const _stopWaiters = new Set();
+let _pipelineStopFlag = false;
+function sleep(ms) {
+ if (_pipelineStopFlag) return Promise.resolve();
+ return new Promise((resolve) => {
+ let timer = setTimeout(() => { _stopWaiters.delete(resolve); resolve(); }, ms);
+ const wake = () => { try { clearTimeout(timer); } catch {} resolve(); };
+ _stopWaiters.add(wake);
+ });
+}
+export function triggerPipelineStop() {
+ _pipelineStopFlag = true;
+ for (const w of _stopWaiters) { try { w(); } catch {} }
+ _stopWaiters.clear();
+}
+export function resetPipelineStop() {
+ _pipelineStopFlag = false;
+}
+// Race any awaitable against the stop signal. The promise rejects with a
+// `_stopRequested` marker the per-node loop already catches and breaks on.
+function raceStop(promise) {
+ return new Promise((resolve, reject) => {
+ if (_pipelineStopFlag) {
+ const e = new Error('Stop requested'); e._stopRequested = true; return reject(e);
+ }
+ let done = false;
+ const wake = () => { if (done) return; done = true; _stopWaiters.delete(wake); const e = new Error('Stop requested'); e._stopRequested = true; reject(e); };
+ _stopWaiters.add(wake);
+ promise.then(
+ (v) => { if (done) return; done = true; _stopWaiters.delete(wake); resolve(v); },
+ (e) => { if (done) return; done = true; _stopWaiters.delete(wake); reject(e); },
+ );
+ });
+}
// Platform-aware imports — Windows has full implementation, others get stubs
let WG_AVAILABLE, IS_ADMIN, emergencyCleanupSync, uninstallWgTunnel, checkV2Ray;
if (process.platform === 'win32') {
@@ -39,7 +80,9 @@ import { checkAndPauseIfInterference, classifyFailure } from '../protocol/diagno
import { loadTransportCache, getCacheStats, saveTransportCache } from '../core/transport-cache.js';
import { testNode } from './node-test.js';
import { testWithRetry } from './retry.js';
-import { insertResult as _dbInsertResult, insertErrorLog as _dbInsertErrorLog } from '../core/db.js';
+import { insertResult as _dbInsertResult, insertErrorLog as _dbInsertErrorLog,
+ insertBatch as _dbInsertBatch, insertBatchResult as _dbInsertBatchResult,
+ updateBatchOnFinish as _dbUpdateBatchOnFinish } from '../core/db.js';
// ─── Internet Health Check & Auto-Resume ─────────────────────────────────────
const INTERNET_CHECK_TARGETS = ['https://www.google.com', 'https://1.1.1.1', 'https://www.cloudflare.com'];
@@ -237,6 +280,13 @@ export function createState() {
stopRequested: false,
lowBalanceWarning: false,
pauseReason: null,
+ testRun: false,
+ continuousLoop: false,
+ pricingMode: 'gigabytes',
+ runMode: null,
+ runPlanId: null,
+ runSubscriptionId: null,
+ runGranter: null,
};
}
@@ -324,6 +374,7 @@ function buildFailResult(node, status, state, errMsg, diag = {}) {
// ─── Main Audit ─────────────────────────────────────────────────────────────
export async function runAudit(resume, state, broadcast, preloadedNodes = null, opts = {}) {
+ resetPipelineStop();
state.status = 'running';
state.startedAt = new Date().toISOString();
state.errorMessage = null;
@@ -332,7 +383,8 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null,
state.retestPassed = null;
state.retestFailed = null;
- state.dryRun = !!opts.dryRun;
+ state.testRun = !!opts.testRun;
+ state.pricingMode = (opts.pricingMode === 'hours') ? 'hours' : 'gigabytes';
clearPoisonedSessions();
clearPaidNodes();
@@ -468,6 +520,27 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null,
}
state.totalNodes = (resume ? results.length : 0) + viableNodes.length;
+
+ // ── Batch row: drives /api/public/runs/current → /live livestream ──────
+ // Without this, getActiveBatch() never sees the in-flight run and /live
+ // keeps showing the previous (finished) batch. Created on every fresh run
+ // (not on resume — the previous batch row stays open until completion).
+ let _currentBatchId = null;
+ if (!resume) {
+ try {
+ const snapshotAddrs = viableNodes.map(({ node }) => node.address).filter(Boolean);
+ _currentBatchId = _dbInsertBatch({
+ started_at: Date.now(),
+ snapshot_size: viableNodes.length,
+ mode: state.testRun ? 'test' : 'p2p',
+ snapshot_addresses: snapshotAddrs,
+ });
+ state.activeBatchId = _currentBatchId;
+ } catch (batchErr) {
+ broadcast('log', { msg: `[batch] insert failed: ${batchErr.message}` });
+ }
+ }
+
const estCostUdvpn = viableNodes.reduce(
(sum, { node }) => sum + parseNodePriceUdvpn(node.gigabyte_prices) * GIGS, 0
);
@@ -511,7 +584,7 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null,
if (!canProceed) { broadcast('log', { msg: '⏹ Aborting — VPN interference not cleared.' }); break; }
let batchSessionMap;
- if (state.dryRun) {
+ if (state.testRun) {
batchSessionMap = new Map();
} else {
try {
@@ -618,6 +691,7 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null,
if (result.pass10mbps) state.passed10++;
if (result.passBaseline) state.passedBaseline++;
upsertResult(result); // success — no snippet needed
+ if (_currentBatchId) { try { _dbInsertBatchResult(_currentBatchId, { ...result, testedAt: Date.now() }); } catch {} }
saveResults();
broadcast('result', { result, state });
if (result.actualMbps != null) {
@@ -681,6 +755,7 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null,
const failResult = buildFailResult(node, status, state, errMsg, error?.diag || {});
state.failedNodes++;
upsertResult(failResult, _logSnippet);
+ if (_currentBatchId) { try { _dbInsertBatchResult(_currentBatchId, { ...failResult, testedAt: Date.now() }); } catch {} }
saveResults();
broadcast('result', { result: failResult, state });
const retryLabel = retried > 0 ? ` (${retried} retries)` : '';
@@ -853,6 +928,18 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null,
state.currentNode = null;
broadcast('state', { state });
const finalFailed = results.filter(r => r.actualMbps == null && r.error).length;
+ if (_currentBatchId) {
+ try {
+ _dbUpdateBatchOnFinish(_currentBatchId, {
+ finished_at: Date.now(),
+ passed: state.testedNodes,
+ failed: finalFailed,
+ });
+ } catch (finishErr) {
+ broadcast('log', { msg: `[batch] finalize failed: ${finishErr.message}` });
+ }
+ state.activeBatchId = null;
+ }
broadcast('log', { msg: `✅ Audit complete. Tested ${state.testedNodes}, Failed ${finalFailed}. ${state.retryCount} retries total.` });
broadcast('log', { msg: `🧠 Transport cache: ${finalCache.nodesCached} nodes learned for next scan.` });
}
@@ -1355,13 +1442,17 @@ export async function runPlanTest(planId, state, broadcast) {
* This mirrors how Android/iOS consumer apps ship — the end user never holds
* P2P, the plan operator covers all on-chain fees via a pre-granted feegrant.
*/
-export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, broadcast) {
+export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, broadcast, opts = {}) {
+ resetPipelineStop();
+ const resume = !!opts.resume;
state.status = 'running';
state.startedAt = new Date().toISOString();
state.errorMessage = null;
- state.totalNodes = 0;
- state.testedNodes = 0;
- state.failedNodes = 0;
+ if (!resume) {
+ state.totalNodes = 0;
+ state.testedNodes = 0;
+ state.failedNodes = 0;
+ }
state.retryCount = 0;
recomputeCounters(state);
clearPoisonedSessions();
@@ -1407,7 +1498,14 @@ export async function runSubPlanTest(planId, subscriptionId, granterAddr, state,
// Surface allowance details in log for operator debugging
const allowanceType = allowance['@type'] || allowance.type_url || null;
const spendLimit = allowance.spend_limit || allowance.allowance?.spend_limit || null;
- broadcast('log', { msg: ` ✓ Fee grant verified — type=${allowanceType || 'unknown'} limit=${JSON.stringify(spendLimit) || 'none'}` });
+ const typeShort = allowanceType ? allowanceType.split('.').pop().replace(/^\//, '') : 'unknown';
+ let limitLabel = 'unlimited';
+ if (Array.isArray(spendLimit) && spendLimit.length) {
+ limitLabel = spendLimit
+ .map(c => `${(parseInt(c.amount || '0', 10) / 1_000_000).toFixed(2)} ${(c.denom || '').replace(/^u/, '').toUpperCase() || 'DVPN'}`)
+ .join(', ');
+ }
+ broadcast('log', { msg: ` ✓ Fee grant verified — ${typeShort}, limit ${limitLabel}` });
} catch (err) {
// If the query itself throws (network error), abort rather than silently proceeding —
// we cannot know whether the grant exists, and every session TX would fail.
@@ -1488,9 +1586,17 @@ export async function runSubPlanTest(planId, subscriptionId, granterAddr, state,
}
broadcast('log', { msg: ` Scanning plan nodes for online status...` });
- const onlineNodes = await scanNodesParallel(planNodes, 20, broadcast, state);
+ let onlineNodes = await scanNodesParallel(planNodes, 20, broadcast, state);
broadcast('log', { msg: ` ${onlineNodes.length}/${planNodes.length} plan nodes are online` });
+ // Resume mode: filter already-tested addresses
+ if (resume) {
+ const testedAddrs = new Set(results.map(r => r.address));
+ const before = onlineNodes.length;
+ onlineNodes = onlineNodes.filter(({ node }) => !testedAddrs.has(node.address));
+ broadcast('log', { msg: `Resume: skipping ${before - onlineNodes.length} already-tested, ${onlineNodes.length} remaining.` });
+ }
+
if (onlineNodes.length === 0) {
state.status = 'done';
state.completedAt = new Date().toISOString();
@@ -1498,7 +1604,7 @@ export async function runSubPlanTest(planId, subscriptionId, granterAddr, state,
return;
}
- state.totalNodes = onlineNodes.length;
+ state.totalNodes = (resume ? results.length : 0) + onlineNodes.length;
broadcast('state', { state });
broadcast('log', { msg: `📡 Running baseline...` });
diff --git a/bin/cli.js b/bin/cli.js
index 22a1f7a..6ea4452 100644
--- a/bin/cli.js
+++ b/bin/cli.js
@@ -52,6 +52,7 @@ const COMMAND_GROUPS = {
Discovery: ['list', 'functions', 'verify-sdks'],
Read: ['nodes', 'node', 'balance', 'subscriptions', 'plans'],
Action: ['speed', 'test', 'audit', 'serve'],
+ Agent: ['agent'],
};
// Flat list for quick lookup
@@ -95,6 +96,10 @@ function printHelp() {
audit Run full audit loop across all nodes
serve Start the browser dashboard server
+ Agent
+ agent End-to-end driver hitting every server function over HTTP
+ (run "sentinel-audit agent map --pretty" for the registry)
+
Examples:
sentinel-audit list
sentinel-audit nodes --pretty
diff --git a/bin/commands/agent.js b/bin/commands/agent.js
new file mode 100644
index 0000000..a82c7fa
--- /dev/null
+++ b/bin/commands/agent.js
@@ -0,0 +1,305 @@
+/**
+ * agent — End-to-end CLI server driver for Sentinel Node Tester.
+ *
+ * Lets an AI / automation drive every operator-facing function over HTTP
+ * against a running `serve` instance. Every subcommand prints structured JSON
+ * to stdout and exits non-zero on error so it composes cleanly in pipelines.
+ *
+ * Discovery:
+ * sentinel-audit agent map # full machine-readable endpoint registry
+ * sentinel-audit agent --help # short-form usage
+ *
+ * Auth:
+ * --token prefer this
+ * $SENTINEL_AUDIT_TOKEN else this
+ * $ADMIN_TOKEN else this
+ * (no token) single-user/local mode is allowed by server
+ *
+ * Target:
+ * --base-url http://host:port prefer this
+ * $SENTINEL_AUDIT_URL else this
+ * --port else http://localhost:
+ * (default) http://localhost:3001
+ */
+
+import { api, apiRequest, resolveBaseUrl, resolveToken } from '../lib/http.js';
+import { printJson } from '../lib/output.js';
+
+export const name = 'agent';
+export const description = 'End-to-end agentic driver: hit every Sentinel Node Tester server function from the CLI.';
+export const usage = 'sentinel-audit agent [...args] [--base-url URL] [--token T] [--pretty]';
+export const flags = [
+ { flag: '--base-url ', description: 'Server URL (default http://localhost:3001 or $SENTINEL_AUDIT_URL)' },
+ { flag: '--port ', description: 'Shorthand to set localhost port (default 3001)' },
+ { flag: '--token ', description: 'Bearer admin token (default $SENTINEL_AUDIT_TOKEN or $ADMIN_TOKEN)' },
+ { flag: '--pretty', description: 'Pretty-print JSON output' },
+ { flag: '--timeout ', description: 'HTTP timeout in seconds (default 30)' },
+ { flag: '--watch ', description: 'For events/state subcommands: stream / poll for N seconds' },
+ // Pass-through bodies
+ { flag: '--plan-id ', description: 'Plan ID (start, test-plan, sub-plans)' },
+ { flag: '--sub-id ', description: 'Subscription ID (start, test-sub-plan)' },
+ { flag: '--sub-granter ', description: 'Subscription granter address (start)' },
+ { flag: '--test-run', description: 'Use TEST RUN mode for /api/start' },
+ { flag: '--pricing-mode ',description: 'gigabytes | hours (default gigabytes)' },
+ { flag: '--addr ', description: 'Node address (sentnode1...) for node, retest, etc.' },
+ { flag: '--country ', description: 'Country filter (audit, retest)' },
+ { flag: '--limit ', description: 'Result limit (errors, results)' },
+ { flag: '--num ', description: 'Run number (runs/save, runs/load)' },
+ { flag: '--sdk ', description: 'SDK selection (js | tkd)' },
+];
+
+// ─── Endpoint registry ───────────────────────────────────────────────────────
+//
+// Single source of truth. `agent map` prints this verbatim so an AI calling
+// the CLI can introspect every function the server exposes.
+
+const ENDPOINTS = [
+ // Discovery / health
+ { sub: 'health', method: 'GET', path: '/health', auth: false, desc: 'Public health probe' },
+ { sub: 'admin-health', method: 'GET', path: '/api/health', auth: true, desc: 'Admin-side health (chain + balance)' },
+ { sub: 'stats', method: 'GET', path: '/api/stats', auth: true, desc: 'Admin run/network stats' },
+ { sub: 'state', method: 'GET', path: '/api/state', auth: true, desc: 'Full server runtime state' },
+ { sub: 'sdk-versions', method: 'GET', path: '/api/sdk-versions', auth: true, desc: 'Installed SDK versions' },
+ { sub: 'sdk-verify', method: 'GET', path: '/api/sdk-verify', auth: true, desc: 'Byte-for-byte SDK verification' },
+ { sub: 'cross-sdk', method: 'GET', path: '/api/cross-sdk', auth: true, desc: 'Cross-SDK comparison data' },
+ { sub: 'failure-analysis',method: 'GET', path: '/api/failure-analysis', auth: true, desc: 'Failure breakdown' },
+ { sub: 'transport-cache', method: 'GET', path: '/api/transport-cache', auth: true, desc: 'Inspect transport cache (wg/v2ray)' },
+
+ // Public reads (no auth)
+ { sub: 'pub-nodes', method: 'GET', path: '/api/public/nodes', auth: false, desc: 'Public node list' },
+ { sub: 'pub-node', method: 'GET', path: '/api/public/node/:addr', auth: false, desc: 'Public single node detail', params: ['addr'] },
+ { sub: 'pub-node-errors', method: 'GET', path: '/api/public/node/:addr/errors', auth: false, desc: 'Public per-node error log', params: ['addr'] },
+ { sub: 'pub-bandwidth', method: 'GET', path: '/api/public/node/:addr/bandwidth', auth: false, desc: 'Public per-node bandwidth history', params: ['addr'] },
+ { sub: 'pub-errors', method: 'GET', path: '/api/public/errors', auth: false, desc: 'Recent failure log entries' },
+ { sub: 'pub-countries', method: 'GET', path: '/api/public/countries', auth: false, desc: 'Country breakdown' },
+ { sub: 'pub-stats', method: 'GET', path: '/api/public/stats', auth: false, desc: 'Public network stats' },
+ { sub: 'pub-run-current', method: 'GET', path: '/api/public/runs/current', auth: false, desc: 'In-flight run snapshot (when broadcast on)' },
+ { sub: 'pub-run-last', method: 'GET', path: '/api/public/runs/last', auth: false, desc: 'Last completed run snapshot' },
+ { sub: 'pub-runs', method: 'GET', path: '/api/public/runs', auth: false, desc: 'List historical runs' },
+ { sub: 'pub-batches', method: 'GET', path: '/api/public/batches', auth: false, desc: 'List broadcast batches' },
+ { sub: 'pub-batch', method: 'GET', path: '/api/public/batch/:id', auth: false, desc: 'Single batch detail', params: ['id'] },
+ { sub: 'pub-logs', method: 'GET', path: '/api/public/logs', auth: false, desc: 'Public broadcast log buffer' },
+ { sub: 'pub-live-state', method: 'GET', path: '/api/public/live-state', auth: false, desc: 'Cold-refresh hydration for /live' },
+ { sub: 'pub-test-status', method: 'GET', path: '/api/public/test/status', auth: false, desc: 'Public test status probe' },
+
+ // Broadcast toggle
+ { sub: 'broadcast', method: 'GET', path: '/api/broadcast', auth: false, desc: 'Read current broadcastLive value' },
+ { sub: 'broadcast-toggle',method: 'POST', path: '/api/broadcast', auth: true, desc: 'Flip broadcastLive (no body)' },
+
+ // Audit lifecycle
+ { sub: 'start', method: 'POST', path: '/api/start', auth: true, desc: 'Start audit run',
+ bodyFromFlags: (f) => {
+ const body = {};
+ if (f['--plan-id']) body.planId = f['--plan-id'];
+ if (f['--sub-id']) body.subscriptionId = f['--sub-id'];
+ if (f['--sub-granter'])body.subscriptionGranter = f['--sub-granter'];
+ if (f['--test-run']) body.testRun = true;
+ if (f['--pricing-mode']) body.pricingMode = f['--pricing-mode'];
+ return body;
+ } },
+ { sub: 'resume', method: 'POST', path: '/api/resume', auth: true, desc: 'Resume the last audit run' },
+ { sub: 'stop', method: 'POST', path: '/api/stop', auth: true, desc: 'Stop in-flight audit' },
+ { sub: 'rescan', method: 'POST', path: '/api/rescan', auth: true, desc: 'Rescan chain node list' },
+ { sub: 'retest-skips', method: 'POST', path: '/api/retest-skips', auth: true, desc: 'Retest only skipped nodes' },
+ { sub: 'retest-fails', method: 'POST', path: '/api/retest-fails', auth: true, desc: 'Retest only failed nodes' },
+ { sub: 'auto-retest', method: 'POST', path: '/api/auto-retest', auth: true, desc: 'Trigger background auto-retest sweep' },
+ { sub: 'clear', method: 'POST', path: '/api/clear', auth: true, desc: 'Clear current results buffer' },
+ { sub: 'economy', method: 'POST', path: '/api/economy', auth: true, desc: 'Toggle economy mode',
+ bodyFromFlags: () => ({}) },
+
+ // Plans & subscriptions
+ { sub: 'plans', method: 'GET', path: '/api/plans', auth: true, desc: 'List wallet plans' },
+ { sub: 'subscriptions', method: 'GET', path: '/api/subscriptions', auth: true, desc: 'List wallet subscriptions' },
+ { sub: 'sub-plans', method: 'GET', path: '/api/sub-plans', auth: true, desc: 'List subscription-plan pairings' },
+ { sub: 'admin-plans', method: 'GET', path: '/api/admin/plans', auth: true, desc: 'Admin plan inspector' },
+ { sub: 'test-plan', method: 'POST', path: '/api/test-plan', auth: true, desc: 'Run a plan-mode audit',
+ bodyFromFlags: (f) => ({ planId: f['--plan-id'] }) },
+ { sub: 'test-sub-plan', method: 'POST', path: '/api/test-sub-plan', auth: true, desc: 'Run a subscription-plan-mode audit',
+ bodyFromFlags: (f) => ({ subscriptionId: f['--sub-id'], planId: f['--plan-id'], subscriptionGranter: f['--sub-granter'] }) },
+
+ // Chain queries
+ { sub: 'chain-nodes', method: 'GET', path: '/api/chain/nodes', auth: true, desc: 'Direct chain node fetch' },
+ { sub: 'chain-status', method: 'GET', path: '/api/chain/node-status', auth: true, desc: 'Per-node status snapshot' },
+
+ // Run history
+ { sub: 'runs', method: 'GET', path: '/api/runs', auth: true, desc: 'List saved audit runs' },
+ { sub: 'run-get', method: 'GET', path: '/api/runs/:num', auth: true, desc: 'Read a specific saved run', params: ['num'] },
+ { sub: 'run-save', method: 'POST', path: '/api/runs/save', auth: true, desc: 'Save the current results buffer' },
+ { sub: 'run-load', method: 'POST', path: '/api/runs/load/:num', auth: true, desc: 'Load run #N back into the buffer', params: ['num'] },
+
+ // Results & errors
+ { sub: 'results', method: 'GET', path: '/api/results', auth: true, desc: 'Current results buffer' },
+
+ // SDK + DNS knobs
+ { sub: 'sdk-get', method: 'GET', path: '/api/sdk', auth: true, desc: 'Read active SDK selection' },
+ { sub: 'sdk-set', method: 'POST', path: '/api/sdk', auth: true, desc: 'Set active SDK',
+ bodyFromFlags: (f) => ({ sdk: f['--sdk'] }) },
+ { sub: 'dns-get', method: 'GET', path: '/api/dns', auth: true, desc: 'Read DNS configuration' },
+ { sub: 'dns-set', method: 'POST', path: '/api/dns', auth: true, desc: 'Update DNS configuration',
+ bodyFromFlags: (f) => {
+ const b = {};
+ if (f['--dns']) b.servers = String(f['--dns']).split(',').map(s => s.trim()).filter(Boolean);
+ if (f['--enabled'] !== undefined) b.enabled = f['--enabled'] === 'true' || f['--enabled'] === true;
+ return b;
+ } },
+
+ // Streaming
+ { sub: 'events', method: 'GET', path: '/api/events', auth: true, desc: 'Admin SSE event stream (use --watch )' },
+ { sub: 'pub-events', method: 'GET', path: '/api/public/events', auth: false, desc: 'Public SSE event stream (use --watch )' },
+];
+
+// ─── Resolution helpers ──────────────────────────────────────────────────────
+
+function findEndpoint(sub) {
+ return ENDPOINTS.find(e => e.sub === sub);
+}
+
+function fillPath(rawPath, params, positional) {
+ if (!params || !params.length) return rawPath;
+ let p = rawPath;
+ for (let i = 0; i < params.length; i++) {
+ const tok = positional[i];
+ if (!tok) {
+ throw new Error(`Missing positional argument for :${params[i]} in ${rawPath}`);
+ }
+ p = p.replace(`:${params[i]}`, encodeURIComponent(tok));
+ }
+ return p;
+}
+
+// ─── Top-level help / map output ─────────────────────────────────────────────
+
+function printAgentHelp() {
+ console.log(`
+ sentinel-audit agent — End-to-end CLI server driver
+
+ Usage:
+ sentinel-audit agent [args...] [flags]
+
+ Discovery:
+ agent map Machine-readable endpoint registry (JSON)
+ agent --help This text
+
+ Common subcommands:
+ health Public health probe
+ state Server runtime state (admin)
+ plans / subscriptions Wallet plans / subs
+ start [--test-run] [--pricing-mode hours|gigabytes]
+ stop / resume / rescan
+ retest-skips / retest-fails
+ broadcast / broadcast-toggle
+ pub-nodes / pub-node / pub-node-errors
+ runs / run-get / run-save / run-load
+ events --watch 30 Stream admin SSE for 30s
+ pub-events --watch 30 Stream public SSE for 30s
+
+ Run "agent map --pretty" for the full registry with HTTP method / path / auth.
+`);
+}
+
+// ─── SSE streaming ───────────────────────────────────────────────────────────
+
+async function streamSse(path, flags) {
+ const watchSecs = parseInt(flags['--watch'] || '30', 10);
+ const res = await apiRequest('GET', path, { flags, raw: true, timeoutMs: (watchSecs + 5) * 1000 });
+ if (!res.ok || !res.body) {
+ throw new Error(`SSE ${path} → HTTP ${res.status}`);
+ }
+
+ const reader = res.body.getReader();
+ const dec = new TextDecoder();
+ let buf = '';
+ const start = Date.now();
+ const events = [];
+
+ while (Date.now() - start < watchSecs * 1000) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ buf += dec.decode(value, { stream: true });
+
+ let idx;
+ while ((idx = buf.indexOf('\n\n')) !== -1) {
+ const chunk = buf.slice(0, idx);
+ buf = buf.slice(idx + 2);
+ let event = 'message';
+ let data = '';
+ for (const line of chunk.split('\n')) {
+ if (line.startsWith('event:')) event = line.slice(6).trim();
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
+ }
+ let parsed = data;
+ try { parsed = JSON.parse(data); } catch {}
+ const rec = { t: Date.now() - start, event, data: parsed };
+ events.push(rec);
+ // Live tap to stderr so the user sees progress; stdout is reserved for
+ // the final aggregate JSON.
+ console.error(JSON.stringify(rec));
+ }
+ }
+
+ try { await reader.cancel(); } catch {}
+ return { source: path, watchSeconds: watchSecs, events };
+}
+
+// ─── Runner ──────────────────────────────────────────────────────────────────
+
+export async function run({ positional, flags: f }) {
+ const sub = positional[0];
+
+ if (!sub || sub === 'help') {
+ printAgentHelp();
+ return;
+ }
+
+ // Discovery: print the full endpoint registry as JSON
+ if (sub === 'map' || sub === 'list') {
+ const base = resolveBaseUrl(f);
+ const tokenSource = f['--token'] ? '--token'
+ : process.env.SENTINEL_AUDIT_TOKEN ? '$SENTINEL_AUDIT_TOKEN'
+ : process.env.ADMIN_TOKEN ? '$ADMIN_TOKEN'
+ : 'none';
+ return {
+ baseUrl: base,
+ tokenSource,
+ tokenPresent: Boolean(resolveToken(f)),
+ total: ENDPOINTS.length,
+ endpoints: ENDPOINTS.map(e => ({
+ sub: e.sub,
+ method: e.method,
+ path: e.path,
+ auth: e.auth,
+ params: e.params || [],
+ description: e.desc,
+ })),
+ };
+ }
+
+ // Streaming subcommands
+ if (sub === 'events' || sub === 'pub-events') {
+ const path = sub === 'events' ? '/api/events' : '/api/public/events';
+ return await streamSse(path, f);
+ }
+
+ // Lookup
+ const ep = findEndpoint(sub);
+ if (!ep) {
+ throw new Error(`unknown agent subcommand: "${sub}". Run "agent map --pretty" for the registry.`);
+ }
+
+ const tail = positional.slice(1);
+ const path = fillPath(ep.path, ep.params, tail);
+
+ // Build query / body
+ const query = {};
+ if (f['--limit']) query.limit = f['--limit'];
+ if (f['--country']) query.country = f['--country'];
+
+ let body;
+ if (ep.method !== 'GET' && ep.method !== 'HEAD') {
+ body = ep.bodyFromFlags ? ep.bodyFromFlags(f) : {};
+ }
+
+ const timeoutMs = parseInt(f['--timeout'] || '30', 10) * 1000;
+
+ return await apiRequest(ep.method, path, { flags: f, body, query, timeoutMs });
+}
diff --git a/bin/lib/http.js b/bin/lib/http.js
new file mode 100644
index 0000000..a407d30
--- /dev/null
+++ b/bin/lib/http.js
@@ -0,0 +1,108 @@
+/**
+ * Sentinel Node Tester — HTTP client for the agent CLI
+ *
+ * Thin wrapper over fetch() that:
+ * - Resolves base URL from --base-url, SENTINEL_AUDIT_URL, or http://localhost:PORT
+ * - Adds Bearer auth from --token / SENTINEL_AUDIT_TOKEN / ADMIN_TOKEN
+ * - Sets X-Admin-Request: 1 on every non-GET (CSRF gate in core/auth.js)
+ * - Returns parsed JSON, never swallows errors
+ */
+
+export function resolveBaseUrl(flags = {}) {
+ const fromFlag = flags['--base-url'] || flags['--url'];
+ if (fromFlag) return String(fromFlag).replace(/\/+$/, '');
+ if (process.env.SENTINEL_AUDIT_URL) return process.env.SENTINEL_AUDIT_URL.replace(/\/+$/, '');
+ const port = flags['--port'] || process.env.PORT || '3001';
+ return `http://localhost:${port}`;
+}
+
+export function resolveToken(flags = {}) {
+ return (
+ flags['--token'] ||
+ process.env.SENTINEL_AUDIT_TOKEN ||
+ process.env.ADMIN_TOKEN ||
+ null
+ );
+}
+
+function buildHeaders(method, token, extra = {}) {
+ const h = { 'Accept': 'application/json', ...extra };
+ if (method && method !== 'GET' && method !== 'HEAD') {
+ h['Content-Type'] = 'application/json';
+ h['X-Admin-Request'] = '1';
+ }
+ if (token) h['Authorization'] = `Bearer ${token}`;
+ return h;
+}
+
+/**
+ * @param {string} method - GET/POST/DELETE etc
+ * @param {string} pathOrUrl - "/api/state" or full URL
+ * @param {object} opts
+ * - flags: parsed CLI flags (used for base url + token)
+ * - body: request body (will be JSON.stringify'd)
+ * - query: object of query string params
+ * - timeoutMs: abort after N ms (default 30s)
+ * - raw: if true, return Response untouched
+ */
+export async function apiRequest(method, pathOrUrl, opts = {}) {
+ const { flags = {}, body, query, timeoutMs = 30_000, raw = false } = opts;
+ const base = resolveBaseUrl(flags);
+ const token = resolveToken(flags);
+
+ let url = pathOrUrl.startsWith('http') ? pathOrUrl : `${base}${pathOrUrl}`;
+ if (query && typeof query === 'object') {
+ const usp = new URLSearchParams();
+ for (const [k, v] of Object.entries(query)) {
+ if (v === undefined || v === null) continue;
+ usp.append(k, String(v));
+ }
+ const qs = usp.toString();
+ if (qs) url += (url.includes('?') ? '&' : '?') + qs;
+ }
+
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
+
+ let res;
+ try {
+ res = await fetch(url, {
+ method,
+ headers: buildHeaders(method, token),
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ signal: ac.signal,
+ });
+ } catch (err) {
+ clearTimeout(timer);
+ throw new Error(`HTTP ${method} ${url} failed: ${err.message || err}`);
+ }
+ clearTimeout(timer);
+
+ if (raw) return res;
+
+ const ct = res.headers.get('content-type') || '';
+ let payload;
+ if (ct.includes('application/json')) {
+ payload = await res.json().catch(() => null);
+ } else {
+ payload = await res.text().catch(() => '');
+ }
+
+ if (!res.ok) {
+ const msg = (payload && typeof payload === 'object' && payload.error)
+ ? payload.error
+ : (typeof payload === 'string' ? payload.slice(0, 240) : `HTTP ${res.status}`);
+ const e = new Error(`${method} ${url} → ${res.status} ${msg}`);
+ e.status = res.status;
+ e.payload = payload;
+ throw e;
+ }
+
+ return payload;
+}
+
+export const api = {
+ get: (p, o = {}) => apiRequest('GET', p, o),
+ post: (p, body, o = {}) => apiRequest('POST', p, { ...o, body }),
+ del: (p, o = {}) => apiRequest('DELETE', p, o),
+};
diff --git a/core/chain.js b/core/chain.js
index a4ab502..29d2b1b 100644
--- a/core/chain.js
+++ b/core/chain.js
@@ -648,17 +648,20 @@ function isActiveStatus(status) {
}
export async function querySubscriptions(walletAddress) {
- // RPC-first: rpcQuerySubscriptionsForAccount returns raw Any-bytes per sub.
- // Decode each entry to extract plan_id and subscription_id via _decodePlanSubscription.
+ // RPC-first for direct subs (fast). RPC's QuerySubscriptionsForAccount does
+ // NOT return shared-plan allocations (e.g. plan 36 where wallet is allocatee
+ // not acc_address) — those only appear via LCD. So we always do an LCD pass
+ // afterwards and merge any IDs RPC missed.
+ const rpcOut = [];
try {
const rpcClient = await getRpcClient();
if (rpcClient) {
- const rawEntries = await rpcQuerySubscriptionsForAccount(rpcClient, walletAddress);
+ // Bug fix: SDK default limit is 100 — wallets with long sub history (older
+ // expired subs still iterated by chain) silently drop newer subscriptions.
+ const rawEntries = await rpcQuerySubscriptionsForAccount(rpcClient, walletAddress, { limit: 5000 });
if (rawEntries && rawEntries.length > 0) {
- const out = [];
for (const entry of rawEntries) {
try {
- // Each entry may be an Any-wrapped bytes blob or already-decoded object
const bytes = entry instanceof Uint8Array ? entry
: entry.value instanceof Uint8Array ? entry.value
: null;
@@ -666,24 +669,25 @@ export async function querySubscriptions(walletAddress) {
const anyDecoded = _decodeAny(bytes);
if (!anyDecoded.valueBytes) continue;
const sub = _decodePlanSubscription(anyDecoded.valueBytes);
- if (!sub.planId) continue; // not a plan subscription
- out.push({
+ if (!sub.planId) continue;
+ rpcOut.push({
id: sub.subscriptionId,
plan_id: sub.planId,
- status: 'STATUS_ACTIVE', // RPC only returns active subs when status=1
+ status: 'STATUS_ACTIVE',
expiry: null,
+ ownerAddress: walletAddress, // RPC only returns direct subs
+ viaAllocation: false,
+ grantedBytes: null,
});
- } catch { /* skip malformed entries */ }
+ } catch { /* skip malformed */ }
}
- if (out.length > 0) return out;
- // RPC returned entries but none decoded as plan subs — fall through to LCD
}
}
} catch (rpcErr) {
console.warn('[querySubscriptions] RPC failed, falling back to LCD:', rpcErr.message);
}
- // LCD fallback: direct query with ?status=1 so chain pre-filters at source.
+ // LCD pass: direct query with ?status=1 so chain pre-filters at source.
// Also filter client-side with isActiveStatus() to handle both chain v2
// ("active") and chain v3 ("STATUS_ACTIVE") response formats.
const lcd = await ensureLcd();
@@ -695,17 +699,54 @@ export async function querySubscriptions(walletAddress) {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
const raw = Array.isArray(data.subscriptions) ? data.subscriptions : [];
- return raw
- .filter(s => s.acc_address === walletAddress)
- .filter(s => isActiveStatus(s.status))
- .map(s => ({
- id: s.id,
+ // /accounts/{addr}/subscriptions returns BOTH direct subs and subs where
+ // addr is an allocatee on a shared plan. For non-direct subs, verify a
+ // non-zero allocation via v2 /subscriptions/{id}/allocations.
+ const active = raw.filter(s => isActiveStatus(s.status));
+ const haveIds = new Set(rpcOut.map(s => String(s.id)));
+ const out = [...rpcOut];
+ for (const s of active) {
+ const isOwner = s.acc_address === walletAddress;
+ const sid = String(s.id);
+ // Enrich expiry on RPC entries
+ if (haveIds.has(sid)) {
+ const existing = out.find(x => String(x.id) === sid);
+ if (existing && !existing.expiry) existing.expiry = s.inactive_at || null;
+ if (existing && s.acc_address) existing.ownerAddress = s.acc_address;
+ continue;
+ }
+ let viaAllocation = false;
+ let grantedBytes = null;
+ if (!isOwner) {
+ try {
+ const ar = await fetch(
+ `${lcd}/sentinel/subscription/v2/subscriptions/${sid}/allocations?pagination.limit=500`,
+ { signal: AbortSignal.timeout(10_000) },
+ );
+ if (ar.ok) {
+ const aj = await ar.json();
+ const mine = (aj.allocations || []).find(a => a.address === walletAddress);
+ if (mine && mine.granted_bytes && mine.granted_bytes !== '0') {
+ viaAllocation = true;
+ grantedBytes = mine.granted_bytes;
+ }
+ }
+ } catch { /* skip */ }
+ if (!viaAllocation) continue;
+ }
+ out.push({
+ id: sid,
plan_id: s.plan_id,
status: s.status,
expiry: s.inactive_at || null,
- }));
+ ownerAddress: s.acc_address,
+ viaAllocation,
+ grantedBytes,
+ });
+ }
+ return out;
} catch {
- return [];
+ return rpcOut;
}
}
@@ -848,6 +889,9 @@ export async function querySubscriberPlansEnriched(walletAddress) {
feeGrantActive,
feeGrantCheckFailed,
nodeCount,
+ viaAllocation: !!s.viaAllocation,
+ grantedBytes: s.grantedBytes || null,
+ subOwnerAddress: s.ownerAddress || null,
});
}
return results;
@@ -893,14 +937,60 @@ export async function queryFeeGrantRpcFirst(client, lcd, granter, grantee) {
request,
);
if (result?.value && result.value.length > 0) {
- // Successfully got a response — the allowance exists.
- // Decode the response: field 1 = Grant proto (embedded)
- const fields = decodeRpcProto(new Uint8Array(result.value));
- if (fields[1]?.[0]) {
- // Minimal: return a truthy object indicating active grant.
- // Full decode would require the Grant proto schema; enough to signal "exists".
- return { exists: true, _rpcSource: true };
+ // QueryAllowanceResponse { Grant allowance = 1 }
+ // Grant { string granter = 1; string grantee = 2; Any allowance = 3 }
+ // Any { string type_url = 1; bytes value = 2 }
+ const top = decodeRpcProto(new Uint8Array(result.value));
+ const grantBytes = top[1]?.[0]?.value;
+ if (!grantBytes) return null;
+ const grant = decodeRpcProto(grantBytes);
+ const anyBytes = grant[3]?.[0]?.value;
+ if (!anyBytes) return { exists: true, _rpcSource: true };
+ const anyFields = decodeRpcProto(anyBytes);
+ const typeUrl = anyFields[1]?.[0]?.value ? decodeRpcString(anyFields[1][0].value) : null;
+ const innerBytes = anyFields[2]?.[0]?.value;
+
+ // Walk through wrapping allowances (PeriodicAllowance/AllowedMsgAllowance) to
+ // reach the BasicAllowance that carries spend_limit.
+ let outerType = typeUrl;
+ let basicBytes = innerBytes;
+ for (let depth = 0; depth < 3 && basicBytes; depth++) {
+ if (outerType?.endsWith('BasicAllowance')) break;
+ const wrap = decodeRpcProto(basicBytes);
+ // PeriodicAllowance.basic = 1 (BasicAllowance) | AllowedMsgAllowance.allowance = 1 (Any)
+ const innerAnyBytes = wrap[1]?.[0]?.value;
+ if (!innerAnyBytes) break;
+ if (outerType?.endsWith('PeriodicAllowance')) {
+ outerType = '/cosmos.feegrant.v1beta1.BasicAllowance';
+ basicBytes = innerAnyBytes;
+ break;
+ }
+ // AllowedMsgAllowance: inner is Any
+ const innerAny = decodeRpcProto(innerAnyBytes);
+ outerType = innerAny[1]?.[0]?.value ? decodeRpcString(innerAny[1][0].value) : null;
+ basicBytes = innerAny[2]?.[0]?.value;
}
+
+ // BasicAllowance { repeated Coin spend_limit = 1; Timestamp expiration = 2 }
+ // Coin { string denom = 1; string amount = 2 }
+ const spend_limit = [];
+ if (outerType?.endsWith('BasicAllowance') && basicBytes) {
+ const basic = decodeRpcProto(basicBytes);
+ for (const coinEntry of (basic[1] || [])) {
+ const coin = decodeRpcProto(coinEntry.value);
+ spend_limit.push({
+ denom: coin[1]?.[0]?.value ? decodeRpcString(coin[1][0].value) : '',
+ amount: coin[2]?.[0]?.value ? decodeRpcString(coin[2][0].value) : '0',
+ });
+ }
+ }
+
+ return {
+ exists: true,
+ _rpcSource: true,
+ '@type': typeUrl,
+ spend_limit: spend_limit.length ? spend_limit : null,
+ };
}
// Empty response = no grant found
return null;
diff --git a/core/db.js b/core/db.js
index 29fa7ee..31bd872 100644
--- a/core/db.js
+++ b/core/db.js
@@ -27,7 +27,7 @@ const _handles = { real: null };
/**
* Returns the open Database instance, creating it on first call. Runs all
- * migrations automatically. Any scope param ('real', 'dry', or a path) is
+ * migrations automatically. Any scope param ('real', 'test', or a path) is
* accepted for back-compat but always returns the single audit.db handle.
*
* @param {string} [which] - Ignored (back-compat). Pass ':memory:' for tests.
@@ -271,6 +271,15 @@ function runMigrations(db) {
db.prepare('UPDATE schema_version SET version = 6').run();
})();
}
+
+ if (current < 7) {
+ db.transaction(() => {
+ // ── Migration v7: rename run mode 'dry' → 'test' ────────────────────────
+ // Idempotent: WHERE mode='dry' matches nothing if already migrated.
+ db.exec(`UPDATE runs SET mode='test' WHERE mode='dry'`);
+ db.prepare('UPDATE schema_version SET version = 7').run();
+ })();
+ }
}
// ─── Run Mutations ───────────────────────────────────────────────────────────
@@ -395,7 +404,7 @@ export function insertResult(run_id, result, which) {
*
* @param {number} run_id
* @param {object[]} results
- * @param {'real'|'dry'} [which='real']
+ * @param {'real'|'test'} [which='real']
*/
export function insertResultsBatch(run_id, results, which) {
const db = getDb(which);
@@ -572,7 +581,7 @@ export function getNetworkStats(which) {
const db = getDb(which);
// Latest result per node (MAX(id) tiebreaker for equal tested_at).
- // No mode filter needed: real and dry data live in separate DB files.
+ // No mode filter needed: real and test data live in separate DB files.
const rows = db.prepare(`
SELECT r.actual_mbps, r.session_ok
FROM results r
diff --git a/core/session.js b/core/session.js
index ea1bce8..616c16c 100644
--- a/core/session.js
+++ b/core/session.js
@@ -333,6 +333,9 @@ export function extractSessionMap(txResult, nodeAddrs) {
* Returns Map for all NEW sessions.
*/
export async function submitBatchPayment(client, account, denom, gigabytes, batch, state, broadcast) {
+ const pricingMode = state?.pricingMode === 'hours' ? 'hours' : 'gigabytes';
+ const sessionGigabytes = pricingMode === 'hours' ? 0 : gigabytes;
+ const sessionHours = pricingMode === 'hours' ? 1 : 0;
const result = new Map();
const reusedAddrs = new Set();
const toPayBatch = [];
@@ -341,15 +344,20 @@ export async function submitBatchPayment(client, account, denom, gigabytes, batc
if (broadcast) broadcast('log', { msg: ` ⏭ Skip ${node.address.slice(0, 20)}… — already paid this run` });
continue;
}
- const priceEntry = (node.gigabyte_prices || []).find(p => p.denom === denom);
- if (priceEntry) toPayBatch.push({ node, priceEntry });
+ const priceList = pricingMode === 'hours' ? (node.hourly_prices || []) : (node.gigabyte_prices || []);
+ const priceEntry = priceList.find(p => p.denom === denom);
+ if (priceEntry) {
+ toPayBatch.push({ node, priceEntry });
+ } else if (broadcast && pricingMode === 'hours') {
+ broadcast('log', { msg: ` ⏭ Skip ${node.address.slice(0, 20)}… — no hourly price` });
+ }
}
if (toPayBatch.length > 0) {
const messages = toPayBatch.map(({ node, priceEntry }) => ({
typeUrl: V3_MSG_TYPE,
value: {
from: account.address, node_address: node.address,
- gigabytes, hours: 0,
+ gigabytes: sessionGigabytes, hours: sessionHours,
max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value },
},
}));
@@ -369,7 +377,7 @@ export async function submitBatchPayment(client, account, denom, gigabytes, batc
typeUrl: V3_MSG_TYPE,
value: {
from: account.address, node_address: node.address,
- gigabytes, hours: 0,
+ gigabytes: sessionGigabytes, hours: sessionHours,
},
}));
txResult = await signAndBroadcastRetry(client, account.address, messagesNoMax, fee, broadcast);
@@ -410,8 +418,10 @@ export async function submitBatchPayment(client, account, denom, gigabytes, batc
}
if (broadcast) broadcast('log', { msg: ` Batch tx (${n} msgs): ${txResult.transactionHash.slice(0, 16)}…` });
toPayBatch.forEach(({ node }) => {
- const priceEntry = (node.gigabyte_prices || []).find(p => p.denom === denom);
- if (priceEntry) state.spentUdvpn += Math.round(parseFloat(priceEntry.quote_value) || 0) * gigabytes;
+ const list = pricingMode === 'hours' ? (node.hourly_prices || []) : (node.gigabyte_prices || []);
+ const priceEntry = list.find(p => p.denom === denom);
+ const units = pricingMode === 'hours' ? sessionHours : gigabytes;
+ if (priceEntry) state.spentUdvpn += Math.round(parseFloat(priceEntry.quote_value) || 0) * units;
});
state.spentUdvpn += 200000 * n;
state.balance = `${(Math.max(0, state.balanceUdvpn - state.spentUdvpn) / 1_000_000).toFixed(4)} P2P (est. remaining)`;
diff --git a/docs/ARCHITECTURE-PUBLIC-LIVE.md b/docs/ARCHITECTURE-PUBLIC-LIVE.md
index bca4a66..41686f4 100644
--- a/docs/ARCHITECTURE-PUBLIC-LIVE.md
+++ b/docs/ARCHITECTURE-PUBLIC-LIVE.md
@@ -54,17 +54,17 @@ admin.html ──POST /api/broadcast──► state.broadcastLive = true/false
One database: `audit.db`. All runs — live and dry — write to it.
- Normal audit run: `mode='live'` (or absent/null for legacy rows).
-- Dry-run (`?dryRun=1` on `/api/start`): `mode='dry'`. Every node row gets `actualMbps: null, errorCode: 'TEST_RUN_SKIP'`. Visually distinguishable in the admin table but stored in the same file.
+- Test-run (`?testRun=1` on `/api/start`): `mode='test'`. Every node row gets `actualMbps: null, errorCode: 'TEST_RUN_SKIP'`. Visually distinguishable in the admin table but stored in the same file.
- `audit-dry.db` no longer exists.
-## TEST RUN (Dry Run)
+## TEST RUN (Test Run)
TEST RUN is not a mode — it is an optional parameter on the normal start endpoint.
-- `POST /api/start` with `dryRun: true` in body, or `POST /api/start?dryRun=1`.
+- `POST /api/start` with `testRun: true` in body, or `POST /api/start?testRun=1`.
- Pipeline skips: plan membership check, online scan, chain operations, payments.
- Every node row: `actualMbps: null, errorCode: 'TEST_RUN_SKIP'`.
-- Run row: `mode='dry'` in `audit.db`.
+- Run row: `mode='test'` in `audit.db`.
- Useful for verifying the pipeline plumbing and UI without spending DVPN.
## SSE Event Contract
@@ -97,5 +97,5 @@ No wallet, plan ID, or fee-grant internals are forwarded on the public SSE strea
- Mode overlay UI in `admin.html`.
- `POST /api/admin/public-test/start`, `POST /api/admin/public-test/stop`, `GET /api/admin/public-test/status`.
- `POST /api/public/test/start`, `POST /api/public/test/stop`, `GET /api/public/test/status`.
-- `audit-dry.db` — superseded by the `mode='dry'` column in `audit.db`.
+- `audit-dry.db` — superseded by the `mode='test'` column in `audit.db`.
- Economy mode (deprecated earlier in the same session).
diff --git a/index.html b/index.html
index 03ae20d..d4f70cd 100644
--- a/index.html
+++ b/index.html
@@ -576,7 +576,7 @@ SENTINEL AUDIT
-
+
Node Error Details
+ × +${escapeHtml(er.log_snippet)}
+ SENTINEL AUDIT
- +SENTINEL AUDIT
const label = paused ? 'Paused' : 'Running...'; btnStart.textContent = label; btnStart.classList.add('loading'); - btnStop.textContent = 'Stop Audit'; + btnStop.textContent = 'Stop Test'; btnStop.classList.remove('loading'); } else { btnStart.textContent = 'New Test'; btnStart.classList.remove('loading'); if (btnResume) { btnResume.textContent = 'Resume'; btnResume.classList.remove('loading'); btnResume.disabled = false; } - btnStop.textContent = 'Stop Audit'; + btnStop.textContent = 'Stop Test'; btnStop.classList.remove('loading'); } @@ -1503,7 +1503,7 @@
- Stop Audit
+ Stop Test
Gracefully stops the current scan after the active node finishes. Results are saved. You can Resume later.
@@ -1571,7 +1571,7 @@ ×
- Only tests nodes in the selected plan. Every session TX is fee-granted by the plan owner — this wallet pays zero gas.
+ Only tests nodes in the selected plan. Session TXs are fee-granted by the plan owner — but only if that plan owner has an active feegrant for this wallet. Plans without a feegrant are flagged below and disabled (this wallet would otherwise pay gas itself).
Loading your subscriptions…
diff --git a/live.html b/live.html
index 5e818a6..bbb04f2 100644
--- a/live.html
+++ b/live.html
@@ -74,6 +74,41 @@
margin: 0 0 18px 0;
}
.live-page-header-left { display: flex; align-items: center; gap: 14px; flex: 1 1 auto; min-width: 0; }
+ .brand-logo { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
+ /* TEST RUN pill — sits next to the page title when a test-run audit is active */
+ .test-run-badge {
+ display: inline-block;
+ vertical-align: middle;
+ margin-left: 10px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: var(--accent);
+ color: var(--bg-card-solid);
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 1.5px;
+ font-family: var(--font-display);
+ }
+ /* TEST RUN banner — full-width, static accent strip. Shown when a test-run
+ audit is active so the public can't mistake skipped rows for real
+ measurements. No animation — keeps the page calm. */
+ .test-run-banner {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 12px 16px;
+ margin-bottom: 22px;
+ background: var(--accent);
+ color: var(--bg-card-solid);
+ font-family: var(--font-display);
+ font-weight: 800;
+ font-size: 13px;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ border-bottom: 2px solid var(--accent);
+ }
+ .test-run-banner.is-visible { display: flex; }
.live-page-header-right {
display: flex; align-items: center; gap: 0;
flex: 0 0 auto;
@@ -271,14 +306,15 @@
th {
text-align: left;
- padding: 0 6px 6px 6px;
+ padding: 8px 6px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
position: sticky; top: 0;
- background: var(--glass-bg);
+ /* Opaque so scrolling rows don't bleed through the header bar */
+ background: var(--bg-card-solid);
z-index: 10;
}
@@ -563,18 +599,33 @@ View on Desktop
+
+
+ TEST RUN — SIMULATED AUDIT (NO REAL MEASUREMENTS)
+
+
- dVPN Node Test
- Live public testing — Sentinel dVPN network
+
+ Sentinel dVPN Node Test
+
+
+
+ Live public testing — Sentinel dVPN network
- Tester Baseline
+ Server Baseline
—
@@ -641,14 +692,12 @@ dVPN Node Test
Current Batch
-
0 / 0 nodes tested
-
Batch size: —
@@ -732,7 +781,8 @@ dVPN Node Test
const notice = document.getElementById('broadcastOffNotice');
if (_broadcastLive) {
if (notice) notice.style.display = 'none';
- showPaused(false);
+ // Reconcile against current admin status; idle = still paused.
+ applyPauseFromState();
if (!wasLive) {
connectSSE();
seedLogsFromRest();
@@ -769,20 +819,41 @@ dVPN Node Test
const urlEl = document.getElementById('mobileGateUrl');
if (urlEl) urlEl.textContent = location.host + '/live';
applyThemeIcons();
+ // Paint cached snapshot first so a refresh shows data instantly,
+ // then resolve broadcast state and reconcile with server.
+ rehydrateFromCache();
renderTable();
renderLiveStats();
// Resolve broadcast state BEFORE loading the current batch — otherwise
// loadCurrentBatch races and may un-pause the overlay using the default
// (broadcastLive=false) before we know the real state.
await checkBroadcastState();
+ // Seed full live state + results via REST so refresh paints the same
+ // dashboard the operator sees, even before the SSE init message lands.
+ if (_broadcastLive) await seedLiveStateFromRest();
loadCurrentBatch();
loadHeaderStats();
+ applyPauseFromState();
setInterval(checkBroadcastState, 30000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') checkBroadcastState();
});
});
+ // Normalize a node's serviceType into the lowercase string the row
+ // renderer expects ('wireguard' | 'v2ray' | null). The sanitizer can hand
+ // us either a number (1=wireguard, 2=v2ray) or a string — both must
+ // produce a transport badge.
+ function normalizeServiceType(v) {
+ if (v == null) return null;
+ if (v === 1 || v === '1') return 'wireguard';
+ if (v === 2 || v === '2') return 'v2ray';
+ const s = String(v).toLowerCase();
+ if (s.includes('wire')) return 'wireguard';
+ if (s.includes('v2')) return 'v2ray';
+ return null;
+ }
+
// ─── Header network stats (baseline + chain-wide) ───
let _headerBaseline = null;
function setHeaderBaseline(mbps) {
@@ -795,6 +866,8 @@ dVPN Node Test
el.style.color = n >= 30 ? '' : 'var(--red)';
}
async function loadHeaderStats() {
+ // Paint from REST first so the header tiles have values on a cold load
+ // even before SSE init arrives.
try {
const r = await fetch('/api/public/test/status', { cache: 'no-store' });
if (r.ok) {
@@ -812,6 +885,49 @@ dVPN Node Test
if (j.passingPct != null) set('hdrNetworkPass', Number(j.passingPct).toFixed(0) + '%');
}
} catch {}
+ // Then layer live-batch values on top once _liveState / resultsArr exist.
+ applyHeaderStatsFromState();
+ }
+
+ // REST fallback so a refresh paints stats + table BEFORE SSE init lands.
+ async function seedLiveStateFromRest() {
+ try {
+ const r = await fetch('/api/public/live-state', { cache: 'no-store' });
+ if (!r.ok) return;
+ const j = await r.json();
+ if (!j || !j.broadcastLive) return;
+ if (j.state) mergeLiveState(j.state);
+ if (Array.isArray(j.results)) {
+ for (const r2 of j.results) {
+ if (!r2 || !r2.address) continue;
+ upsert({
+ address: r2.address,
+ moniker: r2.moniker || '',
+ country: r2.country || '',
+ countryCode: r2.countryCode || '',
+ city: r2.city || '',
+ type: r2.serviceType || null,
+ actualMbps: r2.actualMbps != null ? Number(r2.actualMbps) : null,
+ baselineAtTest: r2.baselineAtTest != null ? Number(r2.baselineAtTest) : null,
+ peers: r2.peers != null ? Number(r2.peers) : null,
+ maxPeers: r2.maxPeers != null ? Number(r2.maxPeers) : null,
+ error: r2.error || null,
+ errorCode: r2.errorCode || null,
+ skipped: !!r2.skipped,
+ inPlan: r2.inPlan === true,
+ testRun: r2.errorCode === 'TEST_RUN_SKIP',
+ testedAt: r2.testedAt || null,
+ });
+ }
+ // Sync the current-batch counter to the seeded rows so the
+ // Tested tile shows real progress on a cold refresh.
+ _cb.tested = resultsArr.length;
+ _cb.passed = resultsArr.filter(x => Number(x.actualMbps) >= 10).length;
+ _cb.failed = _cb.tested - _cb.passed;
+ }
+ applyHeaderStatsFromState();
+ renderLiveStats();
+ } catch {}
}
function toggleTheme() {
@@ -962,8 +1078,12 @@ dVPN Node Test
const countryName = escHtml(r.country || (cc ? cc : '—'));
const countryCol = flag ? `${flag} ${countryName}` : countryName;
const cityCol = escHtml(r.city || '—');
- const act = r.actualMbps != null ? r.actualMbps.toFixed(2) + ' Mbps' : '--';
- const blAt = r.baselineAtTest != null ? r.baselineAtTest.toFixed(2) + ' Mbps' : '--';
+ // TEST RUN rows never connect to the node — surface the Speed and per-row
+ // Baseline columns as "—" so the public can't misread the operator's WAN
+ // baseline as a node-side measurement.
+ const isTestRun = r.errorCode === 'TEST_RUN_SKIP';
+ const act = (!isTestRun && r.actualMbps != null) ? r.actualMbps.toFixed(2) + ' Mbps' : '--';
+ const blAt = (!isTestRun && r.baselineAtTest != null) ? r.baselineAtTest.toFixed(2) + ' Mbps' : '--';
let peersHtml = '--';
const peerCount = r.peers != null ? r.peers : null;
@@ -990,7 +1110,8 @@ dVPN Node Test
const isFail = r.actualMbps == null && !r.skipped;
const copyCell = isFail && addrFull
- ? ``
+ ? `
+ `
: '—';
tr.innerHTML = `
@@ -1102,6 +1223,7 @@ dVPN Node Test
if (o.cat === 'fail' && o.addr) {
parts.push(``);
+ parts.push(``);
}
div.innerHTML = parts.join('');
@@ -1208,7 +1330,13 @@ dVPN Node Test
function cbRender() {
const snap = _cb.snapshotSize;
- const tested = _cb.tested;
+ // Single source of truth: the deduped resultsArr (rows upserted by
+ // address) — same approach admin.html uses for its Tested tile. Server
+ // `state.testedNodes` can double-count retries / counter-increment
+ // races, which produced public 227 vs admin 150 desync. Fall back to
+ // _cb.tested only when resultsArr hasn't arrived (early init).
+ const arr = Array.isArray(resultsArr) ? resultsArr : [];
+ const tested = arr.length > 0 ? arr.length : _cb.tested;
const total = snap || tested;
const pct = total > 0 ? Math.min(100, Math.round(tested / total * 100)) : 0;
@@ -1218,89 +1346,129 @@ dVPN Node Test
const countEl = _cbEl('cbCount');
if (countEl) countEl.textContent = tested + ' / ' + (total || '—') + ' nodes tested';
+ // Pass/Fail derived from resultsArr (matches admin's recompute logic).
+ const passed = arr.filter(r => Number(r.actualMbps) >= 10).length;
+ const failed = arr.filter(r => !r.skipped && r.errorCode !== 'TEST_RUN_SKIP' && (r.error || r.errorCode || r.actualMbps == null)).length;
const passEl = _cbEl('cbPassed');
- if (passEl) passEl.textContent = _cb.passed;
+ if (passEl) passEl.textContent = passed;
const failEl = _cbEl('cbFailed');
- if (failEl) failEl.textContent = _cb.failed;
+ if (failEl) failEl.textContent = failed;
const snapEl = _cbEl('cbSnapshot');
if (snapEl) snapEl.textContent = snap || '—';
- const elapsed = _cb.startedAt ? Math.round((Date.now() - _cb.startedAt) / 1000) : 0;
- const metaEl = _cbEl('cbMeta');
- if (metaEl) {
- const bNum = _cb.iteration != null ? 'Batch #' + _cb.iteration : '';
- const elStr = elapsed > 0 ? elapsed + 's ago' : 'just started';
- metaEl.textContent = bNum ? bNum + ' · Started ' + elStr : (_cb.startedAt ? 'Started ' + elStr : 'waiting for admin to start testing…');
- }
-
- const etaEl = _cbEl('cbEta');
- if (etaEl && tested > 0 && elapsed > 0 && snap > tested) {
- const rem = snap - tested;
- const rate = tested / elapsed;
- const etaSec = Math.round(rem / rate);
- const m = Math.floor(etaSec / 60), s = etaSec % 60;
- etaEl.textContent = 'ETA ~' + (m > 0 ? m + 'm ' : '') + s + 's';
- } else if (etaEl) {
- etaEl.textContent = '';
- }
-
renderLiveStats();
}
function renderLiveStats() {
const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
- const snap = _cb.snapshotSize || 0;
const arr = Array.isArray(resultsArr) ? resultsArr : [];
+ // Snapshot total for THIS run. Prefer the current-batch snapshot from
+ // `batch:start` and `state.totalNodes` (admin's per-run snapshot). Both
+ // reflect the size of the active sweep, not the chain-wide node count.
+ const snap = _cb.snapshotSize ||
+ (_liveState && Number(_liveState.totalNodes)) ||
+ arr.length || 0;
// "Connected" = node returned mbps>0 (handshake + speed both succeeded).
const connected = arr.filter(r => r.actualMbps != null && r.actualMbps > 0).length;
- // "Failed" = explicit error OR mbps==null (excluding TEST RUN skips).
- const failed = arr.filter(r => !r.skipped && (r.error || r.errorCode || r.actualMbps == null)).length;
+ // "Failed" = explicit error OR mbps==null. Exclude TEST RUN skips and
+ // explicit skips — those are not failures, they're not-tested.
+ const failed = arr.filter(r => !r.skipped && r.errorCode !== 'TEST_RUN_SKIP' && (r.error || r.errorCode || r.actualMbps == null)).length;
// Pass 10 Mbps SLA: real connections at or above 10.
const p10 = arr.filter(r => r.actualMbps != null && Number(r.actualMbps) >= 10).length;
- // Tested = anything we have a row for (matches admin's processed count).
- const processed = arr.filter(r => !r.skipped).length;
+ // Tested counter — match admin.html exactly: deduped resultsArr.length
+ // is the single source of truth. State.testedNodes can double-count
+ // (counter-increment races during retries) which caused public to read
+ // 227 while admin read 150. Mid-run joiners get the full backlog via
+ // the `init` SSE event + REST fallback, so arr.length is always the
+ // same row count admin sees.
+ const processed = arr.length;
+ // Real processed (excluding TEST_RUN_SKIP / explicit skips) for failure
+ // / SLA / pass-rate denominators below.
+ const realProcessed = arr.filter(r => !r.skipped && r.errorCode !== 'TEST_RUN_SKIP').length;
// Not Online: failed AND no peers (genuinely offline).
const notOnline = arr.filter(r => r.actualMbps == null && (r.peers === 0 || r.peers == null)).length;
// Dead Plan Nodes: plan-flagged nodes that failed.
const planRows = arr.filter(r => r.inPlan === true);
const deadPlan = planRows.filter(r => r.actualMbps == null).length;
- // Tested
- set('lsTested', processed);
- if (snap > 0 && snap > processed) {
- set('lsTestedSub', `of ${snap.toLocaleString()} | ${(snap - processed).toLocaleString()} remaining`);
- } else if (snap > 0) {
- set('lsTestedSub', `of ${snap.toLocaleString()} | complete`);
+ // Detect TEST RUN: state.testRun flag (immediate), any row marked
+ // TEST_RUN_SKIP, OR no real-processed rows yet but at least one skipped.
+ const isDryRun = !!(_liveState && _liveState.testRun) ||
+ arr.some(r => r.errorCode === 'TEST_RUN_SKIP') ||
+ (arr.length > 0 && realProcessed === 0 &&
+ arr.some(r => r.skipped));
+ const testRunBadge = document.getElementById('testRunBadge');
+ if (testRunBadge) testRunBadge.style.display = isDryRun ? '' : 'none';
+ const testRunBanner = document.getElementById('testRunBanner');
+ if (testRunBanner) testRunBanner.classList.toggle('is-visible', isDryRun);
+
+ // Tested — always shows real progress, even during a TEST RUN.
+ if (snap > 0) {
+ set('lsTested', `${processed.toLocaleString()} / ${snap.toLocaleString()} Nodes`);
+ set('lsTestedSub', snap > processed
+ ? `${(snap - processed).toLocaleString()} remaining`
+ : 'complete');
} else {
- set('lsTestedSub', processed > 0 ? `${processed} tested` : 'no data yet');
+ set('lsTested', processed.toLocaleString());
+ set('lsTestedSub', processed > 0 ? `${processed.toLocaleString()} tested` : 'no data yet');
}
+ // During a TEST RUN, the speed/SLA/failure metrics are intentionally
+ // not measured — surface that with a dash so the public sees that
+ // these are not "0" results, they are simply not produced.
+ const DASH = '—';
+
// Total Failed
- set('lsTotalFailed', failed);
- const failPct = processed > 0 ? (failed / processed * 100).toFixed(1) : '0';
- set('lsTotalFailedPct', failPct + '% failure rate');
+ if (isDryRun) {
+ set('lsTotalFailed', DASH);
+ set('lsTotalFailedPct', 'not tested');
+ } else {
+ set('lsTotalFailed', failed);
+ const failPct = realProcessed > 0 ? (failed / realProcessed * 100).toFixed(1) : '0';
+ set('lsTotalFailedPct', failPct + '% failure rate');
+ }
// Pass 10 Mbps SLA
- set('lsPassedSLA', p10);
- const slaPct = connected > 0 ? (p10 / connected * 100).toFixed(1) : '0';
- set('lsPassedSLAPct', slaPct + '% of connected');
+ if (isDryRun) {
+ set('lsPassedSLA', DASH);
+ set('lsPassedSLAPct', 'not tested');
+ } else {
+ set('lsPassedSLA', p10);
+ const slaPct = connected > 0 ? (p10 / connected * 100).toFixed(1) : '0';
+ set('lsPassedSLAPct', slaPct + '% of connected');
+ }
// Dead Plan Nodes
- set('lsDeadPlan', deadPlan);
- set('lsDeadPlanPct', planRows.length > 0
- ? `${deadPlan}/${planRows.length} plan nodes failed`
- : 'No plan failures');
-
- // Not Online
- set('lsNotOnline', notOnline);
- set('lsNotOnlineSub', notOnline === 1 ? '1 node offline' : `${notOnline} nodes offline`);
+ if (isDryRun) {
+ set('lsDeadPlan', DASH);
+ set('lsDeadPlanPct', 'not tested');
+ } else {
+ set('lsDeadPlan', deadPlan);
+ set('lsDeadPlanPct', planRows.length > 0
+ ? `${deadPlan}/${planRows.length} plan nodes failed`
+ : 'No plan failures');
+ }
- // Pass Rate (connected / processed) — matches admin's stat
- const connRate = processed > 0 ? (connected / processed * 100).toFixed(1) : '0';
- set('lsPassRate', connRate + '%');
+ // Not Online — match admin.html exactly: any row with no measured mbps
+ // and no peers counts as offline. TEST_RUN_SKIP rows are included for
+ // parity with admin (user-confirmed behavior 2026-04-25).
+ const trulyOffline = arr.filter(r =>
+ r.actualMbps == null &&
+ (r.peers === 0 || r.peers == null)
+ ).length;
+ set('lsNotOnline', trulyOffline);
+ set('lsNotOnlineSub', trulyOffline === 1 ? '1 node offline' : `${trulyOffline} nodes offline`);
+
+ // Pass Rate
+ if (isDryRun) {
+ set('lsPassRate', DASH);
+ } else {
+ const connRate = realProcessed > 0 ? (connected / realProcessed * 100).toFixed(1) : '0';
+ set('lsPassRate', connRate + '%');
+ }
const dot = document.getElementById('headerDot');
if (dot) dot.className = (_cb.startedAt && !_cb.gapEndsAt) ? 'pulse-dot running' : 'pulse-dot';
@@ -1308,7 +1476,8 @@ dVPN Node Test
setInterval(() => {
if (_cb.startedAt && _cb.tested >= 0) cbRender();
- else renderLiveStats();
+ renderLiveStats();
+ applyHeaderStatsFromState();
}, 5000);
// ─── Upsert ───
@@ -1317,6 +1486,161 @@ dVPN Node Test
if (idx >= 0) resultsArr[idx] = { ...resultsArr[idx], ...r };
else resultsArr.push(r);
addSingleRow(r);
+ schedulePersist();
+ }
+
+ // ─── Live snapshot persistence ───
+ // Mirror admin's view across page refresh: cache the current resultsArr +
+ // last-known state so a refresh paints immediately, before SSE init lands.
+ const LIVE_CACHE_KEY = 'live:snapshot:v1';
+ const LIVE_CACHE_TTL_MS = 60 * 60 * 1000; // 1h — enough to survive a reload
+ let _liveState = {};
+ let _persistTimer = null;
+ function schedulePersist() {
+ if (_persistTimer) return;
+ _persistTimer = setTimeout(() => {
+ _persistTimer = null;
+ try {
+ const snap = {
+ ts: Date.now(),
+ broadcastLive: _broadcastLive,
+ state: _liveState || {},
+ // Stamp the run number so rehydrate can detect a stale cache
+ // from a previous test and discard it instead of repainting
+ // last run's rows on top of a fresh run.
+ activeRunNumber: _liveState && _liveState.activeRunNumber != null ? _liveState.activeRunNumber : null,
+ results: resultsArr,
+ cb: { ..._cb },
+ };
+ localStorage.setItem(LIVE_CACHE_KEY, JSON.stringify(snap));
+ } catch {}
+ }, 250);
+ }
+ function rehydrateFromCache() {
+ try {
+ const raw = localStorage.getItem(LIVE_CACHE_KEY);
+ if (!raw) return;
+ const snap = JSON.parse(raw);
+ if (!snap || !snap.ts || (Date.now() - snap.ts) > LIVE_CACHE_TTL_MS) {
+ try { localStorage.removeItem(LIVE_CACHE_KEY); } catch {}
+ return;
+ }
+ // Park the cached run number; we'll compare against the live SSE
+ // state and discard rows below when they're from a previous run.
+ if (snap.activeRunNumber != null) _prevActiveRunNumber = snap.activeRunNumber;
+ if (snap.state && typeof snap.state === 'object') _liveState = snap.state;
+ if (Array.isArray(snap.results) && snap.results.length && resultsArr.length === 0) {
+ for (const r of snap.results) {
+ resultsArr.push(r);
+ addSingleRow(r);
+ }
+ }
+ if (snap.cb && typeof snap.cb === 'object') {
+ // Restore counters so the bar paints immediately
+ Object.assign(_cb, snap.cb);
+ cbRender();
+ }
+ applyHeaderStatsFromState();
+ } catch {}
+ }
+
+ // Merge a state snapshot from admin into our local mirror, then refresh
+ // the header derived from current-batch values.
+ function mergeLiveState(s) {
+ if (!s || typeof s !== 'object') return;
+ _liveState = { ..._liveState, ...s };
+ applyHeaderStatsFromState();
+ applyPauseFromState();
+ schedulePersist();
+ }
+
+ // Show the "Testing Has Been Paused" overlay whenever there is no active
+ // run on admin — i.e. broadcastLive is off OR state.status is not running
+ // and there is no in-flight batch. Public visitors should see paused, not
+ // a frozen stale dashboard.
+ function applyPauseFromState() {
+ const status = _liveState && _liveState.status;
+ const running = status === 'running' || status === 'paused';
+ const midBatch = !!(_cb && _cb.batchId);
+ if (!_broadcastLive) { showPaused(true); return; }
+ showPaused(!running && !midBatch);
+ }
+
+ // Derive header stats from the live state + resultsArr (current batch),
+ // never from chain-wide aggregates. Falls back to "—" when unknown.
+ function applyHeaderStatsFromState() {
+ const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
+ // Server Baseline — from state.baselineMbps
+ if (_liveState && _liveState.baselineMbps != null) {
+ setHeaderBaseline(_liveState.baselineMbps);
+ }
+ // Network Nodes — total snapshot size for current batch (state.totalNodes
+ // tracks scanned/online; fall back to _cb.snapshotSize, then resultsArr.)
+ const totalNodes =
+ (_liveState && _liveState.totalNodes) ||
+ _cb.snapshotSize ||
+ resultsArr.length;
+ // Only overwrite if we actually have a live value; otherwise leave
+ // whatever loadHeaderStats() painted from /api/public/stats.
+ if (totalNodes) set('hdrNetworkNodes', Number(totalNodes).toLocaleString());
+ // Network Median — derive from passed nodes' actualMbps in current batch
+ const speeds = resultsArr
+ .map(r => Number(r.actualMbps))
+ .filter(n => Number.isFinite(n) && n > 0)
+ .sort((a, b) => a - b);
+ if (speeds.length) {
+ const mid = Math.floor(speeds.length / 2);
+ const median = speeds.length % 2 ? speeds[mid] : (speeds[mid - 1] + speeds[mid]) / 2;
+ set('hdrNetworkMedian', median.toFixed(1) + ' Mbps');
+ }
+ // else: leave the REST-painted value in place.
+ // Network Pass Rate — passed / processed in current batch.
+ // Excludes TEST_RUN_SKIP and explicit skips so the rate reflects real tests.
+ const processed = resultsArr.filter(r =>
+ !(r.skipped || r.errorCode === 'TEST_RUN_SKIP')
+ ).length;
+ const passed = resultsArr.filter(r =>
+ Number.isFinite(Number(r.actualMbps)) && Number(r.actualMbps) >= 10
+ ).length;
+ if (processed > 0) set('hdrNetworkPass', Math.round(passed / processed * 100) + '%');
+ // else: leave the REST-painted value in place.
+ applyLiveModeBadge();
+ }
+
+ // Mirror the admin "what's running" badge: Plan #N / P2P / Test Run with a
+ // 1-line description. Reads state.runMode + state.runPlanId (forwarded by
+ // the server's sanitizePublicState whitelist).
+ function applyLiveModeBadge() {
+ const badge = document.getElementById('liveModeBadge');
+ const labelEl = document.getElementById('liveModeBadgeLabel');
+ const subEl = document.getElementById('liveModeSub');
+ if (!badge || !labelEl) return;
+ const status = _liveState && _liveState.status;
+ const active = status === 'running' || status === 'paused' || (_cb && _cb.batchId);
+ if (!active) {
+ badge.style.display = 'none';
+ if (subEl) subEl.textContent = 'Live public testing — Sentinel dVPN network';
+ return;
+ }
+ let label, detail, color;
+ if ((_liveState && _liveState.testRun) || (_liveState && _liveState.runMode === 'test')) {
+ label = 'Test Run';
+ detail = 'Sample data — no real measurements, no payments';
+ color = 'var(--accent)';
+ } else if (_liveState && _liveState.runMode === 'subscription' && _liveState.runPlanId) {
+ label = 'Plan #' + _liveState.runPlanId;
+ detail = 'Subscription-allocated sessions, plan-scoped node set';
+ color = 'var(--green)';
+ } else {
+ label = 'P2P';
+ detail = 'All online nodes — direct peer-to-peer payments per session';
+ color = 'var(--accent)';
+ }
+ badge.style.display = '';
+ badge.style.borderColor = color;
+ badge.style.color = color;
+ labelEl.textContent = label;
+ if (subEl) subEl.textContent = detail;
}
// ─── SSE dispatcher ───
@@ -1324,11 +1648,43 @@ dVPN Node Test
let _sseRetry = 1000;
const SSE_MAX_RETRY = 30000;
+ // Track previous run identity so we can detect a new run starting on the
+ // operator side from `state` events alone — direct /api/start and
+ // /api/test-sub-plan invocations don't emit `loop:started`, only
+ // continuous.js does, so without this detector /live keeps stale rows
+ // from the prior run forever.
+ let _prevActiveRunNumber = null;
+ let _prevStatus = null;
+
+ // Wipe rendered rows + log for a brand-new run so the live page is not
+ // showing stale data from the previous run that the operator just stopped.
+ // Mirrors the admin panel's "clear log + renderTable on start" behaviour.
+ function resetLiveForNewRun() {
+ try {
+ resultsArr.length = 0;
+ const tbody = document.getElementById('resultsBody');
+ if (tbody) tbody.innerHTML = '';
+ const body = document.getElementById('logBody');
+ if (body) body.innerHTML = '';
+ _cb.batchId = null;
+ _cb.tested = 0;
+ _cb.passed = 0;
+ _cb.failed = 0;
+ _cb.snapshotSize = 0;
+ cbRender();
+ try { localStorage.removeItem(LIVE_CACHE_KEY); } catch {}
+ } catch {}
+ }
+
function handleEvent(d) {
switch (d.type) {
case 'loop:started':
+ // A fresh run started on the operator side — drop stale rows + log
+ // so /live reflects the new run, not the previous one.
+ resetLiveForNewRun();
setStatus('running');
- showPaused(false);
+ _liveState.status = 'running';
+ applyPauseFromState();
appendLog({ tag: 'SYS', cat: 'sys', status: 'ok', msg: 'Testing started' });
break;
case 'loop:stopping':
@@ -1337,12 +1693,16 @@ dVPN Node Test
break;
case 'loop:stopped':
setStatus('idle');
- showPaused(true);
+ _liveState.status = 'idle';
+ _cb.batchId = null;
+ applyPauseFromState();
appendLog({ tag: 'SYS', cat: 'sys', msg: 'Testing stopped' });
break;
case 'loop:error':
setStatus('error');
- showPaused(true);
+ _liveState.status = 'error';
+ _cb.batchId = null;
+ applyPauseFromState();
appendLog({ tag: 'SYS', cat: 'fail', status: 'fail', errCode: 'TESTER_ERROR', msg: d.error || 'unknown error' });
break;
case 'iteration:start':
@@ -1356,7 +1716,15 @@ dVPN Node Test
appendLog({ tag: 'ROUND', cat: 'sys', status: 'ok', msg: `Round ${d.iteration || '?'} done — ${passed} online, ${failed} down (${rate} online)` });
break;
}
- case 'batch:start':
+ case 'batch:start': {
+ // If this batch is different from the one we have rendered, drop
+ // stale rows from the prior batch so the table reflects the current
+ // run only. (loop:started already wipes for top-level run starts;
+ // this catches mid-loop iteration boundaries too.)
+ const newBatchId = d.batchId || d.batch_id || null;
+ if (newBatchId && _cb.batchId && newBatchId !== _cb.batchId) {
+ resetLiveForNewRun();
+ }
setStatus('running');
showPaused(false);
cbApplyBatchStart(d);
@@ -1365,8 +1733,9 @@ dVPN Node Test
msg: `Round ${d.iteration != null ? d.iteration : (d.batchId || '?')} — testing ${d.snapshotSize || d.snapshot_size || d.total || '?'} nodes`,
});
break;
+ }
case 'batch:node:result': {
- // Real-run uses snake_case; dry-run fanout uses camelCase.
+ // Real-run uses snake_case; test-run fanout uses camelCase.
const addr = d.node_address ?? d.address ?? null;
const moniker = d.moniker || '';
const mbps = d.actual_mbps ?? d.actualMbps ?? null;
@@ -1424,7 +1793,7 @@ dVPN Node Test
errorCode: err || null,
skipped,
inPlan: d.inPlan === true,
- dryRun: err === 'TEST_RUN_SKIP',
+ testRun: err === 'TEST_RUN_SKIP',
testedAt: tested,
});
cbApplyNodeResult({ passed: resultMbps != null && resultMbps >= 10 });
@@ -1456,7 +1825,10 @@ dVPN Node Test
const running = !!(d.status && d.status.running);
const midBatch = !!d.batchId;
setStatus(running || midBatch ? 'running' : 'idle');
- showPaused(!running && !midBatch);
+ // Defer the actual paused-overlay decision until after we've merged
+ // the full state below so applyPauseFromState sees the latest status.
+ if (!_liveState.status) _liveState.status = running ? 'running' : 'idle';
+ applyPauseFromState();
if (d.batchId && _cb.batchId == null) {
_cb.batchId = d.batchId;
if (d.snapshotSize) _cb.snapshotSize = d.snapshotSize;
@@ -1465,14 +1837,132 @@ dVPN Node Test
// Seed the log panel with the persisted backlog so a page refresh
// mid-audit doesn't blank the activity log.
if (Array.isArray(d.logs) && d.logs.length) {
- for (const line of d.logs) appendLog(line);
+ const body = document.getElementById('logBody');
+ if (body && body.children.length === 0) {
+ for (const line of d.logs) appendLog(line);
+ }
+ }
+ // Mirror admin: full state snapshot + per-node results so /live paints
+ // identically on connect — not only on subsequent updates.
+ if (d.state && typeof d.state === 'object') {
+ // If localStorage rehydrated rows from a previous run number,
+ // discover the mismatch on first init and wipe BEFORE the
+ // results-replay block below paints fresh-run rows on top.
+ const initRunNum = d.state.activeRunNumber != null ? d.state.activeRunNumber : null;
+ if (_prevActiveRunNumber != null && initRunNum != null && initRunNum !== _prevActiveRunNumber) {
+ resetLiveForNewRun();
+ }
+ mergeLiveState(d.state);
+ _prevActiveRunNumber = initRunNum;
+ _prevStatus = d.state.status || null;
+ }
+ // Init is authoritative for the current run. If REST hydration
+ // painted stale rows from a previous batch, drop any rows not in
+ // the init's results list when the run is actively in flight.
+ // This is what stops /live from showing "253/1040" from test #46
+ // while admin is mid-flight on test #47.
+ const _stateRunning = d.state && (d.state.status === 'running' || d.state.status === 'paused');
+ if (_stateRunning && Array.isArray(d.results)) {
+ const liveAddrs = new Set(d.results.map(r => r && r.address).filter(Boolean));
+ if (resultsArr.length > 0 && (resultsArr.length > liveAddrs.size + 5)) {
+ // Painted >> live → REST seeded historical rows. Wipe all and
+ // let the init's results array (and subsequent SSE events) be
+ // the only source of truth for the live table.
+ resultsArr.length = 0;
+ const tbody = document.getElementById('resultsBody');
+ if (tbody) tbody.innerHTML = '';
+ _cb.tested = 0; _cb.passed = 0; _cb.failed = 0;
+ }
}
+ if (Array.isArray(d.results) && d.results.length) {
+ for (const r of d.results) {
+ upsert({
+ address: r.address,
+ moniker: r.moniker || '',
+ country: r.country || '',
+ countryCode: r.countryCode || '',
+ city: r.city || '',
+ type: normalizeServiceType(r.serviceType ?? r.type),
+ actualMbps: r.actualMbps != null ? Number(r.actualMbps) : null,
+ baselineAtTest: r.baselineAtTest != null ? Number(r.baselineAtTest) : null,
+ peers: r.peers != null ? Number(r.peers) : null,
+ maxPeers: r.maxPeers != null ? Number(r.maxPeers) : null,
+ error: r.error || null,
+ errorCode: r.errorCode || null,
+ skipped: !!r.skipped,
+ inPlan: r.inPlan === true,
+ testRun: r.errorCode === 'TEST_RUN_SKIP',
+ testedAt: r.testedAt || null,
+ });
+ }
+ // Sync the current-batch counter to the replayed rows so the
+ // Tested tile + cb panel reflect actual progress on first paint.
+ _cb.tested = resultsArr.length;
+ _cb.passed = resultsArr.filter(x => Number(x.actualMbps) >= 10).length;
+ _cb.failed = _cb.tested - _cb.passed;
+ }
+ applyHeaderStatsFromState();
+ renderLiveStats();
+ if (_cb.batchId) cbRender();
break;
}
case 'log': {
if (d.msg) appendLog(d.msg);
break;
}
+ case 'state': {
+ // Admin emits broadcast('state', { state }) — server forwards full
+ // sanitized state. Mirror counters into _liveState + header + tiles.
+ if (d.state) {
+ const newRunNum = d.state.activeRunNumber != null ? d.state.activeRunNumber : null;
+ const newStatus = d.state.status || null;
+ const runChanged = newRunNum != null && _prevActiveRunNumber != null && newRunNum !== _prevActiveRunNumber;
+ const statusStartedRun = newStatus === 'running'
+ && (_prevStatus === 'done' || _prevStatus === 'error' || _prevStatus === 'idle' || _prevStatus === 'stopped');
+ if (runChanged || statusStartedRun) {
+ resetLiveForNewRun();
+ }
+ _prevActiveRunNumber = newRunNum;
+ _prevStatus = newStatus;
+ mergeLiveState(d.state);
+ renderLiveStats();
+ if (_cb.batchId) cbRender();
+ }
+ break;
+ }
+ case 'result': {
+ // Same shape as admin: broadcast('result', { result }). Upsert by
+ // address so /live's table matches admin row-for-row.
+ const r = d.result;
+ if (!r || !r.address) break;
+ upsert({
+ address: r.address,
+ moniker: r.moniker || '',
+ country: r.country || '',
+ countryCode: r.countryCode || '',
+ city: r.city || '',
+ type: normalizeServiceType(r.serviceType ?? r.type),
+ actualMbps: r.actualMbps != null ? Number(r.actualMbps) : null,
+ baselineAtTest: r.baselineAtTest != null ? Number(r.baselineAtTest) : null,
+ peers: r.peers != null ? Number(r.peers) : null,
+ maxPeers: r.maxPeers != null ? Number(r.maxPeers) : null,
+ error: r.error || null,
+ errorCode: r.errorCode || null,
+ skipped: !!r.skipped,
+ inPlan: r.inPlan === true,
+ testRun: r.errorCode === 'TEST_RUN_SKIP',
+ testedAt: r.testedAt || null,
+ });
+ applyHeaderStatsFromState();
+ renderLiveStats();
+ break;
+ }
+ case 'progress': {
+ // Numeric checkpoints from the pipeline (testedNodes, etc.).
+ // Treat as a partial state update so counters/header refresh.
+ mergeLiveState(d);
+ break;
+ }
default: break;
}
}
@@ -1518,6 +2008,13 @@ dVPN Node Test
const data = await res.json();
if (isHistorical || broadcastOff) showPaused(true);
const nodes = (data && data.nodes) || (data && data.results) || [];
+ // Mirror runMode/runPlanId from REST into _liveState so the mode badge
+ // paints on first load before any SSE state event arrives.
+ if (data && !isHistorical) {
+ if (data.runMode) _liveState.runMode = data.runMode;
+ if (data.runPlanId) _liveState.runPlanId = data.runPlanId;
+ if (data.mode === 'test') _liveState.testRun = true;
+ }
if (data && (data.started_at || data.id)) {
cbApplyBatchStart({
batchId: data.id,
@@ -1538,7 +2035,16 @@ dVPN Node Test
// run is actively in flight on the operator side.
if (!isHistorical && !broadcastOff) showPaused(false);
}
- if (Array.isArray(nodes)) {
+ // Only paint per-node rows when REST returned a *live* in-flight batch.
+ // The historical fallback (/api/public/runs/last → finished batch) is
+ // also used when there's no active batch yet — painting those rows
+ // here causes /live to show stale data (e.g. 253/1040 from the prior
+ // run) until SSE results trickle in. Counters (#tested, #failed) are
+ // already wiped above when isHistorical+broadcastOff would be wrong;
+ // here we just refuse to paint per-node rows for a finished batch
+ // when broadcast is on, because the operator is mid-run on a NEW one.
+ const paintRows = Array.isArray(nodes) && (!isHistorical || broadcastOff);
+ if (paintRows) {
for (const n of nodes) {
const addr = n.node_address || n.address;
if (!addr) continue;
@@ -1560,10 +2066,19 @@ dVPN Node Test
errorCode: n.errorCode || n.error_code || null,
skipped: !!n.skipped,
inPlan: n.inPlan === true || n.in_plan === true,
- dryRun: n.error_code === 'TEST_RUN_SKIP',
+ testRun: n.error_code === 'TEST_RUN_SKIP',
testedAt: n.testedAt || n.tested_at || null,
});
}
+ } else if (isHistorical && _broadcastLive) {
+ // Broadcast ON but no live batch in DB yet (audit hasn't reached
+ // the snapshot phase) → wipe the table and counters so /live shows
+ // an empty "warming up" state instead of last-run rows.
+ resultsArr.length = 0;
+ const tbody = document.getElementById('resultsBody');
+ if (tbody) tbody.innerHTML = '';
+ _cb.batchId = null; _cb.tested = 0; _cb.passed = 0; _cb.failed = 0; _cb.snapshotSize = 0;
+ cbRender();
}
} catch (_) {}
}
@@ -1619,6 +2134,72 @@ dVPN Node Test
}
setTimeout(() => { btn.innerHTML = original; btn.disabled = false; }, 1500);
}
+
+ async function showRowFailurePopup(ev) {
+ ev.stopPropagation();
+ const btn = ev.currentTarget;
+ const addr = btn.dataset.addr;
+ const moniker = btn.dataset.moniker || '';
+ if (!addr) return;
+
+ const existing = document.getElementById('errorPopupOverlay');
+ if (existing) existing.remove();
+
+ const overlay = document.createElement('div');
+ overlay.id = 'errorPopupOverlay';
+ overlay.style.cssText = 'position:fixed;inset:0;background:var(--glass-bg);backdrop-filter:blur(8px);z-index:9999;display:flex;align-items:center;justify-content:center;';
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
+ const popup = document.createElement('div');
+ popup.style.cssText = 'background:var(--bg-card-solid);border:1px solid var(--border);border-radius:16px;padding:24px 28px;max-width:780px;width:92%;max-height:85vh;overflow-y:auto;color:var(--text);font-family:var(--font-display);line-height:1.55';
+ popup.innerHTML = `
+
+ Node Error Details
+ ×
+
+ Loading…
+ `;
+ overlay.appendChild(popup);
+ document.body.appendChild(overlay);
+
+ try {
+ const res = await fetch('/api/public/node/' + encodeURIComponent(addr) + '/errors?limit=1', { credentials: 'same-origin' });
+ const data = await res.json();
+ const errs = (data && data.errors) || [];
+ const body = document.getElementById('errorPopupBody');
+ if (!body) return;
+ if (!errs.length) {
+ body.innerHTML = 'No stored failure log for this node yet.';
+ return;
+ }
+ const er = errs[0];
+ const eh = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
+ const captured = er.captured_at ? new Date(typeof er.captured_at === 'number' ? er.captured_at : Number(er.captured_at) || er.captured_at).toLocaleString() : '—';
+ const row = (label, value) => `
+
+ ${label}
+ ${value || '—'}
+ `;
+ body.innerHTML = `
+ ${row('Node', eh(moniker))}
+ ${row('Address', `${eh(addr)}`)}
+ ${row('Stage', eh(er.stage))}
+ ${row('Error Code', `${eh(er.error_code)}`)}
+ ${row('Captured', eh(captured))}
+ ${row('Message', `${eh(er.error_message)}`)}
+ ${er.log_snippet ? `
+
+ Log Snippet
+ ${eh(er.log_snippet)}
+ ` : ''}
+
+
+
+ `;
+ } catch (err) {
+ const body = document.getElementById('errorPopupBody');
+ if (body) body.innerHTML = 'Failed to load: ' + (err.message || 'unknown') + '';
+ }
+ }
View on Desktop
dVPN Node Test
-+ Sentinel dVPN Node Test + + +
+dVPN Node Test
Current BatchdVPN Node Test
const notice = document.getElementById('broadcastOffNotice'); if (_broadcastLive) { if (notice) notice.style.display = 'none'; - showPaused(false); + // Reconcile against current admin status; idle = still paused. + applyPauseFromState(); if (!wasLive) { connectSSE(); seedLogsFromRest(); @@ -769,20 +819,41 @@dVPN Node Test
const urlEl = document.getElementById('mobileGateUrl'); if (urlEl) urlEl.textContent = location.host + '/live'; applyThemeIcons(); + // Paint cached snapshot first so a refresh shows data instantly, + // then resolve broadcast state and reconcile with server. + rehydrateFromCache(); renderTable(); renderLiveStats(); // Resolve broadcast state BEFORE loading the current batch — otherwise // loadCurrentBatch races and may un-pause the overlay using the default // (broadcastLive=false) before we know the real state. await checkBroadcastState(); + // Seed full live state + results via REST so refresh paints the same + // dashboard the operator sees, even before the SSE init message lands. + if (_broadcastLive) await seedLiveStateFromRest(); loadCurrentBatch(); loadHeaderStats(); + applyPauseFromState(); setInterval(checkBroadcastState, 30000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkBroadcastState(); }); }); + // Normalize a node's serviceType into the lowercase string the row + // renderer expects ('wireguard' | 'v2ray' | null). The sanitizer can hand + // us either a number (1=wireguard, 2=v2ray) or a string — both must + // produce a transport badge. + function normalizeServiceType(v) { + if (v == null) return null; + if (v === 1 || v === '1') return 'wireguard'; + if (v === 2 || v === '2') return 'v2ray'; + const s = String(v).toLowerCase(); + if (s.includes('wire')) return 'wireguard'; + if (s.includes('v2')) return 'v2ray'; + return null; + } + // ─── Header network stats (baseline + chain-wide) ─── let _headerBaseline = null; function setHeaderBaseline(mbps) { @@ -795,6 +866,8 @@dVPN Node Test
el.style.color = n >= 30 ? '' : 'var(--red)'; } async function loadHeaderStats() { + // Paint from REST first so the header tiles have values on a cold load + // even before SSE init arrives. try { const r = await fetch('/api/public/test/status', { cache: 'no-store' }); if (r.ok) { @@ -812,6 +885,49 @@dVPN Node Test
if (j.passingPct != null) set('hdrNetworkPass', Number(j.passingPct).toFixed(0) + '%'); } } catch {} + // Then layer live-batch values on top once _liveState / resultsArr exist. + applyHeaderStatsFromState(); + } + + // REST fallback so a refresh paints stats + table BEFORE SSE init lands. + async function seedLiveStateFromRest() { + try { + const r = await fetch('/api/public/live-state', { cache: 'no-store' }); + if (!r.ok) return; + const j = await r.json(); + if (!j || !j.broadcastLive) return; + if (j.state) mergeLiveState(j.state); + if (Array.isArray(j.results)) { + for (const r2 of j.results) { + if (!r2 || !r2.address) continue; + upsert({ + address: r2.address, + moniker: r2.moniker || '', + country: r2.country || '', + countryCode: r2.countryCode || '', + city: r2.city || '', + type: r2.serviceType || null, + actualMbps: r2.actualMbps != null ? Number(r2.actualMbps) : null, + baselineAtTest: r2.baselineAtTest != null ? Number(r2.baselineAtTest) : null, + peers: r2.peers != null ? Number(r2.peers) : null, + maxPeers: r2.maxPeers != null ? Number(r2.maxPeers) : null, + error: r2.error || null, + errorCode: r2.errorCode || null, + skipped: !!r2.skipped, + inPlan: r2.inPlan === true, + testRun: r2.errorCode === 'TEST_RUN_SKIP', + testedAt: r2.testedAt || null, + }); + } + // Sync the current-batch counter to the seeded rows so the + // Tested tile shows real progress on a cold refresh. + _cb.tested = resultsArr.length; + _cb.passed = resultsArr.filter(x => Number(x.actualMbps) >= 10).length; + _cb.failed = _cb.tested - _cb.passed; + } + applyHeaderStatsFromState(); + renderLiveStats(); + } catch {} } function toggleTheme() { @@ -962,8 +1078,12 @@dVPN Node Test
const countryName = escHtml(r.country || (cc ? cc : '—')); const countryCol = flag ? `${flag} ${countryName}` : countryName; const cityCol = escHtml(r.city || '—'); - const act = r.actualMbps != null ? r.actualMbps.toFixed(2) + ' Mbps' : '--'; - const blAt = r.baselineAtTest != null ? r.baselineAtTest.toFixed(2) + ' Mbps' : '--'; + // TEST RUN rows never connect to the node — surface the Speed and per-row + // Baseline columns as "—" so the public can't misread the operator's WAN + // baseline as a node-side measurement. + const isTestRun = r.errorCode === 'TEST_RUN_SKIP'; + const act = (!isTestRun && r.actualMbps != null) ? r.actualMbps.toFixed(2) + ' Mbps' : '--'; + const blAt = (!isTestRun && r.baselineAtTest != null) ? r.baselineAtTest.toFixed(2) + ' Mbps' : '--'; let peersHtml = '--'; const peerCount = r.peers != null ? r.peers : null; @@ -990,7 +1110,8 @@dVPN Node Test
const isFail = r.actualMbps == null && !r.skipped; const copyCell = isFail && addrFull - ? `` + ? ` + ` : '—'; tr.innerHTML = ` @@ -1102,6 +1223,7 @@dVPN Node Test
if (o.cat === 'fail' && o.addr) { parts.push(``); + parts.push(``); } div.innerHTML = parts.join(''); @@ -1208,7 +1330,13 @@dVPN Node Test
function cbRender() { const snap = _cb.snapshotSize; - const tested = _cb.tested; + // Single source of truth: the deduped resultsArr (rows upserted by + // address) — same approach admin.html uses for its Tested tile. Server + // `state.testedNodes` can double-count retries / counter-increment + // races, which produced public 227 vs admin 150 desync. Fall back to + // _cb.tested only when resultsArr hasn't arrived (early init). + const arr = Array.isArray(resultsArr) ? resultsArr : []; + const tested = arr.length > 0 ? arr.length : _cb.tested; const total = snap || tested; const pct = total > 0 ? Math.min(100, Math.round(tested / total * 100)) : 0; @@ -1218,89 +1346,129 @@dVPN Node Test
const countEl = _cbEl('cbCount'); if (countEl) countEl.textContent = tested + ' / ' + (total || '—') + ' nodes tested'; + // Pass/Fail derived from resultsArr (matches admin's recompute logic). + const passed = arr.filter(r => Number(r.actualMbps) >= 10).length; + const failed = arr.filter(r => !r.skipped && r.errorCode !== 'TEST_RUN_SKIP' && (r.error || r.errorCode || r.actualMbps == null)).length; const passEl = _cbEl('cbPassed'); - if (passEl) passEl.textContent = _cb.passed; + if (passEl) passEl.textContent = passed; const failEl = _cbEl('cbFailed'); - if (failEl) failEl.textContent = _cb.failed; + if (failEl) failEl.textContent = failed; const snapEl = _cbEl('cbSnapshot'); if (snapEl) snapEl.textContent = snap || '—'; - const elapsed = _cb.startedAt ? Math.round((Date.now() - _cb.startedAt) / 1000) : 0; - const metaEl = _cbEl('cbMeta'); - if (metaEl) { - const bNum = _cb.iteration != null ? 'Batch #' + _cb.iteration : ''; - const elStr = elapsed > 0 ? elapsed + 's ago' : 'just started'; - metaEl.textContent = bNum ? bNum + ' · Started ' + elStr : (_cb.startedAt ? 'Started ' + elStr : 'waiting for admin to start testing…'); - } - - const etaEl = _cbEl('cbEta'); - if (etaEl && tested > 0 && elapsed > 0 && snap > tested) { - const rem = snap - tested; - const rate = tested / elapsed; - const etaSec = Math.round(rem / rate); - const m = Math.floor(etaSec / 60), s = etaSec % 60; - etaEl.textContent = 'ETA ~' + (m > 0 ? m + 'm ' : '') + s + 's'; - } else if (etaEl) { - etaEl.textContent = ''; - } - renderLiveStats(); } function renderLiveStats() { const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; - const snap = _cb.snapshotSize || 0; const arr = Array.isArray(resultsArr) ? resultsArr : []; + // Snapshot total for THIS run. Prefer the current-batch snapshot from + // `batch:start` and `state.totalNodes` (admin's per-run snapshot). Both + // reflect the size of the active sweep, not the chain-wide node count. + const snap = _cb.snapshotSize || + (_liveState && Number(_liveState.totalNodes)) || + arr.length || 0; // "Connected" = node returned mbps>0 (handshake + speed both succeeded). const connected = arr.filter(r => r.actualMbps != null && r.actualMbps > 0).length; - // "Failed" = explicit error OR mbps==null (excluding TEST RUN skips). - const failed = arr.filter(r => !r.skipped && (r.error || r.errorCode || r.actualMbps == null)).length; + // "Failed" = explicit error OR mbps==null. Exclude TEST RUN skips and + // explicit skips — those are not failures, they're not-tested. + const failed = arr.filter(r => !r.skipped && r.errorCode !== 'TEST_RUN_SKIP' && (r.error || r.errorCode || r.actualMbps == null)).length; // Pass 10 Mbps SLA: real connections at or above 10. const p10 = arr.filter(r => r.actualMbps != null && Number(r.actualMbps) >= 10).length; - // Tested = anything we have a row for (matches admin's processed count). - const processed = arr.filter(r => !r.skipped).length; + // Tested counter — match admin.html exactly: deduped resultsArr.length + // is the single source of truth. State.testedNodes can double-count + // (counter-increment races during retries) which caused public to read + // 227 while admin read 150. Mid-run joiners get the full backlog via + // the `init` SSE event + REST fallback, so arr.length is always the + // same row count admin sees. + const processed = arr.length; + // Real processed (excluding TEST_RUN_SKIP / explicit skips) for failure + // / SLA / pass-rate denominators below. + const realProcessed = arr.filter(r => !r.skipped && r.errorCode !== 'TEST_RUN_SKIP').length; // Not Online: failed AND no peers (genuinely offline). const notOnline = arr.filter(r => r.actualMbps == null && (r.peers === 0 || r.peers == null)).length; // Dead Plan Nodes: plan-flagged nodes that failed. const planRows = arr.filter(r => r.inPlan === true); const deadPlan = planRows.filter(r => r.actualMbps == null).length; - // Tested - set('lsTested', processed); - if (snap > 0 && snap > processed) { - set('lsTestedSub', `of ${snap.toLocaleString()} | ${(snap - processed).toLocaleString()} remaining`); - } else if (snap > 0) { - set('lsTestedSub', `of ${snap.toLocaleString()} | complete`); + // Detect TEST RUN: state.testRun flag (immediate), any row marked + // TEST_RUN_SKIP, OR no real-processed rows yet but at least one skipped. + const isDryRun = !!(_liveState && _liveState.testRun) || + arr.some(r => r.errorCode === 'TEST_RUN_SKIP') || + (arr.length > 0 && realProcessed === 0 && + arr.some(r => r.skipped)); + const testRunBadge = document.getElementById('testRunBadge'); + if (testRunBadge) testRunBadge.style.display = isDryRun ? '' : 'none'; + const testRunBanner = document.getElementById('testRunBanner'); + if (testRunBanner) testRunBanner.classList.toggle('is-visible', isDryRun); + + // Tested — always shows real progress, even during a TEST RUN. + if (snap > 0) { + set('lsTested', `${processed.toLocaleString()} / ${snap.toLocaleString()} Nodes`); + set('lsTestedSub', snap > processed + ? `${(snap - processed).toLocaleString()} remaining` + : 'complete'); } else { - set('lsTestedSub', processed > 0 ? `${processed} tested` : 'no data yet'); + set('lsTested', processed.toLocaleString()); + set('lsTestedSub', processed > 0 ? `${processed.toLocaleString()} tested` : 'no data yet'); } + // During a TEST RUN, the speed/SLA/failure metrics are intentionally + // not measured — surface that with a dash so the public sees that + // these are not "0" results, they are simply not produced. + const DASH = '—'; + // Total Failed - set('lsTotalFailed', failed); - const failPct = processed > 0 ? (failed / processed * 100).toFixed(1) : '0'; - set('lsTotalFailedPct', failPct + '% failure rate'); + if (isDryRun) { + set('lsTotalFailed', DASH); + set('lsTotalFailedPct', 'not tested'); + } else { + set('lsTotalFailed', failed); + const failPct = realProcessed > 0 ? (failed / realProcessed * 100).toFixed(1) : '0'; + set('lsTotalFailedPct', failPct + '% failure rate'); + } // Pass 10 Mbps SLA - set('lsPassedSLA', p10); - const slaPct = connected > 0 ? (p10 / connected * 100).toFixed(1) : '0'; - set('lsPassedSLAPct', slaPct + '% of connected'); + if (isDryRun) { + set('lsPassedSLA', DASH); + set('lsPassedSLAPct', 'not tested'); + } else { + set('lsPassedSLA', p10); + const slaPct = connected > 0 ? (p10 / connected * 100).toFixed(1) : '0'; + set('lsPassedSLAPct', slaPct + '% of connected'); + } // Dead Plan Nodes - set('lsDeadPlan', deadPlan); - set('lsDeadPlanPct', planRows.length > 0 - ? `${deadPlan}/${planRows.length} plan nodes failed` - : 'No plan failures'); - - // Not Online - set('lsNotOnline', notOnline); - set('lsNotOnlineSub', notOnline === 1 ? '1 node offline' : `${notOnline} nodes offline`); + if (isDryRun) { + set('lsDeadPlan', DASH); + set('lsDeadPlanPct', 'not tested'); + } else { + set('lsDeadPlan', deadPlan); + set('lsDeadPlanPct', planRows.length > 0 + ? `${deadPlan}/${planRows.length} plan nodes failed` + : 'No plan failures'); + } - // Pass Rate (connected / processed) — matches admin's stat - const connRate = processed > 0 ? (connected / processed * 100).toFixed(1) : '0'; - set('lsPassRate', connRate + '%'); + // Not Online — match admin.html exactly: any row with no measured mbps + // and no peers counts as offline. TEST_RUN_SKIP rows are included for + // parity with admin (user-confirmed behavior 2026-04-25). + const trulyOffline = arr.filter(r => + r.actualMbps == null && + (r.peers === 0 || r.peers == null) + ).length; + set('lsNotOnline', trulyOffline); + set('lsNotOnlineSub', trulyOffline === 1 ? '1 node offline' : `${trulyOffline} nodes offline`); + + // Pass Rate + if (isDryRun) { + set('lsPassRate', DASH); + } else { + const connRate = realProcessed > 0 ? (connected / realProcessed * 100).toFixed(1) : '0'; + set('lsPassRate', connRate + '%'); + } const dot = document.getElementById('headerDot'); if (dot) dot.className = (_cb.startedAt && !_cb.gapEndsAt) ? 'pulse-dot running' : 'pulse-dot'; @@ -1308,7 +1476,8 @@dVPN Node Test
setInterval(() => { if (_cb.startedAt && _cb.tested >= 0) cbRender(); - else renderLiveStats(); + renderLiveStats(); + applyHeaderStatsFromState(); }, 5000); // ─── Upsert ─── @@ -1317,6 +1486,161 @@dVPN Node Test
if (idx >= 0) resultsArr[idx] = { ...resultsArr[idx], ...r }; else resultsArr.push(r); addSingleRow(r); + schedulePersist(); + } + + // ─── Live snapshot persistence ─── + // Mirror admin's view across page refresh: cache the current resultsArr + + // last-known state so a refresh paints immediately, before SSE init lands. + const LIVE_CACHE_KEY = 'live:snapshot:v1'; + const LIVE_CACHE_TTL_MS = 60 * 60 * 1000; // 1h — enough to survive a reload + let _liveState = {}; + let _persistTimer = null; + function schedulePersist() { + if (_persistTimer) return; + _persistTimer = setTimeout(() => { + _persistTimer = null; + try { + const snap = { + ts: Date.now(), + broadcastLive: _broadcastLive, + state: _liveState || {}, + // Stamp the run number so rehydrate can detect a stale cache + // from a previous test and discard it instead of repainting + // last run's rows on top of a fresh run. + activeRunNumber: _liveState && _liveState.activeRunNumber != null ? _liveState.activeRunNumber : null, + results: resultsArr, + cb: { ..._cb }, + }; + localStorage.setItem(LIVE_CACHE_KEY, JSON.stringify(snap)); + } catch {} + }, 250); + } + function rehydrateFromCache() { + try { + const raw = localStorage.getItem(LIVE_CACHE_KEY); + if (!raw) return; + const snap = JSON.parse(raw); + if (!snap || !snap.ts || (Date.now() - snap.ts) > LIVE_CACHE_TTL_MS) { + try { localStorage.removeItem(LIVE_CACHE_KEY); } catch {} + return; + } + // Park the cached run number; we'll compare against the live SSE + // state and discard rows below when they're from a previous run. + if (snap.activeRunNumber != null) _prevActiveRunNumber = snap.activeRunNumber; + if (snap.state && typeof snap.state === 'object') _liveState = snap.state; + if (Array.isArray(snap.results) && snap.results.length && resultsArr.length === 0) { + for (const r of snap.results) { + resultsArr.push(r); + addSingleRow(r); + } + } + if (snap.cb && typeof snap.cb === 'object') { + // Restore counters so the bar paints immediately + Object.assign(_cb, snap.cb); + cbRender(); + } + applyHeaderStatsFromState(); + } catch {} + } + + // Merge a state snapshot from admin into our local mirror, then refresh + // the header derived from current-batch values. + function mergeLiveState(s) { + if (!s || typeof s !== 'object') return; + _liveState = { ..._liveState, ...s }; + applyHeaderStatsFromState(); + applyPauseFromState(); + schedulePersist(); + } + + // Show the "Testing Has Been Paused" overlay whenever there is no active + // run on admin — i.e. broadcastLive is off OR state.status is not running + // and there is no in-flight batch. Public visitors should see paused, not + // a frozen stale dashboard. + function applyPauseFromState() { + const status = _liveState && _liveState.status; + const running = status === 'running' || status === 'paused'; + const midBatch = !!(_cb && _cb.batchId); + if (!_broadcastLive) { showPaused(true); return; } + showPaused(!running && !midBatch); + } + + // Derive header stats from the live state + resultsArr (current batch), + // never from chain-wide aggregates. Falls back to "—" when unknown. + function applyHeaderStatsFromState() { + const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; + // Server Baseline — from state.baselineMbps + if (_liveState && _liveState.baselineMbps != null) { + setHeaderBaseline(_liveState.baselineMbps); + } + // Network Nodes — total snapshot size for current batch (state.totalNodes + // tracks scanned/online; fall back to _cb.snapshotSize, then resultsArr.) + const totalNodes = + (_liveState && _liveState.totalNodes) || + _cb.snapshotSize || + resultsArr.length; + // Only overwrite if we actually have a live value; otherwise leave + // whatever loadHeaderStats() painted from /api/public/stats. + if (totalNodes) set('hdrNetworkNodes', Number(totalNodes).toLocaleString()); + // Network Median — derive from passed nodes' actualMbps in current batch + const speeds = resultsArr + .map(r => Number(r.actualMbps)) + .filter(n => Number.isFinite(n) && n > 0) + .sort((a, b) => a - b); + if (speeds.length) { + const mid = Math.floor(speeds.length / 2); + const median = speeds.length % 2 ? speeds[mid] : (speeds[mid - 1] + speeds[mid]) / 2; + set('hdrNetworkMedian', median.toFixed(1) + ' Mbps'); + } + // else: leave the REST-painted value in place. + // Network Pass Rate — passed / processed in current batch. + // Excludes TEST_RUN_SKIP and explicit skips so the rate reflects real tests. + const processed = resultsArr.filter(r => + !(r.skipped || r.errorCode === 'TEST_RUN_SKIP') + ).length; + const passed = resultsArr.filter(r => + Number.isFinite(Number(r.actualMbps)) && Number(r.actualMbps) >= 10 + ).length; + if (processed > 0) set('hdrNetworkPass', Math.round(passed / processed * 100) + '%'); + // else: leave the REST-painted value in place. + applyLiveModeBadge(); + } + + // Mirror the admin "what's running" badge: Plan #N / P2P / Test Run with a + // 1-line description. Reads state.runMode + state.runPlanId (forwarded by + // the server's sanitizePublicState whitelist). + function applyLiveModeBadge() { + const badge = document.getElementById('liveModeBadge'); + const labelEl = document.getElementById('liveModeBadgeLabel'); + const subEl = document.getElementById('liveModeSub'); + if (!badge || !labelEl) return; + const status = _liveState && _liveState.status; + const active = status === 'running' || status === 'paused' || (_cb && _cb.batchId); + if (!active) { + badge.style.display = 'none'; + if (subEl) subEl.textContent = 'Live public testing — Sentinel dVPN network'; + return; + } + let label, detail, color; + if ((_liveState && _liveState.testRun) || (_liveState && _liveState.runMode === 'test')) { + label = 'Test Run'; + detail = 'Sample data — no real measurements, no payments'; + color = 'var(--accent)'; + } else if (_liveState && _liveState.runMode === 'subscription' && _liveState.runPlanId) { + label = 'Plan #' + _liveState.runPlanId; + detail = 'Subscription-allocated sessions, plan-scoped node set'; + color = 'var(--green)'; + } else { + label = 'P2P'; + detail = 'All online nodes — direct peer-to-peer payments per session'; + color = 'var(--accent)'; + } + badge.style.display = ''; + badge.style.borderColor = color; + badge.style.color = color; + labelEl.textContent = label; + if (subEl) subEl.textContent = detail; } // ─── SSE dispatcher ─── @@ -1324,11 +1648,43 @@dVPN Node Test
let _sseRetry = 1000; const SSE_MAX_RETRY = 30000; + // Track previous run identity so we can detect a new run starting on the + // operator side from `state` events alone — direct /api/start and + // /api/test-sub-plan invocations don't emit `loop:started`, only + // continuous.js does, so without this detector /live keeps stale rows + // from the prior run forever. + let _prevActiveRunNumber = null; + let _prevStatus = null; + + // Wipe rendered rows + log for a brand-new run so the live page is not + // showing stale data from the previous run that the operator just stopped. + // Mirrors the admin panel's "clear log + renderTable on start" behaviour. + function resetLiveForNewRun() { + try { + resultsArr.length = 0; + const tbody = document.getElementById('resultsBody'); + if (tbody) tbody.innerHTML = ''; + const body = document.getElementById('logBody'); + if (body) body.innerHTML = ''; + _cb.batchId = null; + _cb.tested = 0; + _cb.passed = 0; + _cb.failed = 0; + _cb.snapshotSize = 0; + cbRender(); + try { localStorage.removeItem(LIVE_CACHE_KEY); } catch {} + } catch {} + } + function handleEvent(d) { switch (d.type) { case 'loop:started': + // A fresh run started on the operator side — drop stale rows + log + // so /live reflects the new run, not the previous one. + resetLiveForNewRun(); setStatus('running'); - showPaused(false); + _liveState.status = 'running'; + applyPauseFromState(); appendLog({ tag: 'SYS', cat: 'sys', status: 'ok', msg: 'Testing started' }); break; case 'loop:stopping': @@ -1337,12 +1693,16 @@dVPN Node Test
break; case 'loop:stopped': setStatus('idle'); - showPaused(true); + _liveState.status = 'idle'; + _cb.batchId = null; + applyPauseFromState(); appendLog({ tag: 'SYS', cat: 'sys', msg: 'Testing stopped' }); break; case 'loop:error': setStatus('error'); - showPaused(true); + _liveState.status = 'error'; + _cb.batchId = null; + applyPauseFromState(); appendLog({ tag: 'SYS', cat: 'fail', status: 'fail', errCode: 'TESTER_ERROR', msg: d.error || 'unknown error' }); break; case 'iteration:start': @@ -1356,7 +1716,15 @@dVPN Node Test
appendLog({ tag: 'ROUND', cat: 'sys', status: 'ok', msg: `Round ${d.iteration || '?'} done — ${passed} online, ${failed} down (${rate} online)` }); break; } - case 'batch:start': + case 'batch:start': { + // If this batch is different from the one we have rendered, drop + // stale rows from the prior batch so the table reflects the current + // run only. (loop:started already wipes for top-level run starts; + // this catches mid-loop iteration boundaries too.) + const newBatchId = d.batchId || d.batch_id || null; + if (newBatchId && _cb.batchId && newBatchId !== _cb.batchId) { + resetLiveForNewRun(); + } setStatus('running'); showPaused(false); cbApplyBatchStart(d); @@ -1365,8 +1733,9 @@dVPN Node Test
msg: `Round ${d.iteration != null ? d.iteration : (d.batchId || '?')} — testing ${d.snapshotSize || d.snapshot_size || d.total || '?'} nodes`, }); break; + } case 'batch:node:result': { - // Real-run uses snake_case; dry-run fanout uses camelCase. + // Real-run uses snake_case; test-run fanout uses camelCase. const addr = d.node_address ?? d.address ?? null; const moniker = d.moniker || ''; const mbps = d.actual_mbps ?? d.actualMbps ?? null; @@ -1424,7 +1793,7 @@dVPN Node Test
errorCode: err || null, skipped, inPlan: d.inPlan === true, - dryRun: err === 'TEST_RUN_SKIP', + testRun: err === 'TEST_RUN_SKIP', testedAt: tested, }); cbApplyNodeResult({ passed: resultMbps != null && resultMbps >= 10 }); @@ -1456,7 +1825,10 @@dVPN Node Test
const running = !!(d.status && d.status.running); const midBatch = !!d.batchId; setStatus(running || midBatch ? 'running' : 'idle'); - showPaused(!running && !midBatch); + // Defer the actual paused-overlay decision until after we've merged + // the full state below so applyPauseFromState sees the latest status. + if (!_liveState.status) _liveState.status = running ? 'running' : 'idle'; + applyPauseFromState(); if (d.batchId && _cb.batchId == null) { _cb.batchId = d.batchId; if (d.snapshotSize) _cb.snapshotSize = d.snapshotSize; @@ -1465,14 +1837,132 @@dVPN Node Test
// Seed the log panel with the persisted backlog so a page refresh // mid-audit doesn't blank the activity log. if (Array.isArray(d.logs) && d.logs.length) { - for (const line of d.logs) appendLog(line); + const body = document.getElementById('logBody'); + if (body && body.children.length === 0) { + for (const line of d.logs) appendLog(line); + } + } + // Mirror admin: full state snapshot + per-node results so /live paints + // identically on connect — not only on subsequent updates. + if (d.state && typeof d.state === 'object') { + // If localStorage rehydrated rows from a previous run number, + // discover the mismatch on first init and wipe BEFORE the + // results-replay block below paints fresh-run rows on top. + const initRunNum = d.state.activeRunNumber != null ? d.state.activeRunNumber : null; + if (_prevActiveRunNumber != null && initRunNum != null && initRunNum !== _prevActiveRunNumber) { + resetLiveForNewRun(); + } + mergeLiveState(d.state); + _prevActiveRunNumber = initRunNum; + _prevStatus = d.state.status || null; + } + // Init is authoritative for the current run. If REST hydration + // painted stale rows from a previous batch, drop any rows not in + // the init's results list when the run is actively in flight. + // This is what stops /live from showing "253/1040" from test #46 + // while admin is mid-flight on test #47. + const _stateRunning = d.state && (d.state.status === 'running' || d.state.status === 'paused'); + if (_stateRunning && Array.isArray(d.results)) { + const liveAddrs = new Set(d.results.map(r => r && r.address).filter(Boolean)); + if (resultsArr.length > 0 && (resultsArr.length > liveAddrs.size + 5)) { + // Painted >> live → REST seeded historical rows. Wipe all and + // let the init's results array (and subsequent SSE events) be + // the only source of truth for the live table. + resultsArr.length = 0; + const tbody = document.getElementById('resultsBody'); + if (tbody) tbody.innerHTML = ''; + _cb.tested = 0; _cb.passed = 0; _cb.failed = 0; + } } + if (Array.isArray(d.results) && d.results.length) { + for (const r of d.results) { + upsert({ + address: r.address, + moniker: r.moniker || '', + country: r.country || '', + countryCode: r.countryCode || '', + city: r.city || '', + type: normalizeServiceType(r.serviceType ?? r.type), + actualMbps: r.actualMbps != null ? Number(r.actualMbps) : null, + baselineAtTest: r.baselineAtTest != null ? Number(r.baselineAtTest) : null, + peers: r.peers != null ? Number(r.peers) : null, + maxPeers: r.maxPeers != null ? Number(r.maxPeers) : null, + error: r.error || null, + errorCode: r.errorCode || null, + skipped: !!r.skipped, + inPlan: r.inPlan === true, + testRun: r.errorCode === 'TEST_RUN_SKIP', + testedAt: r.testedAt || null, + }); + } + // Sync the current-batch counter to the replayed rows so the + // Tested tile + cb panel reflect actual progress on first paint. + _cb.tested = resultsArr.length; + _cb.passed = resultsArr.filter(x => Number(x.actualMbps) >= 10).length; + _cb.failed = _cb.tested - _cb.passed; + } + applyHeaderStatsFromState(); + renderLiveStats(); + if (_cb.batchId) cbRender(); break; } case 'log': { if (d.msg) appendLog(d.msg); break; } + case 'state': { + // Admin emits broadcast('state', { state }) — server forwards full + // sanitized state. Mirror counters into _liveState + header + tiles. + if (d.state) { + const newRunNum = d.state.activeRunNumber != null ? d.state.activeRunNumber : null; + const newStatus = d.state.status || null; + const runChanged = newRunNum != null && _prevActiveRunNumber != null && newRunNum !== _prevActiveRunNumber; + const statusStartedRun = newStatus === 'running' + && (_prevStatus === 'done' || _prevStatus === 'error' || _prevStatus === 'idle' || _prevStatus === 'stopped'); + if (runChanged || statusStartedRun) { + resetLiveForNewRun(); + } + _prevActiveRunNumber = newRunNum; + _prevStatus = newStatus; + mergeLiveState(d.state); + renderLiveStats(); + if (_cb.batchId) cbRender(); + } + break; + } + case 'result': { + // Same shape as admin: broadcast('result', { result }). Upsert by + // address so /live's table matches admin row-for-row. + const r = d.result; + if (!r || !r.address) break; + upsert({ + address: r.address, + moniker: r.moniker || '', + country: r.country || '', + countryCode: r.countryCode || '', + city: r.city || '', + type: normalizeServiceType(r.serviceType ?? r.type), + actualMbps: r.actualMbps != null ? Number(r.actualMbps) : null, + baselineAtTest: r.baselineAtTest != null ? Number(r.baselineAtTest) : null, + peers: r.peers != null ? Number(r.peers) : null, + maxPeers: r.maxPeers != null ? Number(r.maxPeers) : null, + error: r.error || null, + errorCode: r.errorCode || null, + skipped: !!r.skipped, + inPlan: r.inPlan === true, + testRun: r.errorCode === 'TEST_RUN_SKIP', + testedAt: r.testedAt || null, + }); + applyHeaderStatsFromState(); + renderLiveStats(); + break; + } + case 'progress': { + // Numeric checkpoints from the pipeline (testedNodes, etc.). + // Treat as a partial state update so counters/header refresh. + mergeLiveState(d); + break; + } default: break; } } @@ -1518,6 +2008,13 @@dVPN Node Test
const data = await res.json(); if (isHistorical || broadcastOff) showPaused(true); const nodes = (data && data.nodes) || (data && data.results) || []; + // Mirror runMode/runPlanId from REST into _liveState so the mode badge + // paints on first load before any SSE state event arrives. + if (data && !isHistorical) { + if (data.runMode) _liveState.runMode = data.runMode; + if (data.runPlanId) _liveState.runPlanId = data.runPlanId; + if (data.mode === 'test') _liveState.testRun = true; + } if (data && (data.started_at || data.id)) { cbApplyBatchStart({ batchId: data.id, @@ -1538,7 +2035,16 @@dVPN Node Test
// run is actively in flight on the operator side. if (!isHistorical && !broadcastOff) showPaused(false); } - if (Array.isArray(nodes)) { + // Only paint per-node rows when REST returned a *live* in-flight batch. + // The historical fallback (/api/public/runs/last → finished batch) is + // also used when there's no active batch yet — painting those rows + // here causes /live to show stale data (e.g. 253/1040 from the prior + // run) until SSE results trickle in. Counters (#tested, #failed) are + // already wiped above when isHistorical+broadcastOff would be wrong; + // here we just refuse to paint per-node rows for a finished batch + // when broadcast is on, because the operator is mid-run on a NEW one. + const paintRows = Array.isArray(nodes) && (!isHistorical || broadcastOff); + if (paintRows) { for (const n of nodes) { const addr = n.node_address || n.address; if (!addr) continue; @@ -1560,10 +2066,19 @@dVPN Node Test
errorCode: n.errorCode || n.error_code || null, skipped: !!n.skipped, inPlan: n.inPlan === true || n.in_plan === true, - dryRun: n.error_code === 'TEST_RUN_SKIP', + testRun: n.error_code === 'TEST_RUN_SKIP', testedAt: n.testedAt || n.tested_at || null, }); } + } else if (isHistorical && _broadcastLive) { + // Broadcast ON but no live batch in DB yet (audit hasn't reached + // the snapshot phase) → wipe the table and counters so /live shows + // an empty "warming up" state instead of last-run rows. + resultsArr.length = 0; + const tbody = document.getElementById('resultsBody'); + if (tbody) tbody.innerHTML = ''; + _cb.batchId = null; _cb.tested = 0; _cb.passed = 0; _cb.failed = 0; _cb.snapshotSize = 0; + cbRender(); } } catch (_) {} } @@ -1619,6 +2134,72 @@dVPN Node Test
} setTimeout(() => { btn.innerHTML = original; btn.disabled = false; }, 1500); } + + async function showRowFailurePopup(ev) { + ev.stopPropagation(); + const btn = ev.currentTarget; + const addr = btn.dataset.addr; + const moniker = btn.dataset.moniker || ''; + if (!addr) return; + + const existing = document.getElementById('errorPopupOverlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'errorPopupOverlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:var(--glass-bg);backdrop-filter:blur(8px);z-index:9999;display:flex;align-items:center;justify-content:center;'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + const popup = document.createElement('div'); + popup.style.cssText = 'background:var(--bg-card-solid);border:1px solid var(--border);border-radius:16px;padding:24px 28px;max-width:780px;width:92%;max-height:85vh;overflow-y:auto;color:var(--text);font-family:var(--font-display);line-height:1.55'; + popup.innerHTML = ` +Node Error Details
+ × +${eh(er.log_snippet)}
+