SENTINEL NODE TEST
+SENTINEL NODE TEST
SENTINEL NODE TEST
}); } + function copyAddrFromData(ev) { + const el = ev.currentTarget || ev.target; + const v = el && el.dataset ? el.dataset.addr : ''; + if (!v) return; + copyToClipboard(v, el); + } + window.copyAddrFromData = copyAddrFromData; + // Dedup helper — replace existing entry for same address, or append function upsertLocal(result) { const idx = resultsArr.findIndex(r => r.address === result.address); @@ -1199,7 +1255,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 +1272,65 @@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; + } + } + + // ─── Reconcile UI _testingMode with the server's authoritative runMode ─ + // The client mode flag (_testingMode) is only meaningful during the + // chooser flow. Once a run is active, the server's state.runMode is + // authoritative — drift between the two is what produced the C-1 family + // of bugs (Resume/Retest short-circuiting on stale UI state). When a + // server-sent state lands, force the UI flag to mirror it so any later + // resumeAudit / retestFails / testPlanFlow call branches correctly. + if (active && state.runMode) { + const want = state.testRun || state.runMode === 'test' + ? 'testrun' + : (state.runMode === 'subscription' ? 'plan' : 'p2p'); + if (_testingMode !== want) { + _testingMode = want; + try { localStorage.setItem('sntTestingMode', _testingMode); } catch {} + if (state.runMode === 'subscription' && state.runPlanId) _selectedPlanId = state.runPlanId; + } + } + + // 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 +1344,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 +1389,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 isTestRun = !!state.testRun || + resultsArr.some(r => r && r.errorCode === 'TEST_RUN_SKIP'); + const DASH = '—'; + // Total Failed - document.getElementById('statTotalFailed').textContent = fail; + document.getElementById('statTotalFailed').textContent = isTestRun ? DASH : fail; const failPct = processed > 0 ? (fail / processed * 100).toFixed(1) : 0; - document.getElementById('statTotalFailedPct').textContent = failPct + '% failure rate'; + document.getElementById('statTotalFailedPct').textContent = isTestRun + ? 'not measured in test run' + : failPct + '% failure rate'; // 10 Mbps SLA + pass rate combined - document.getElementById('statPassed').textContent = p10; + document.getElementById('statPassed').textContent = isTestRun ? DASH : p10; const passRate = done > 0 ? (p10 / done * 100).toFixed(1) : 0; - document.getElementById('statPassedPct').textContent = passRate + '% of connected'; + document.getElementById('statPassedPct').textContent = isTestRun + ? '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 = isTestRun ? 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 +1463,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 = isTestRun ? 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 = isTestRun + ? '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'); @@ -1416,6 +1551,7 @@SENTINEL NODE TEST
let activeFilter = 'all'; function setFilter(f) { activeFilter = f; + _resultsPage = 1; // Update button styles ['all','pass','fail','slow','wg','v2'].forEach(id => { const el = document.getElementById('filter' + id.charAt(0).toUpperCase() + id.slice(1)); @@ -1434,24 +1570,70 @@SENTINEL NODE TEST
return arr; } + const RESULTS_PAGE_SIZE = 50; + let _resultsPage = 1; + function renderTable() { const tbody = document.getElementById('resultsBody'); + const tableWrap = tbody.closest('.table-container, .results-container, div'); + const prevScroll = tableWrap ? tableWrap.scrollTop : 0; + + const filtered = applyFilter(resultsArr).slice().reverse(); + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / RESULTS_PAGE_SIZE)); + if (_resultsPage > totalPages) _resultsPage = totalPages; + const start = (_resultsPage - 1) * RESULTS_PAGE_SIZE; + const slice = filtered.slice(start, start + RESULTS_PAGE_SIZE); + tbody.innerHTML = ''; - const filtered = applyFilter(resultsArr); - filtered.slice(-maxRows).reverse().forEach(r => appendRowHtml(r, tbody)); - const label = activeFilter === 'all' ? filtered.length + ' Entries' : filtered.length + '/' + resultsArr.length + ' (' + activeFilter.toUpperCase() + ')'; + slice.forEach(r => appendRowHtml(r, tbody)); + + const label = activeFilter === 'all' ? total + ' Entries' : total + '/' + resultsArr.length + ' (' + activeFilter.toUpperCase() + ')'; document.getElementById('resultsCountLabel').textContent = label; + renderResultsPager(total, totalPages); + + if (tableWrap) tableWrap.scrollTop = prevScroll; + } + + function renderResultsPager(total, totalPages) { + const pager = document.getElementById('resultsPager'); + if (!pager) return; + if (totalPages <= 1) { pager.innerHTML = ''; return; } + const start = (_resultsPage - 1) * RESULTS_PAGE_SIZE + 1; + const end = Math.min(_resultsPage * RESULTS_PAGE_SIZE, total); + pager.innerHTML = + '' + + 'Page ' + _resultsPage + ' / ' + totalPages + ' · ' + start + '–' + end + ' of ' + total + '' + + ''; + } + + function resultsGoPage(n) { + const filtered = applyFilter(resultsArr); + const totalPages = Math.max(1, Math.ceil(filtered.length / RESULTS_PAGE_SIZE)); + _resultsPage = Math.min(Math.max(1, n), totalPages); + renderTable(); } + window.resultsGoPage = resultsGoPage; + // Live SSE row update — only patches when the row is on the visible page, + // otherwise relies on the next renderTable() to pick up the change. function addSingleRow(r) { - const tbody = document.getElementById('resultsBody'); - // Remove old row for same node (upsert replaced it in array, now fix DOM) - for (const row of tbody.querySelectorAll('tr')) { - if (row.dataset.addr === r.address) { row.remove(); break; } + const filtered = applyFilter(resultsArr).slice().reverse(); + const idx = filtered.findIndex(x => x.address === r.address); + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / RESULTS_PAGE_SIZE)); + const pageStart = (_resultsPage - 1) * RESULTS_PAGE_SIZE; + const pageEnd = pageStart + RESULTS_PAGE_SIZE; + + // If the new row would land on the current page, re-render the page so + // ordering stays correct. Otherwise just refresh the counter + pager. + if (idx >= pageStart && idx < pageEnd) { + renderTable(); + return; } - appendRowHtml(r, tbody, true); - document.getElementById('resultsCountLabel').textContent = resultsArr.length + ' Entries'; - if (tbody.children.length > maxRows) tbody.removeChild(tbody.lastChild); + const label = activeFilter === 'all' ? total + ' Entries' : total + '/' + resultsArr.length + ' (' + activeFilter.toUpperCase() + ')'; + document.getElementById('resultsCountLabel').textContent = label; + renderResultsPager(total, totalPages); } function appendRowHtml(r, tbody, prepend = false) { @@ -1463,13 +1645,13 @@SENTINEL NODE TEST
const addrFull = r.address || ''; const monikerText = r.moniker || r.nodeName || r.name || '(unknown)'; const addrShort = addrFull ? `${addrFull.slice(0, 12)}…${addrFull.slice(-5)}` : '--'; - const addr = addrFull ? `${addrShort}` : '--'; + const addr = addrFull ? `${escHtml(addrShort)}` : '--'; const cc = r.countryCode || _countryToCode(r.country) || ''; const flag = cc.length === 2 - ? `SENTINEL NODE TEST
const isFail = r.actualMbps == null && !r.skipped; const copyCell = isFail && addrFull - ? `` + ? ` + ` : '—'; tr.innerHTML = ` @@ -1684,11 +1867,107 @@SENTINEL NODE TEST
} }).catch(() => {}); + 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 {} + + // Server state is authoritative when a run is active — fall back to the + // local UI flag only when there's no live runMode (chooser flow before + // /api/start fires). This is the C-1 fix: Resume/Retest no longer trust + // a stale _testingMode after a process restart or context drift. + function isTestRunMode() { + if (state && (state.testRun === true || state.runMode === 'test')) return true; + if (state && state.runMode === 'p2p') return false; + if (state && state.runMode === 'subscription') return false; + return _testingMode === 'testrun'; + } + function isPlanMode() { + if (state && state.runMode === 'subscription') return true; + if (state && (state.runMode === 'p2p' || state.runMode === 'test')) return false; + 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(testRun ? { testRun: true } : {}), + body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); if (data.error) { appendLog('Start failed: ' + data.error); return false; } @@ -1697,28 +1976,119 @@SENTINEL NODE TEST
async function startAudit() { if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; } - const dryEl = document.getElementById('testRunDev'); - const isTestRun = !!(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
btn.disabled = true; document.getElementById('btnStop').disabled = false; try { - if (!await devStart(isTestRun)) { + // Fire /api/start FIRST. Only wipe the dashboard if the server + // accepted the request — otherwise a 4xx (e.g. PUBLIC_RUN_ACTIVE, + // missing MNEMONIC, RUN_ACTIVE) used to leave the operator with an + // empty dashboard for a run that never started. + const ok = await devStart(isTestRun); + if (!ok) { btn.classList.remove('loading'); - btn.textContent = 'New Test'; + btn.textContent = 'Start Test'; btn.disabled = false; return; } + // Server accepted — safe to clear previous test data from dashboard. + resultsArr = []; + state.testedNodes = 0; + state.failedNodes = 0; + state.retryCount = 0; + state.totalNodes = 0; + state.passed15 = 0; + state.passed10 = 0; + state.passedBaseline = 0; + state.nodeSpeedHistory = []; + state.baselineHistory = []; + state.estimatedTotalCost = '0.0000 P2P'; + state.startedAt = null; + state.completedAt = null; + state.errorMessage = null; + state.pauseReason = null; + renderTable(); + applyState(); + document.getElementById('logBody').innerHTML = ''; 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('testRunDev'); - 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 +2168,7 @@SENTINEL NODE TEST
} async function retestFails() { - const dryEl = document.getElementById('testRunDev'); - if (dryEl && dryEl.checked) { + if (isTestRunMode()) { if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; } await devStart(true); return; @@ -1829,6 +2223,276 @@SENTINEL NODE TEST
} } + // CLI for dVPN Builders. Closeable via × button, Escape, or backdrop click. + 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:880px;width:94%;max-height:90vh;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:12px 14px;border-radius:6px;font-size:12px;border:1px solid var(--border);white-space:pre;overflow-x:auto;line-height:1.6;'; + const sectionTitle = 'color:var(--accent);font-weight:600;font-size:14px;margin:22px 0 8px;letter-spacing:0.4px;text-transform:uppercase'; + const subTitle = 'color:var(--text);font-weight:600;font-size:13px;margin:14px 0 6px'; + const tocStyle = 'display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 6px;font-size:11px'; + const chipStyle = 'background:var(--bg-input);border:1px solid var(--border);padding:3px 8px;border-radius:99px;color:var(--text-dim);text-decoration:none;letter-spacing:0.4px'; + + popup.innerHTML = ` +CLI for dVPN Builders
+
- Stop Audit
+ Stop Test
Gracefully stops the current scan after the active node finishes. Results are saved. You can Resume later.
@@ -1928,13 +2592,12 @@
- Subscription Plan
- Test Sub. Plan
+ Test Subscription Plan
×
- 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…
@@ -1962,6 +2625,9 @@ 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 +2642,7 @@
Plan ${p.planId}
${badge}
+ ${allocBadge}
Subscription #${p.subscriptionId}
@@ -1995,8 +2662,7 @@ Select plan…'];
- _subPlanCache.plans.forEach(p => {
- const usable = p.feeGrantActive && p.nodeCount > 0 && p.ownerSentAddr;
- const label = `Plan ${p.planId} — ${p.nodeCount} nodes${p.feeGrantActive ? '' : ' • no fee grant'}`;
- opts.push(``);
- });
- sel.innerHTML = opts.join('');
+ // Server accepted — clean canvas for the new run.
+ const _logBody = document.getElementById('logBody');
+ if (_logBody) _logBody.innerHTML = '';
+ resultsArr = [];
+ state.testedNodes = 0;
+ state.failedNodes = 0;
+ state.totalNodes = 0;
+ state.passed10 = 0;
+ state.passed15 = 0;
+ state.passedBaseline = 0;
+ state.nodeSpeedHistory = [];
+ state.baselineHistory = [];
+ renderTable();
+ applyState();
} catch (err) {
- sel.innerHTML = ``;
+ alert('Sub. Plan request failed: ' + err.message);
}
}
- function onSubPlanInlineChange() { /* selection stored via select.value; Start reads it */ }
-
- async function startCreateMode() {
- const btn = document.getElementById('btnCreateStart');
- const dryEl = document.getElementById('testRunDev');
- if (dryEl && dryEl.checked) {
- if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; }
- await devStart(true);
- return;
- }
- if (_createMode === 'p2p') {
- startAudit();
- return;
- }
- const sel = document.getElementById('subPlanInlineSelect');
- const planId = sel ? sel.value : '';
- if (!planId) {
- alert('Pick a subscription plan first.');
- return;
- }
- const plan = _subPlanCache.plans.find(p => String(p.planId) === String(planId));
- if (!plan) { alert('Plan not found in cache. Reopen and try again.'); return; }
- if (!plan.feeGrantActive || plan.nodeCount === 0 || !plan.ownerSentAddr) {
- alert('This plan is not testable: ' + (
- !plan.ownerSentAddr ? 'plan owner unknown.'
- : plan.nodeCount === 0 ? 'no active nodes.'
- : 'plan owner has not granted gas to this wallet.'
- ));
- return;
- }
- if (btn) { btn.disabled = true; btn.classList.add('loading'); }
- try {
- await runSubPlanTest(plan.planId, plan.subscriptionId, plan.ownerSentAddr);
- } finally {
- if (btn) { btn.disabled = false; btn.classList.remove('loading'); }
- }
- }
+ // ─── Create-Mode shadow chooser removed ─────────────────────────────────
+ // The setCreateMode / startCreateMode / loadSubPlansInline / _createMode
+ // / _subPlanCache surface was an unreachable shadow of the live New-Test
+ // chooser flow. It had no DOM caller (the active flow goes through
+ // setTestingMode + startAudit / runSubPlanTest / devStart). Removed to
+ // eliminate drift between two parallel mode-picker code paths.
async function stopAudit() {
const btn = document.getElementById('btnStop');
@@ -2218,41 +2832,83 @@ Search failed: ' + e.message + '';
+ 'Search failed: ' + escHtml(e.message || String(e)) + ' ';
+ const pager = document.getElementById('nsPager');
+ if (pager) pager.innerHTML = '';
}
}
- function nsRenderResults(nodes) {
+ let _nsAllNodes = [];
+ let _nsPage = 1;
+ const NS_PAGE_SIZE = 50;
+
+ function nsRenderPage() {
const tbody = document.getElementById('nsBody');
- document.getElementById('nsResultCount').textContent = nodes.length ? nodes.length + ' nodes' : '';
- if (!nodes.length) {
+ const total = _nsAllNodes.length;
+ document.getElementById('nsResultCount').textContent = total ? total + ' nodes' : '';
+ if (!total) {
tbody.innerHTML = 'No nodes found. ';
+ const pager = document.getElementById('nsPager');
+ if (pager) pager.innerHTML = '';
return;
}
- tbody.innerHTML = nodes.map(function(n) {
- const addrShort = n.addr ? n.addr.slice(0, 14) + '…' + n.addr.slice(-5) : '—';
+ const totalPages = Math.max(1, Math.ceil(total / NS_PAGE_SIZE));
+ if (_nsPage > totalPages) _nsPage = totalPages;
+ const start = (_nsPage - 1) * NS_PAGE_SIZE;
+ const slice = _nsAllNodes.slice(start, start + NS_PAGE_SIZE);
+ tbody.innerHTML = slice.map(function(n) {
+ const addrFull = n.addr || '';
+ const addrShort = addrFull ? addrFull.slice(0, 14) + '…' + addrFull.slice(-5) : '—';
const lastTested = n.last_tested ? new Date(n.last_tested).toLocaleString() : '—';
const badge = n.last_pass == null ? 'N/A'
: n.last_pass ? 'PASS'
: 'FAIL';
const mbps = n.actual_mbps != null ? n.actual_mbps.toFixed(2) + ' Mbps' : '—';
- const sa = (n.addr || '').replace(/'/g, '');
- return ''
- + '' + (n.moniker || '—') + ' '
- + '' + addrShort + ' '
- + '' + (n.country || '—') + ' '
- + '' + lastTested + ' '
+ + ''
+ + '' + escHtml(addrShort) + ' '
+ + ''
+ + '' + escHtml(lastTested) + ' '
+ '' + badge + ' '
- + '' + mbps + ' '
+ + '' + mbps + ' '
+ ' ';
}).join('');
+ nsRenderPager(total, totalPages);
}
+ function nsRenderPager(total, totalPages) {
+ const pager = document.getElementById('nsPager');
+ if (!pager) return;
+ if (totalPages <= 1) { pager.innerHTML = ''; return; }
+ const start = (_nsPage - 1) * NS_PAGE_SIZE + 1;
+ const end = Math.min(_nsPage * NS_PAGE_SIZE, total);
+ pager.innerHTML =
+ ''
+ + 'Page ' + _nsPage + ' / ' + totalPages + ' · ' + start + '–' + end + ' of ' + total + ''
+ + '';
+ }
+
+ function nsGoPage(n) {
+ const totalPages = Math.max(1, Math.ceil(_nsAllNodes.length / NS_PAGE_SIZE));
+ _nsPage = Math.min(Math.max(1, n), totalPages);
+ nsRenderPage();
+ }
+ window.nsGoPage = nsGoPage;
+
+ function nsOpenDrawerFromRow(ev) {
+ const tr = ev.currentTarget || ev.target.closest('tr');
+ if (!tr) return;
+ nsOpenDrawer(tr.dataset.addr || '');
+ }
+ window.nsOpenDrawerFromRow = nsOpenDrawerFromRow;
+
async function nsOpenDrawer(addr) {
if (!addr) return;
_nsCurrentAddr = addr;
@@ -2276,15 +2932,15 @@ ' + (nd.addr || addr) + ''],
- ['Country', nd.country || '—'],
- ['City', nd.city || '—'],
- ['Service', nd.service_type || '—'],
- ['Status', nd.status || '—'],
- ['Last Tested', nd.last_tested ? new Date(nd.last_tested).toLocaleString() : '—'],
+ ['Address', '' + escHtml(nd.addr || addr) + ''],
+ ['Country', escHtml(nd.country || '—')],
+ ['City', escHtml(nd.city || '—')],
+ ['Service', escHtml(nd.service_type || '—')],
+ ['Status', escHtml(nd.status || '—')],
+ ['Last Tested', escHtml(nd.last_tested ? new Date(nd.last_tested).toLocaleString() : '—')],
].map(function(pair) {
return ''
- + '' + pair[0] + ''
+ + '' + escHtml(pair[0]) + ''
+ '' + pair[1] + '';
}).join('');
@@ -2371,7 +3027,7 @@ { 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 = _fmtCapturedUtc(er.captured_at);
+ 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..e9c71e7 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)
@@ -305,7 +323,9 @@ async function _resolveSnapshot() {
* Main loop body — runs until stopRequested or unrecoverable error.
*/
async function _runLoop() {
- _ctrl.running = true;
+ // _ctrl.running is set synchronously in start() before this is fire-and-forget
+ // invoked, so a second start() call cannot race past the `if (_ctrl.running)`
+ // guard while we're still on the microtask queue.
_ctrl.stopRequested = false;
_ctrl.startedAt = Date.now();
_ctrl.iteration = 0;
@@ -331,9 +351,19 @@ async function _runLoop() {
mode: _ctrl.mode,
});
- // Fresh pipeline state per iteration so counters reset cleanly
+ // Fresh pipeline state per iteration so counters reset cleanly.
+ // Inherit SDK / pricingMode / runMode from _ctrl so per-node failure
+ // rows record the correct sdk and pipeline branches the right way —
+ // without this, every continuous-loop result fell back to sdk:'js'.
const loopState = createState();
loopState.stopRequested = false;
+ if (_ctrl.activeSDK) loopState.activeSDK = _ctrl.activeSDK;
+ if (_ctrl.pricingMode) loopState.pricingMode = _ctrl.pricingMode;
+ loopState.runMode = _ctrl.mode || 'p2p';
+ loopState.runPlanId = _ctrl.planId || null;
+ loopState.runSubscriptionId = _ctrl.subscriptionId || null;
+ loopState.runGranter = _ctrl.subscriptionGranter || null;
+ loopState.testRun = !!_ctrl.testRun;
let currentBatchId = 0;
let frozenNodes = null;
@@ -648,6 +678,13 @@ export async function start(opts = {}) {
_ctrl.minDelayMs = minDelayMs;
_ctrl.lastError = null;
_ctrl.iteration = 0;
+ _ctrl.activeSDK = opts.activeSDK || _ctrl.activeSDK || 'js';
+ _ctrl.pricingMode = opts.pricingMode || _ctrl.pricingMode || null;
+ _ctrl.testRun = !!opts.testRun;
+ // Mark running synchronously BEFORE the fire-and-forget so a second
+ // start() call cannot slip through the `if (_ctrl.running)` guard while
+ // _runLoop is still on the microtask queue (the C-2 race window).
+ _ctrl.running = true;
_emitScoped('loop:started', {
mode,
diff --git a/audit/pipeline.js b/audit/pipeline.js
index 12eada6..eedde10 100644
--- a/audit/pipeline.js
+++ b/audit/pipeline.js
@@ -14,13 +14,38 @@ import {
} from '../core/constants.js';
import { cachedWalletSetup, createFreshClient, signAndBroadcastRetry } from '../core/wallet.js';
import { getAllNodes, fetchPlanMembership, ensureLcd, getActiveLcd, getRpcClient, rpcFetchAllNodesForPlanPaginated } from '../core/chain.js';
-import { rpcQueryNode } from 'sentinel-dvpn-sdk';
+import { rpcQueryNode } from 'blue-js-sdk';
import {
submitBatchPayment, waitForBatchSessions, waitForSessionActive,
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.
+const _stopWaiters = new Set();
+let _pipelineStopFlag = false;
+function sleep(ms) {
+ if (_pipelineStopFlag) return Promise.resolve();
+ return new Promise((resolve) => {
+ let timer = setTimeout(() => { _stopWaiters.delete(wake); resolve(); }, ms);
+ const wake = () => { try { clearTimeout(timer); } catch {} _stopWaiters.delete(wake); resolve(); };
+ _stopWaiters.add(wake);
+ });
+}
+export function triggerPipelineStop() {
+ _pipelineStopFlag = true;
+ for (const w of _stopWaiters) { try { w(); } catch {} }
+ _stopWaiters.clear();
+}
+export function resetPipelineStop() {
+ _pipelineStopFlag = false;
+}
+
// Platform-aware imports — Windows has full implementation, others get stubs
let WG_AVAILABLE, IS_ADMIN, emergencyCleanupSync, uninstallWgTunnel, checkV2Ray;
if (process.platform === 'win32') {
@@ -324,6 +349,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;
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..0d38125
--- /dev/null
+++ b/bin/commands/agent.js
@@ -0,0 +1,325 @@
+/**
+ * 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: '--remote-url ', description: 'Node remote URL (https://host:port) for chain-status' },
+ { 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: 'sdk-verify-key', method: 'GET', path: '/api/sdk-verify/:key', auth: true, desc: 'SDK file content by verification key', params: ['key'] },
+ { 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' },
+ { sub: 'pub-test-start', method: 'POST', path: '/api/public/test/start', auth: false, desc: 'Start gated public test (requires ALLOW_PUBLIC_TEST=true)',
+ bodyFromFlags: (f) => ({ mode: f['--mode'] === 'subscription' ? 'subscription' : 'p2p' }) },
+ { sub: 'pub-test-stop', method: 'POST', path: '/api/public/test/stop', auth: false, desc: 'Stop gated public test (requires ALLOW_PUBLIC_TEST=true)' },
+
+ // 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' },
+
+ // 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 (requires --remote-url https://host:port)',
+ queryFromFlags: (f) => {
+ const url = f['--remote-url'] || f['--remoteUrl'];
+ return url ? { remoteUrl: url } : {};
+ } },
+
+ // 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);
+}
+
+// Map :paramName → fallback flag name when positional is omitted
+const PARAM_FLAG_FALLBACK = {
+ addr: '--addr',
+ num: '--num',
+};
+
+function fillPath(rawPath, params, positional, flags) {
+ if (!params || !params.length) return rawPath;
+ let p = rawPath;
+ for (let i = 0; i < params.length; i++) {
+ let tok = positional[i];
+ if (!tok && flags) {
+ const fallbackFlag = PARAM_FLAG_FALLBACK[params[i]];
+ if (fallbackFlag && typeof flags[fallbackFlag] === 'string') {
+ tok = flags[fallbackFlag];
+ }
+ }
+ 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, f);
+
+ // Build query / body
+ const query = {};
+ if (f['--limit']) query.limit = f['--limit'];
+ if (f['--country']) query.country = f['--country'];
+ if (ep.queryFromFlags) Object.assign(query, ep.queryFromFlags(f) || {});
+
+ 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/commands/node.js b/bin/commands/node.js
index 837df2c..5bbdf75 100644
--- a/bin/commands/node.js
+++ b/bin/commands/node.js
@@ -9,7 +9,7 @@ import {
getRpcClient,
ensureLcd,
} from '../../core/chain.js';
-import { rpcQueryNode } from 'sentinel-dvpn-sdk';
+import { rpcQueryNode } from 'blue-js-sdk';
// ─── Metadata ────────────────────────────────────────────────────────────────
diff --git a/bin/lib/args.js b/bin/lib/args.js
index 3398d3f..c327152 100644
--- a/bin/lib/args.js
+++ b/bin/lib/args.js
@@ -24,6 +24,11 @@ const ALIASES = {
const VALUE_FLAGS = new Set([
'--lcd', '--sdk', '--port', '--wallet', '--output',
'--limit', '--max', '--country', '--timeout', '--out',
+ // agent driver flags
+ '--base-url', '--token', '--watch',
+ '--plan-id', '--sub-id', '--sub-granter',
+ '--pricing-mode', '--addr', '--remote-url', '--num', '--mode',
+ '--dns', '--enabled',
]);
// ─── Parser ───────────────────────────────────────────────────────────────────
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..0cdbe28 100644
--- a/core/chain.js
+++ b/core/chain.js
@@ -15,6 +15,7 @@ import {
LCD_ENDPOINTS as SDK_LCD_ENDPOINTS,
createRpcQueryClientWithFallback,
rpcQueryNode,
+ rpcQueryNodes,
rpcQueryNodesForPlan,
rpcQueryPlan,
rpcQuerySubscriptionsForAccount,
@@ -23,7 +24,7 @@ import {
queryFeeGrant,
broadcastWithFeeGrant as sdkBroadcastWithFeeGrant,
disconnectRpc,
-} from 'sentinel-dvpn-sdk';
+} from 'blue-js-sdk';
// ─── LCD Endpoint Management ────────────────────────────────────────────────
const LCD_LIST = SDK_LCD_ENDPOINTS.map(e => e.url);
@@ -85,43 +86,15 @@ export function cleanupRpc() {
// ─── Node List (RPC primary, LCD fallback) ─────────────────────────────────
/**
- * Fetch all active nodes via RPC with raw ABCI pagination.
- * The chain caps ABCI queries at 100 per page, so we loop with next_key.
+ * Wrapper around SDK's rpcQueryNodes with broadcast logging.
* Returns null if RPC is unavailable (signals LCD fallback).
*/
async function rpcFetchAllNodes(broadcast) {
const client = await getRpcClient();
- if (!client) return null; // signal LCD fallback
- return rpcFetchAllNodesPaginated(client, broadcast);
-}
-
-/**
- * Raw ABCI paginated fetch for all active nodes.
- * Loops with next_key until all pages are fetched.
- *
- * Cosmos PageRequest proto fields:
- * 1 = key (bytes), 2 = offset (uint64), 3 = limit (uint64),
- * 4 = count_total (bool), 5 = reverse (bool)
- * NOTE: The SDK's encodePagination has a bug — it puts limit at field 2 (offset).
- * We use correct field numbers here.
- */
-async function rpcFetchAllNodesPaginated(client, broadcast) {
- // Sentinel v3 chain truncates at `limit` without emitting `next_key`.
- // A single large request returns the full set (chain has its own hard ceiling).
- const PAGE_SIZE = 10000;
+ if (!client) return null;
try {
- const pagination = encodeRpcVarintField(3, PAGE_SIZE); // limit at field 3
- const request = concatBytes([
- encodeRpcVarintField(1, 1), // status = active
- encodeRpcEmbedded(2, pagination), // pagination
- ]);
- const result = await client.queryClient.queryAbci(
- '/sentinel.node.v3.QueryService/QueryNodes',
- request,
- );
- const fields = decodeRpcProto(new Uint8Array(result.value));
- const nodes = (fields[1] || []).map(entry => decodeRpcNode(decodeRpcProto(entry.value)));
- if (broadcast) broadcast('log', { msg: ` RPC: ${nodes.length} nodes fetched (limit=${PAGE_SIZE})` });
+ const nodes = await rpcQueryNodes(client, { status: 1, limit: 10000 });
+ if (broadcast) broadcast('log', { msg: ` RPC: ${nodes.length} nodes fetched` });
return nodes;
} catch (err) {
if (broadcast) broadcast('log', { msg: ` RPC fetch failed: ${err.message}` });
@@ -130,37 +103,18 @@ async function rpcFetchAllNodesPaginated(client, broadcast) {
}
/**
- * Fetch ALL nodes linked to a plan via RPC with raw ABCI pagination.
- * Chain caps ABCI queries at 100 per page; we loop with next_key until done.
- *
- * QueryNodesForPlanRequest proto:
- * 1 = id (uint64 plan id), 2 = status (enum), 3 = pagination (PageRequest)
- * PageRequest fields: 1=key, 2=offset, 3=limit, 4=count_total, 5=reverse
+ * Wrapper around SDK's rpcQueryNodesForPlan with broadcast logging.
+ * Returns [] on failure (caller falls back to LCD).
*
* @param {{ queryClient }} client
* @param {number|string|bigint} planId
* @param {(channel:string, data:any)=>void} [broadcast]
- * @returns {Promise>} — empty array on failure
+ * @returns {Promise>}
*/
export async function rpcFetchAllNodesForPlanPaginated(client, planId, broadcast) {
- // Sentinel v3 chain truncates at `limit` without emitting `next_key`.
- // A single large request returns the full set (observed: plan 36 → 803 nodes
- // with limit=1000 but only 100 with limit=100, and no next_key either time).
- const PAGE_SIZE = 10000;
try {
- const pagination = encodeRpcVarintField(3, PAGE_SIZE); // limit at field 3
- const request = concatBytes([
- encodeRpcVarintField(1, BigInt(planId)), // plan id
- encodeRpcVarintField(2, 1), // status = active
- encodeRpcEmbedded(3, pagination), // pagination
- ]);
- const result = await client.queryClient.queryAbci(
- '/sentinel.node.v3.QueryService/QueryNodesForPlan',
- request,
- );
- const fields = decodeRpcProto(new Uint8Array(result.value));
- const nodes = (fields[1] || []).map(entry => decodeRpcNode(decodeRpcProto(entry.value)));
- if (broadcast) broadcast('log', { msg: ` RPC plan ${planId}: ${nodes.length} nodes (limit=${PAGE_SIZE})` });
+ const nodes = await rpcQueryNodesForPlan(client, BigInt(planId), { status: 1, limit: 10000 });
+ if (broadcast) broadcast('log', { msg: ` RPC plan ${planId}: ${nodes.length} nodes` });
return nodes;
} catch (err) {
if (broadcast) broadcast('log', { msg: ` RPC plan ${planId} fetch failed: ${err.message}` });
@@ -169,7 +123,8 @@ export async function rpcFetchAllNodesForPlanPaginated(client, planId, broadcast
}
// ─── Minimal Protobuf Helpers (for raw ABCI pagination) ────────────────────
-// Exported so session.js can reuse for RPC session queries.
+// Used by discoverPlans, queryPlanOwnerSent, queryFeeGrantRpcFirst, session.js,
+// and the scripts/probe-*.mjs tooling.
export function encodeRpcVarint(value) {
let n = BigInt(value);
@@ -258,24 +213,6 @@ export function decodeRpcString(data) {
return new TextDecoder().decode(data);
}
-function decodeRpcPrice(fields) {
- return {
- denom: fields[1]?.[0] ? decodeRpcString(fields[1][0].value) : '',
- base_value: fields[2]?.[0] ? decodeRpcString(fields[2][0].value) : '0',
- quote_value: fields[3]?.[0] ? decodeRpcString(fields[3][0].value) : '0',
- };
-}
-
-function decodeRpcNode(fields) {
- return {
- address: fields[1]?.[0] ? decodeRpcString(fields[1][0].value) : '',
- gigabyte_prices: (fields[2] || []).map(f => decodeRpcPrice(decodeRpcProto(f.value))),
- hourly_prices: (fields[3] || []).map(f => decodeRpcPrice(decodeRpcProto(f.value))),
- remote_addrs: (fields[4] || []).map(f => decodeRpcString(f.value)),
- status: fields[6]?.[0] ? Number(fields[6][0].value) : 0,
- };
-}
-
// ─── Node List (RPC primary → LCD fallback) ────────────────────────────────
/**
@@ -648,17 +585,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 +606,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 +636,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 +826,9 @@ export async function querySubscriberPlansEnriched(walletAddress) {
feeGrantActive,
feeGrantCheckFailed,
nodeCount,
+ viaAllocation: !!s.viaAllocation,
+ grantedBytes: s.grantedBytes || null,
+ subOwnerAddress: s.ownerAddress || null,
});
}
return results;
@@ -893,14 +874,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/constants.js b/core/constants.js
index 6292168..2d7bf2d 100644
--- a/core/constants.js
+++ b/core/constants.js
@@ -23,9 +23,12 @@ export const LOW_BALANCE_WARN = 500_000; // 0.5 P2P
export const PORT = parseInt(process.env.PORT || '3001', 10);
// ─── DNS Configuration ──────────────────────────────────────────────────────
-// Default: HNS (Handshake) — decentralized DNS, 0.01% error rate across 9,298 tests
+// Default: HNS (Handshake) — decentralized DNS, 0.01% error rate across 9,298 tests.
+// SDK uses key `handshake`; tester uses `hns` historically — both keys resolve to the same servers.
+const _hns = ['103.196.38.38', '103.196.38.39'];
export const DNS_PRESETS = {
- hns: ['103.196.38.38', '103.196.38.39'],
+ hns: _hns,
+ handshake: _hns,
google: ['8.8.8.8', '8.8.4.4'],
cloudflare: ['1.1.1.1', '1.0.0.1'],
quad9: ['9.9.9.9', '149.112.112.112'],
@@ -36,7 +39,7 @@ if (ACTIVE_DNS.length === 0) ACTIVE_DNS = DNS_PRESETS.hns;
export function setActiveDns(servers) { ACTIVE_DNS = servers; }
// ─── Protocol Message Types (from SDK — single source of truth) ─────────────
-import { MSG_TYPES } from 'sentinel-dvpn-sdk';
+import { MSG_TYPES } from 'blue-js-sdk';
export { MSG_TYPES };
export const V3_MSG_TYPE = MSG_TYPES.START_SESSION;
export const V3_SUB_TYPE = MSG_TYPES.START_SUBSCRIPTION;
diff --git a/core/countries.js b/core/countries.js
index 4d103a7..3c62306 100644
--- a/core/countries.js
+++ b/core/countries.js
@@ -1,65 +1,31 @@
/**
- * Country/Flag helpers — copied from Sentinel SDK js-sdk/app-helpers.js
- * 80+ countries confirmed on the Sentinel network as of 2026-03.
+ * Country/Flag helpers — base map from Blue JS SDK, layered with tester-only
+ * extras (Central Asia + Balkans) that should be upstreamed.
*/
-// ─── Country Name → ISO Code Map ────────────────────────────────────────────
+import {
+ COUNTRY_MAP as SDK_COUNTRY_MAP,
+ countryNameToCode as sdkCountryNameToCode,
+ getFlagUrl as sdkGetFlagUrl,
+ getFlagEmoji as sdkGetFlagEmoji,
+} from 'blue-js-sdk';
-export const COUNTRY_MAP = Object.freeze({
- // Standard names
- 'united states': 'US', 'germany': 'DE', 'france': 'FR', 'united kingdom': 'GB',
- 'netherlands': 'NL', 'canada': 'CA', 'japan': 'JP', 'singapore': 'SG',
- 'australia': 'AU', 'brazil': 'BR', 'india': 'IN', 'south korea': 'KR',
- 'turkey': 'TR', 'romania': 'RO', 'poland': 'PL', 'spain': 'ES',
- 'italy': 'IT', 'sweden': 'SE', 'norway': 'NO', 'finland': 'FI',
- 'switzerland': 'CH', 'austria': 'AT', 'ireland': 'IE', 'portugal': 'PT',
- 'czech republic': 'CZ', 'hungary': 'HU', 'bulgaria': 'BG', 'greece': 'GR',
- 'ukraine': 'UA', 'russia': 'RU', 'hong kong': 'HK', 'taiwan': 'TW',
- 'thailand': 'TH', 'vietnam': 'VN', 'indonesia': 'ID', 'philippines': 'PH',
- 'mexico': 'MX', 'argentina': 'AR', 'chile': 'CL', 'colombia': 'CO',
- 'south africa': 'ZA', 'israel': 'IL', 'united arab emirates': 'AE',
- 'nigeria': 'NG', 'latvia': 'LV', 'lithuania': 'LT', 'estonia': 'EE',
- 'croatia': 'HR', 'serbia': 'RS', 'denmark': 'DK', 'belgium': 'BE',
- 'luxembourg': 'LU', 'malta': 'MT', 'cyprus': 'CY', 'iceland': 'IS',
- 'new zealand': 'NZ', 'malaysia': 'MY', 'bangladesh': 'BD', 'pakistan': 'PK',
- 'egypt': 'EG', 'kenya': 'KE', 'morocco': 'MA', 'peru': 'PE',
- 'venezuela': 'VE', 'georgia': 'GE', 'guatemala': 'GT', 'puerto rico': 'PR',
- 'china': 'CN', 'saudi arabia': 'SA', 'kazakhstan': 'KZ', 'mongolia': 'MN',
- 'slovakia': 'SK', 'albania': 'AL', 'moldova': 'MD', 'jamaica': 'JM',
- 'bolivia': 'BO', 'ecuador': 'EC', 'uruguay': 'UY', 'bahrain': 'BH',
- 'dr congo': 'CD', 'costa rica': 'CR', 'panama': 'PA', 'paraguay': 'PY',
- 'dominican republic': 'DO', 'el salvador': 'SV', 'honduras': 'HN',
- 'nicaragua': 'NI', 'cuba': 'CU', 'haiti': 'HT', 'trinidad and tobago': 'TT',
+// ─── Country Name → ISO Code Map ────────────────────────────────────────────
+// Tester-only extras not yet in SDK COUNTRY_MAP. PR candidate upstream.
+const TESTER_COUNTRY_EXTRAS = {
'kyrgyzstan': 'KG', 'uzbekistan': 'UZ', 'tajikistan': 'TJ',
'bosnia and herzegovina': 'BA', 'north macedonia': 'MK', 'montenegro': 'ME',
'kosovo': 'XK', 'slovenia': 'SI',
+ // Short codes for the extras above
+ 'kg': 'KG', 'uz': 'UZ', 'tj': 'TJ',
+};
- // Variant names the chain returns
- 'the netherlands': 'NL', 'türkiye': 'TR', 'turkiye': 'TR', 'czechia': 'CZ',
- 'russian federation': 'RU', 'viet nam': 'VN', 'korea': 'KR',
- 'republic of korea': 'KR', 'uae': 'AE', 'uk': 'GB', 'usa': 'US',
- 'democratic republic of the congo': 'CD', 'congo': 'CD',
-
- // Short codes (some nodes return these)
- 'us': 'US', 'de': 'DE', 'fr': 'FR', 'gb': 'GB', 'nl': 'NL', 'ca': 'CA',
- 'jp': 'JP', 'sg': 'SG', 'au': 'AU', 'br': 'BR', 'in': 'IN', 'kr': 'KR',
- 'tr': 'TR', 'ro': 'RO', 'pl': 'PL', 'es': 'ES', 'it': 'IT', 'se': 'SE',
- 'no': 'NO', 'fi': 'FI', 'ch': 'CH', 'at': 'AT', 'ie': 'IE', 'pt': 'PT',
- 'cz': 'CZ', 'hu': 'HU', 'bg': 'BG', 'gr': 'GR', 'ua': 'UA', 'ru': 'RU',
- 'hk': 'HK', 'tw': 'TW', 'th': 'TH', 'vn': 'VN', 'id': 'ID', 'ph': 'PH',
- 'mx': 'MX', 'ar': 'AR', 'cl': 'CL', 'co': 'CO', 'za': 'ZA', 'il': 'IL',
- 'ae': 'AE', 'ng': 'NG', 'lv': 'LV', 'lt': 'LT', 'ee': 'EE', 'hr': 'HR',
- 'rs': 'RS', 'dk': 'DK', 'be': 'BE', 'lu': 'LU', 'mt': 'MT', 'cy': 'CY',
- 'is': 'IS', 'nz': 'NZ', 'my': 'MY', 'bd': 'BD', 'pk': 'PK', 'eg': 'EG',
- 'ke': 'KE', 'ma': 'MA', 'pe': 'PE', 've': 'VE', 'ge': 'GE', 'gt': 'GT',
- 'pr': 'PR', 'cn': 'CN', 'sa': 'SA', 'kz': 'KZ', 'mn': 'MN', 'sk': 'SK',
- 'al': 'AL', 'md': 'MD', 'jm': 'JM', 'bo': 'BO', 'ec': 'EC', 'uy': 'UY',
- 'bh': 'BH', 'cd': 'CD', 'kg': 'KG', 'uz': 'UZ', 'tj': 'TJ',
-});
+export const COUNTRY_MAP = Object.freeze({ ...SDK_COUNTRY_MAP, ...TESTER_COUNTRY_EXTRAS });
/**
* Convert a country name to ISO 3166-1 alpha-2 code.
* Handles standard names, chain variants, and short codes.
+ * Uses tester's superset map (SDK + extras) — falls through to SDK on miss.
*/
export function countryNameToCode(name) {
if (!name) return null;
@@ -70,25 +36,11 @@ export function countryNameToCode(name) {
for (const [key, code] of Object.entries(COUNTRY_MAP)) {
if (key.length > 2 && (lower.includes(key) || key.includes(lower))) return code;
}
- return null;
+ return sdkCountryNameToCode(name);
}
-/**
- * Get flag image URL from flagcdn.com (for native apps where emoji doesn't render).
- */
-export function getFlagUrl(code, width = 40) {
- if (!code || code.length !== 2) return '';
- return `https://flagcdn.com/w${width}/${code.toLowerCase()}.png`;
-}
-
-/**
- * Get emoji flag for a country code (for web/browser).
- */
-export function getFlagEmoji(code) {
- if (!code || code.length !== 2) return '';
- const upper = code.toUpperCase();
- return String.fromCodePoint(upper.charCodeAt(0) + 0x1F1A5, upper.charCodeAt(1) + 0x1F1A5);
-}
+export const getFlagUrl = sdkGetFlagUrl;
+export const getFlagEmoji = sdkGetFlagEmoji;
// ─── Country Code → Continent Map ──────────────────────────────────────────
diff --git a/core/db.js b/core/db.js
index 3729146..c4c8c48 100644
--- a/core/db.js
+++ b/core/db.js
@@ -274,7 +274,8 @@ function runMigrations(db) {
if (current < 7) {
db.transaction(() => {
- // ── Migration v7: rename legacy runs.mode='dry' to 'test' ────────────────
+ // ── 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();
})();
@@ -580,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
@@ -1012,8 +1013,7 @@ export function searchErrors({ q = null, stage = null, limit = 100, offset = 0 }
el.error_message,
el.log_snippet,
el.captured_at,
- r.run_id,
- r.iteration
+ r.run_id
FROM error_logs el
INNER JOIN results r ON el.result_id = r.id
${where}
diff --git a/core/errors.js b/core/errors.js
index caab52e..f6d14cd 100644
--- a/core/errors.js
+++ b/core/errors.js
@@ -10,7 +10,7 @@ import {
SentinelError, ValidationError, NodeError, ChainError as SdkChainError,
TunnelError as SdkTunnelError, SecurityError,
ErrorCodes, ERROR_SEVERITY, isRetryable, userMessage,
-} from 'sentinel-dvpn-sdk';
+} from 'blue-js-sdk';
// Re-export SDK error utilities for consumers
export { ErrorCodes, ERROR_SEVERITY, isRetryable, userMessage, SentinelError, ValidationError, NodeError, SecurityError };
diff --git a/core/sdk-verify.js b/core/sdk-verify.js
index bc34eae..7c53c4f 100644
--- a/core/sdk-verify.js
+++ b/core/sdk-verify.js
@@ -1,7 +1,7 @@
/**
* Sentinel Node Tester — SDK Verification
*
- * Verifies that the installed copies of `sentinel-dvpn-sdk` (Blue JS) and
+ * Verifies that the installed copies of `blue-js-sdk` (Blue JS) and
* `@sentinel-official/sentinel-js-sdk` (TKD) match the published GitHub
* source at their current tag.
*
@@ -34,7 +34,7 @@ import { Readable } from 'stream';
// ─── Config ──────────────────────────────────────────────────────────────────
const TRACKED_SDKS = [
- { key: 'blue-js', pkg: 'sentinel-dvpn-sdk' },
+ { key: 'blue-js', pkg: 'blue-js-sdk' },
{ key: 'tkd-js', pkg: '@sentinel-official/sentinel-js-sdk' },
];
diff --git a/core/session.js b/core/session.js
index ea1bce8..4891b91 100644
--- a/core/session.js
+++ b/core/session.js
@@ -7,46 +7,44 @@
* credential cache (local disk), session map (audit TTL), dedup guard.
*/
-import { readFileSync, writeFileSync, existsSync } from 'fs';
-import { DENOM, GIGS, CREDS_FILE, SESSION_MAP_TTL, V3_MSG_TYPE } from './constants.js';
+import { DENOM, GIGS, SESSION_MAP_TTL, V3_MSG_TYPE } from './constants.js';
import { signAndBroadcastRetry, assertIsDeliverTxSuccess } from './wallet.js';
-import {
- getActiveLcd, getRpcClient,
- encodeRpcVarint, encodeRpcVarintField, encodeRpcBytes, encodeRpcEmbedded,
- concatBytes, decodeRpcProto, decodeRpcString,
-} from './chain.js';
+import { getActiveLcd, getRpcClient } from './chain.js';
import { sleep } from '../protocol/speedtest.js';
import {
extractAllSessionIds as sdkExtractAllSessionIds,
- markSessionPoisoned as sdkMarkPoisoned,
- isSessionPoisoned as sdkIsPoisoned,
- querySessions as sdkQuerySessions,
- querySessionAllocation as sdkQueryAllocation,
-} from 'sentinel-dvpn-sdk';
-
-// ─── Session Credential Cache (disk-persistent) ─────────────────────────────
-let credentialCache = {};
-if (existsSync(CREDS_FILE)) {
- try { credentialCache = JSON.parse(readFileSync(CREDS_FILE, 'utf8')); } catch { credentialCache = {}; }
-}
+ rpcQuerySessionsForAccount,
+ rpcQuerySession as sdkRpcQuerySession,
+ saveCredentials as sdkSaveCredentials,
+ loadCredentials as sdkLoadCredentials,
+ clearCredentials as sdkClearCredentials,
+ clearAllCredentials as sdkClearAllCredentials,
+} from 'blue-js-sdk';
+
+// ─── Session Credential Cache (SDK encrypted store: ~/.sentinel-sdk/) ──────
+// Tester API stays { saveCredential, getCredential, clearCredential, clearAllCredentials }
+// — call sites pass a flat object containing `sessionId` + credential fields. We unpack
+// sessionId here and delegate to SDK's encrypted, pruned, sessionId-keyed credential store.
export function saveCredential(nodeAddr, data) {
- credentialCache[nodeAddr] = { ...data, savedAt: new Date().toISOString() };
- writeFileSync(CREDS_FILE, JSON.stringify(credentialCache, null, 2), 'utf8');
+ const { sessionId, ...creds } = data || {};
+ sdkSaveCredentials(nodeAddr, String(sessionId || ''), creds);
}
export function getCredential(nodeAddr) {
- return credentialCache[nodeAddr] || null;
+ const stored = sdkLoadCredentials(nodeAddr);
+ if (!stored) return null;
+ // Caller-side accesses .sessionId / .type / .uuid / .wgPrivateKey / etc. flat.
+ // SDK already returns flat shape with sessionId at top level — pass through.
+ return stored;
}
export function clearCredential(nodeAddr) {
- delete credentialCache[nodeAddr];
- writeFileSync(CREDS_FILE, JSON.stringify(credentialCache, null, 2), 'utf8');
+ sdkClearCredentials(nodeAddr);
}
export function clearAllCredentials() {
- credentialCache = {};
- writeFileSync(CREDS_FILE, '{}', 'utf8');
+ sdkClearAllCredentials();
}
// ─── Session Reuse Map ──────────────────────────────────────────────────────
@@ -92,93 +90,18 @@ export function addToSessionMap(nodeAddr, sessionId) {
sessionMap.set(nodeAddr, { sessionId, maxBytes: GIGS * 1_000_000_000, usedBytes: 0 });
}
-// ─── RPC Session Decoder ────────────────────────────────────────────────────
-// BaseSession proto fields (sentinel.session.v3.BaseSession):
-// 1=id(uint64), 2=acc_address(string), 3=node_address(string),
-// 4=download_bytes(string), 5=upload_bytes(string), 6=max_bytes(string),
-// 7=duration, 8=max_duration, 9=status(enum), 10=inactive_at, 11=start_at, 12=status_at
-// Session wrappers: field 1 = base_session (embedded)
-
-function decodeRpcBaseSession(buf) {
- const f = decodeRpcProto(buf);
- return {
- id: f[1]?.[0] ? f[1][0].value : 0n,
- acc_address: f[2]?.[0] ? decodeRpcString(f[2][0].value) : '',
- node_address: f[3]?.[0] ? decodeRpcString(f[3][0].value) : '',
- download_bytes: f[4]?.[0] ? decodeRpcString(f[4][0].value) : '0',
- upload_bytes: f[5]?.[0] ? decodeRpcString(f[5][0].value) : '0',
- max_bytes: f[6]?.[0] ? decodeRpcString(f[6][0].value) : '0',
- status: f[9]?.[0] ? Number(f[9][0].value) : 0,
- };
-}
-
-function decodeRpcSession(anyBuf) {
- // Unwrap google.protobuf.Any: field 1 = type_url, field 2 = value
- const anyFields = decodeRpcProto(anyBuf);
- const innerBuf = anyFields[2]?.[0]?.value;
- if (!innerBuf) return null;
- // Session wrapper: field 1 = base_session (embedded)
- const sessionFields = decodeRpcProto(innerBuf);
- if (!sessionFields[1]?.[0]) return null;
- return decodeRpcBaseSession(sessionFields[1][0].value);
-}
+// ─── RPC Session Fetch (delegates to SDK) ──────────────────────────────────
+// SDK's rpcQuerySessionsForAccount handles ABCI encoding + Any-unwrapping +
+// BaseSession decoding. Sentinel v3 truncates at `limit` without emitting
+// next_key, so a single large request returns the full set.
-/**
- * Fetch sessions for account via RPC (ABCI query) with pagination.
- * Returns array of decoded session objects, or null if RPC unavailable.
- */
async function rpcFetchAllSessions(walletAddress) {
const client = await getRpcClient();
if (!client) return null;
-
- const allSessions = [];
- let nextKeyBytes = null;
- let page = 0;
- const PAGE_SIZE = 100;
- const MAX_PAGES = 20;
-
try {
- do {
- const paginationParts = [];
- if (nextKeyBytes) {
- paginationParts.push(encodeRpcBytes(1, nextKeyBytes));
- }
- paginationParts.push(encodeRpcVarintField(3, PAGE_SIZE));
-
- const request = concatBytes([
- encodeRpcBytes(1, new TextEncoder().encode(walletAddress)), // address (string = field 1)
- encodeRpcEmbedded(2, concatBytes(paginationParts)), // pagination
- ]);
-
- const result = await client.queryClient.queryAbci(
- '/sentinel.session.v3.QueryService/QuerySessionsForAccount',
- request,
- );
- const resp = new Uint8Array(result.value);
- if (resp.length <= 2) break; // empty pagination-only response
-
- const fields = decodeRpcProto(resp);
-
- // Field 1 = repeated google.protobuf.Any (sessions)
- const sessions = (fields[1] || []).map(entry => decodeRpcSession(entry.value)).filter(Boolean);
- allSessions.push(...sessions);
- page++;
-
- // Extract pagination response (field 2) for next_key
- nextKeyBytes = null;
- if (fields[2]?.[0]) {
- const pagResp = decodeRpcProto(fields[2][0].value);
- if (pagResp[1]?.[0]?.value?.length > 0) {
- nextKeyBytes = pagResp[1][0].value;
- }
- }
-
- if (sessions.length < PAGE_SIZE) break;
- } while (nextKeyBytes && page < MAX_PAGES);
-
- return allSessions;
+ return await rpcQuerySessionsForAccount(client, walletAddress, { limit: 2000 });
} catch {
- return null; // signal LCD fallback
+ return null;
}
}
@@ -333,6 +256,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 +267,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 +300,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 +341,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)`;
@@ -458,24 +391,14 @@ export async function waitForBatchSessions(nodeAddrs, walletAddr, maxWaitMs = 20
}
/**
- * Query a single session by ID via RPC.
- * Returns decoded base session or null.
+ * Query a single session by ID via RPC. Delegates to SDK.
+ * Returns decoded session object or null.
*/
async function rpcQuerySession(sessionId) {
const client = await getRpcClient();
if (!client) return null;
try {
- const request = encodeRpcVarintField(1, Number(sessionId));
- const result = await client.queryClient.queryAbci(
- '/sentinel.session.v3.QueryService/QuerySession',
- request,
- );
- const resp = new Uint8Array(result.value);
- if (resp.length <= 2) return null;
- const fields = decodeRpcProto(resp);
- // Field 1 = google.protobuf.Any (session)
- if (!fields[1]?.[0]) return null;
- return decodeRpcSession(fields[1][0].value);
+ return await sdkRpcQuerySession(client, sessionId);
} catch {
return null;
}
diff --git a/core/transport-cache.js b/core/transport-cache.js
index 5ced6a6..070eb50 100644
--- a/core/transport-cache.js
+++ b/core/transport-cache.js
@@ -10,8 +10,21 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import path from 'path';
+import { recordTransportResult } from 'blue-js-sdk';
import { RESULTS_DIR } from './constants.js';
+// SDK rate-key format mirrors connection/tunnel.js _transportRateKey:
+// network=='grpc' or security=='tls' get suffix 'tls' (or special 'grpc/none'),
+// otherwise just the network. Tester records protocol-aware key locally and
+// dual-writes a normalized key into the SDK rate cache so embedded SDK calls
+// benefit from tester's broad-coverage observations.
+function sdkTransportKey(network, security) {
+ if (!network) return null;
+ if (security === 'tls') return `${network}/tls`;
+ if (network === 'grpc') return 'grpc/none';
+ return network;
+}
+
// ─── Cache File ──────────────────────────────────────────────────────────────
const CACHE_FILE = path.join(RESULTS_DIR, 'transport-cache.json');
@@ -68,6 +81,11 @@ export function recordTransportSuccess(nodeAddr, transport) {
cache.globalStats[key].attempts++;
cache.globalStats[key].rate = cache.globalStats[key].successes / cache.globalStats[key].attempts;
+ // Dual-write into SDK's getDynamicRate cache so embedded SDK code paths
+ // (setupV2Ray transport ordering) benefit from tester observations.
+ const sdkKey = sdkTransportKey(network, security);
+ if (sdkKey) try { recordTransportResult(sdkKey, true); } catch { }
+
dirty = true;
}
@@ -87,6 +105,9 @@ export function recordTransportFailure(transport) {
cache.globalStats[key].attempts++;
cache.globalStats[key].rate = cache.globalStats[key].successes / cache.globalStats[key].attempts;
+ const sdkKey = sdkTransportKey(network, security);
+ if (sdkKey) try { recordTransportResult(sdkKey, false); } catch { }
+
dirty = true;
}
diff --git a/core/types.js b/core/types.js
index 97e8364..e0a1a43 100644
--- a/core/types.js
+++ b/core/types.js
@@ -78,8 +78,16 @@
* @property {string|null} errorMessage
* @property {boolean} stopRequested
* @property {boolean} lowBalanceWarning
- * @property {boolean} economyMode
* @property {string|null} pauseReason - Why audit is paused (VPN interference, etc.)
+ * @property {'p2p'|'subscription'|'test'|null} runMode - Active run mode (persisted)
+ * @property {boolean} testRun - Skip-only TEST RUN demo flag (persisted)
+ * @property {string|number|null} runPlanId - Plan ID when runMode='p2p'
+ * @property {string|number|null} runSubscriptionId - Subscription ID when runMode='subscription'
+ * @property {string|null} runGranter - Fee-grant granter address (admin-only; never broadcast over public SSE)
+ * @property {string|null} pricingMode - Active pricing strategy
+ * @property {string|null} activeSDK - Active SDK identifier
+ * @property {boolean} continuousLoop - True when a continuous loop run is active
+ * @property {boolean} broadcastLive - When true, public SSE forwards live events; when false, public sees last-completed snapshot only
*/
export {};
diff --git a/core/wallet.js b/core/wallet.js
index 571d052..bf0eb80 100644
--- a/core/wallet.js
+++ b/core/wallet.js
@@ -15,9 +15,10 @@ import {
buildRegistry,
broadcast as sdkBroadcast,
createSafeBroadcaster,
+ clearWalletCache,
RPC_ENDPOINTS as SDK_RPC_ENDPOINTS,
SDK_VERSION,
-} from 'sentinel-dvpn-sdk';
+} from 'blue-js-sdk';
import { RPC_ENDPOINTS as LOCAL_RPC_ENDPOINTS, GAS_PRICE as GAS_PRICE_STR } from './constants.js';
// Use SDK RPC endpoints (5 endpoints), fall back to local constants
@@ -82,6 +83,7 @@ export async function getOrReconnectClient() {
export async function forceReconnect() {
if (_managedClient) { try { _managedClient.disconnect(); } catch { } }
_managedClient = null;
+ clearWalletCache();
_activeRpcIdx = (_activeRpcIdx + 1) % RPC_LIST.length;
return getOrReconnectClient();
}
diff --git a/dictator.html b/dictator.html
deleted file mode 100644
index 23902f1..0000000
--- a/dictator.html
+++ /dev/null
@@ -1,944 +0,0 @@
-
-
-
-
-
-
- Dictator Mode — Sentinel Network Audit
-
-
-
-
-
-
-
-
-
-
- Dictator Mode
- Sentinel Network · Google Accessibility Audit
-
-
-
- WINDOWS
-
-
- JS SDK
-
-
-
-
-
- Live
-
-
- ← Dashboard
-
-
-
-
-
-
-
- Countries by Google Accessibility
-
-
-
-
-
-
- Loading audit data
-
-
-
-
- Copied
-
-
-
-
-
diff --git a/docs/ARCHITECTURE-PUBLIC-LIVE.md b/docs/ARCHITECTURE-PUBLIC-LIVE.md
index 3ed680d..41686f4 100644
--- a/docs/ARCHITECTURE-PUBLIC-LIVE.md
+++ b/docs/ARCHITECTURE-PUBLIC-LIVE.md
@@ -55,9 +55,9 @@ One database: `audit.db`. All runs — live and dry — write to it.
- Normal audit run: `mode='live'` (or absent/null for legacy rows).
- 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-test.db` no longer exists.
+- `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.
@@ -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-test.db` — superseded by the `mode='test'` 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/docs/FRONTEND-SPEC.md b/docs/FRONTEND-SPEC.md
index 8fb34a0..b610c66 100644
--- a/docs/FRONTEND-SPEC.md
+++ b/docs/FRONTEND-SPEC.md
@@ -757,8 +757,6 @@ Integrator equivalent:
| GET | `/api/sdk` | Get SDK | Not used by frontend | `{ sdk }` |
| GET | `/api/dns` | Get DNS config | DNS startup loader | `{ servers, presets }` |
| POST | `/api/dns` | Set DNS | setDNS() | `{ ok, servers, preset }` |
-| GET | `/dictator` | Dictator page | Link in header | HTML |
-| GET | `/api/dictator` | Dictator data | Not used by main frontend | `{ sdk, countries, ... }` |
| GET | `/health` | Health check | Not used by frontend | `{ status, uptime }` |
---
@@ -944,9 +942,7 @@ Integrator equivalent:
5. **Low balance warning** -- `state.lowBalanceWarning` is set by server but never shown in frontend.
-6. **Dictator Mode link** -- EXISTS in header but leads to separate `dictator.html` (not part of main dashboard code review).
-
-7. **Moniker display** -- Result rows show truncated address, not moniker. The moniker field exists in results but is unused in the table.
+6. **Moniker display** -- Result rows show truncated address, not moniker. The moniker field exists in results but is unused in the table.
---
@@ -963,7 +959,6 @@ These exist in server.js but have no corresponding frontend function:
| `POST /api/auto-retest` | Server-side filtered retest | Frontend uses client-side filtered retest-fails |
| `GET /api/runs/:num` | Get run details without loading | Frontend loads runs directly |
| `GET /api/sdk` | Get current SDK | Frontend syncs via SSE state |
-| `GET /api/dictator` | Dictator data | Used by dictator.html, not main dashboard |
| `GET /health` | Health check | For monitoring, not UI |
---
diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md
index 78fa1e8..59ba770 100644
--- a/docs/INTEGRATION.md
+++ b/docs/INTEGRATION.md
@@ -191,7 +191,7 @@ es.addEventListener('state', e => {
```
┌─ Header ──────────────────────────────────────────────────────┐
-│ [Logo] SENTINEL AUDIT [JS|C#] [DNS▼] [Dictator Mode] │
+│ [Logo] SENTINEL AUDIT [JS|C#] [DNS▼] │
├─ Stats ───────────────────────────────────────────────────────┤
│ Total: 1002 | Tested: 975 | Remaining: 27 | Rate: 97.2% │
│ [New Test] [Resume] [Rescan] [Retest Failed] [Stop] [Economy] │
diff --git a/docs/TECHNICAL-BLUEPRINT.md b/docs/TECHNICAL-BLUEPRINT.md
index 9e97436..826d71f 100644
--- a/docs/TECHNICAL-BLUEPRINT.md
+++ b/docs/TECHNICAL-BLUEPRINT.md
@@ -472,7 +472,7 @@ testWithRetry(testFn, broadcast, state, nodeAddr):
## Dashboard UI (index.html)
### Layout Sections
-1. **Header:** Logo, Windows badge, SDK toggle (JS/C#), DNS dropdown, Dictator Mode link
+1. **Header:** Logo, Windows badge, SDK toggle (JS/C#), DNS dropdown
2. **Stats grid:** Total, Tested, Remaining, Pass Rate, >10Mbps, Balance
3. **Controls:** New Test, Resume, Rescan, Retest Failed, Stop, Economy, Plan Test, Reset
4. **Speed history:** Last 10 speeds as color-coded pills (green ≥15, yellow ≥5, red <5)
diff --git a/docs/UX-FEATURE-PARITY.md b/docs/UX-FEATURE-PARITY.md
index 68b6b92..78e3a18 100644
--- a/docs/UX-FEATURE-PARITY.md
+++ b/docs/UX-FEATURE-PARITY.md
@@ -16,7 +16,6 @@ Some features only apply to the standalone CLI/Browser tester. When embedded in
| Mnemonic in .env | ✓ Required | ✗ App manages auth | App handles credentials |
| SentinelAudit.vbs | ✓ Required | ✗ App has own launcher | App handles elevation |
| Port 3001 | ✓ Default | Configurable or embedded | May conflict with app |
-| Dictator Mode link | ✓ Optional | ✗ Remove | App-specific feature |
| Economy mode | ✓ Optional | ✓ Keep | Users want to limit spending |
| Plan Test | ✓ Optional | Only if app uses plans | Depends on app type |
diff --git a/easy.js b/easy.js
index b60ef0a..1097cf1 100644
--- a/easy.js
+++ b/easy.js
@@ -17,7 +17,7 @@
*/
import { getAllNodes, queryNodeStatusDirect, cleanupRpc } from './core/chain.js';
-import { cachedWalletSetup, createFreshClient, buildV3Registry } from './core/wallet.js';
+import { cachedWalletSetup, createFreshClient } from './core/wallet.js';
import { findExistingSession, submitBatchPayment, clearPaidNodes, buildSessionMap } from './core/session.js';
import { testNode } from './audit/node-test.js';
import { testWithRetry } from './audit/retry.js';
diff --git a/index.html b/index.html
index 03ae20d..bacfd61 100644
--- a/index.html
+++ b/index.html
@@ -535,7 +535,6 @@ SENTINEL AUDIT
- Dictator Mode
@@ -569,14 +568,13 @@ SENTINEL AUDIT
-
-
+
@@ -883,18 +881,16 @@ 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');
}
- if (state.economyMode != null) updateEconomyBtn(state.economyMode);
-
// Sync SDK toggle with server state (don't override user's click)
if (state.activeSDK && state.activeSDK !== activeSDK) {
setSDK(state.activeSDK);
@@ -1235,34 +1231,6 @@ SENTINEL AUDIT
if (body.children.length > 500) body.removeChild(body.firstChild);
}
- async function toggleEconomy() {
- try {
- const res = await fetch('/api/economy', { method: 'POST' });
- const data = await res.json();
- updateEconomyBtn(data.economyMode);
- } catch {}
- }
-
- function updateEconomyBtn(on) {
- const btn = document.getElementById('btnEconomy');
- if (on) {
- btn.style.background = 'rgba(0,200,83,0.15)';
- btn.style.borderColor = 'var(--accent-green)';
- btn.style.color = 'var(--accent-green)';
- btn.textContent = '♻ Economy ON';
- } else {
- btn.style.background = 'transparent';
- btn.style.borderColor = 'rgba(255,255,255,0.15)';
- btn.style.color = '#888';
- btn.textContent = '♻ Economy';
- }
- }
-
- // Sync economy state on page load
- fetch('/api/state').then(r => r.json()).then(d => {
- if (d.state?.economyMode != null) updateEconomyBtn(d.state.economyMode);
- }).catch(() => {});
-
// ─── Test Run Management ────────────────────────────────────────────────
async function loadRunsList() {
try {
@@ -1503,15 +1471,10 @@
- Stop Audit
+ Stop Test
Gracefully stops the current scan after the active node finishes. Results are saved. You can Resume later.
-
- Economy Mode
- Caps the number of nodes tested to what the wallet can afford. Prevents running out of P2P mid-scan.
-
-
Test via Plan (WIP)
Plan testing is under development. Currently hidden from the dashboard. Coming in a future release.
@@ -1571,7 +1534,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 b712959..fa7c238 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;
@@ -267,18 +302,35 @@
.table-wrap { overflow-y: auto; flex: 1 1 auto; min-height: 0; padding: 0 8px 8px 8px; margin-top: 8px; }
- table { width: 100%; border-collapse: separate; border-spacing: 0 4px; }
+ table { width: 100%; border-collapse: separate; border-spacing: 0 4px; table-layout: fixed; }
+ td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ td.td-num, th.th-num { font-variant-numeric: tabular-nums; text-align: right; }
+ td.td-moniker { max-width: 180px; }
+ td.td-error { max-width: 240px; }
+
+ .pager .btn-paginate {
+ background: var(--bg-input);
+ color: var(--text);
+ border: 1px solid var(--border);
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-size: 11px;
+ cursor: pointer;
+ }
+ .pager .btn-paginate:disabled { opacity: 0.4; cursor: not-allowed; }
+ .pager .btn-paginate:not(:disabled):hover { border-color: var(--border-hover); }
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;
}
@@ -460,7 +512,7 @@
position: absolute; right: -6px; bottom: -6px; z-index: 2;
width: 46px; height: 46px; border-radius: 50%;
background: var(--yellow);
- color: #000;
+ color: var(--bg);
display: flex; align-items: center; justify-content: center;
border: 3px solid var(--bg);
box-shadow: 0 0 0 0 rgba(255, 200, 0, 0.45);
@@ -563,18 +615,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 +708,12 @@ dVPN Node Test
Current Batch
-
0 / 0 nodes tested
-
Batch size: —
@@ -693,6 +758,11 @@ dVPN Node Test
+
@@ -732,7 +802,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 +840,45 @@ 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();
+ const _liveResultsPrev = document.getElementById('liveResultsPrev');
+ const _liveResultsNext = document.getElementById('liveResultsNext');
+ if (_liveResultsPrev) _liveResultsPrev.addEventListener('click', () => liveGoPage(-1));
+ if (_liveResultsNext) _liveResultsNext.addEventListener('click', () => liveGoPage(1));
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 +891,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 +910,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() {
@@ -834,7 +975,8 @@ dVPN Node Test
// ─── State ───
let resultsArr = [];
let activeFilter = 'all';
- const MAX_ROWS = 500;
+ const LIVE_PAGE_SIZE = 50;
+ let _livePage = 1;
const MAX_LOG = 400;
// ─── Helpers ───
@@ -843,6 +985,19 @@ dVPN Node Test
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
+ function fmtUtc(ts) {
+ if (!ts) return '';
+ const n = Number(ts);
+ const ms = Number.isFinite(n) ? (n < 1e12 ? n * 1000 : n) : new Date(ts).getTime();
+ if (!Number.isFinite(ms)) return '';
+ const d = new Date(ms);
+ const yyyy = d.getUTCFullYear();
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(d.getUTCDate()).padStart(2, '0');
+ const hh = String(d.getUTCHours()).padStart(2, '0');
+ const mi = String(d.getUTCMinutes()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`;
+ }
function shortAddr(a) {
if (!a) return '--';
return a.length > 22 ? a.slice(0, 12) + '…' + a.slice(-5) : a;
@@ -888,6 +1043,7 @@ dVPN Node Test
// ─── Filter ───
function setFilter(f) {
activeFilter = f;
+ _livePage = 1;
for (const id of ['filterAll','filterPass','filterFail','filterSlow','filterWg','filterV2']) {
const el = document.getElementById(id);
if (el) {
@@ -915,30 +1071,66 @@ dVPN Node Test
// ─── Table render ───
function renderTable() {
const tbody = document.getElementById('resultsBody');
+ const wrap = tbody.closest('.table-wrap');
+ const prevScroll = wrap ? wrap.scrollTop : 0;
tbody.innerHTML = '';
- const filtered = applyFilter(resultsArr);
- if (filtered.length === 0) {
+ const filtered = applyFilter(resultsArr).slice().reverse();
+ const total = filtered.length;
+ const totalPages = Math.max(1, Math.ceil(total / LIVE_PAGE_SIZE));
+ if (_livePage > totalPages) _livePage = totalPages;
+ const start = (_livePage - 1) * LIVE_PAGE_SIZE;
+ const slice = filtered.slice(start, start + LIVE_PAGE_SIZE);
+ if (total === 0) {
tbody.innerHTML = 'No results match current filter. ';
} else {
- filtered.slice(-MAX_ROWS).reverse().forEach(r => appendRowHtml(r, tbody));
+ slice.forEach(r => appendRowHtml(r, tbody));
}
const label = activeFilter === 'all'
? resultsArr.length + ' Entries'
: filtered.length + '/' + resultsArr.length + ' (' + activeFilter.toUpperCase() + ')';
document.getElementById('resultsCountLabel').textContent = label;
+ renderLivePager(total, totalPages);
+ if (wrap) wrap.scrollTop = prevScroll;
+ }
+
+ function renderLivePager(total, totalPages) {
+ const pager = document.getElementById('liveResultsPager');
+ if (!pager) return;
+ if (totalPages <= 1) { pager.style.display = 'none'; return; }
+ pager.style.display = 'flex';
+ const startIdx = (_livePage - 1) * LIVE_PAGE_SIZE + 1;
+ const endIdx = Math.min(_livePage * LIVE_PAGE_SIZE, total);
+ document.getElementById('liveResultsPageInfo').textContent =
+ `Page ${_livePage} of ${totalPages} — ${startIdx}–${endIdx} of ${total}`;
+ document.getElementById('liveResultsPrev').disabled = _livePage <= 1;
+ document.getElementById('liveResultsNext').disabled = _livePage >= totalPages;
+ }
+
+ function liveGoPage(delta) {
+ const filtered = applyFilter(resultsArr);
+ const totalPages = Math.max(1, Math.ceil(filtered.length / LIVE_PAGE_SIZE));
+ const next = Math.min(totalPages, Math.max(1, _livePage + delta));
+ if (next === _livePage) return;
+ _livePage = next;
+ renderTable();
}
function addSingleRow(r) {
- const tbody = document.getElementById('resultsBody');
- // Clear placeholder row if present
- const placeholder = tbody.querySelector('tr td[colspan]');
- if (placeholder) tbody.innerHTML = '';
- for (const row of tbody.querySelectorAll('tr')) {
- if (row.dataset.addr === r.address) { row.remove(); break; }
+ // Re-render current page only when the affected row falls on the visible page;
+ // otherwise just bump the count label.
+ const filtered = applyFilter(resultsArr).slice().reverse();
+ const idxInFiltered = filtered.findIndex(x => x.address === r.address);
+ const onVisiblePage = idxInFiltered >= 0
+ && idxInFiltered >= (_livePage - 1) * LIVE_PAGE_SIZE
+ && idxInFiltered < _livePage * LIVE_PAGE_SIZE;
+ if (onVisiblePage) {
+ renderTable();
+ } else {
+ document.getElementById('resultsCountLabel').textContent = resultsArr.length + ' Entries';
+ const total = filtered.length;
+ const totalPages = Math.max(1, Math.ceil(total / LIVE_PAGE_SIZE));
+ renderLivePager(total, totalPages);
}
- appendRowHtml(r, tbody, true);
- document.getElementById('resultsCountLabel').textContent = resultsArr.length + ' Entries';
- if (tbody.children.length > MAX_ROWS) tbody.removeChild(tbody.lastChild);
}
function appendRowHtml(r, tbody, prepend = false) {
@@ -962,8 +1154,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,18 +1186,19 @@ dVPN Node Test
const isFail = r.actualMbps == null && !r.skipped;
const copyCell = isFail && addrFull
- ? ``
+ ? `
+ `
: '—';
tr.innerHTML = `
${proto} ${transportHtml}
- ${escHtml(monikerText)}
+ ${escHtml(monikerText)}
${addr}
${countryCol}
${cityCol}
- ${peersHtml}
- ${act}
- ${blAt}
+ ${peersHtml}
+ ${act}
+ ${blAt}
${badge}
${copyCell}
`;
@@ -1102,6 +1299,7 @@ dVPN Node Test
if (o.cat === 'fail' && o.addr) {
parts.push(``);
+ parts.push(``);
}
div.innerHTML = parts.join('');
@@ -1208,7 +1406,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 +1422,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 isTestRun = !!(_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 = isTestRun ? '' : 'none';
+ const testRunBanner = document.getElementById('testRunBanner');
+ if (testRunBanner) testRunBanner.classList.toggle('is-visible', isTestRun);
+
+ // 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 (isTestRun) {
+ 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 (isTestRun) {
+ 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 (isTestRun) {
+ 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 (isTestRun) {
+ 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 +1552,8 @@ dVPN Node Test
setInterval(() => {
if (_cb.startedAt && _cb.tested >= 0) cbRender();
- else renderLiveStats();
+ renderLiveStats();
+ applyHeaderStatsFromState();
}, 5000);
// ─── Upsert ───
@@ -1317,6 +1562,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 +1724,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 +1769,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 +1792,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,6 +1809,7 @@ 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; test-run fanout uses camelCase.
const addr = d.node_address ?? d.address ?? null;
@@ -1424,7 +1869,7 @@ dVPN Node Test
errorCode: err || null,
skipped,
inPlan: d.inPlan === true,
- testRun: err === 'TEST_RUN_SKIP',
+ testRun: err === 'TEST_RUN_SKIP',
testedAt: tested,
});
cbApplyNodeResult({ passed: resultMbps != null && resultMbps >= 10 });
@@ -1456,7 +1901,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,18 +1913,137 @@ 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;
}
}
+ let _sseEverConnected = false;
function connectSSE() {
if (!_broadcastLive) return;
if (_sse) { try { _sse.close(); } catch (_) {} }
@@ -1488,7 +2055,15 @@ dVPN Node Test
if (!d || !d.type) return;
try { handleEvent(d); } catch (_) {}
};
- _sse.onopen = () => { _sseRetry = 1000; };
+ _sse.onopen = () => {
+ _sseRetry = 1000;
+ // On reconnect, re-fetch current batch via REST so the table doesn't
+ // sit on stale data until the next live event arrives.
+ if (_sseEverConnected) {
+ try { loadCurrentBatch(); } catch (_) {}
+ }
+ _sseEverConnected = true;
+ };
_sse.onerror = () => {
try { _sse.close(); } catch (_) {}
_sse = null;
@@ -1518,6 +2093,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 +2120,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;
@@ -1564,6 +2155,15 @@ dVPN Node Test
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 (_) {}
}
@@ -1597,7 +2197,7 @@ dVPN Node Test
'Stage: ' + (er.stage || '—'),
'Error Code: ' + (er.error_code || '—'),
'Message: ' + (er.error_message || '—'),
- 'Captured: ' + (er.captured_at || '—'),
+ 'Captured: ' + (er.captured_at ? fmtUtc(er.captured_at) : '—'),
'',
];
if (er.log_snippet) { lines.push('Log Snippet:'); lines.push(er.log_snippet); }
@@ -1619,6 +2219,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 ? fmtUtc(er.captured_at) : '—';
+ 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') + '';
+ }
+ }
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 +2642,7 @@
Plan ${p.planId}
${badge}
+ ${allocBadge}
Select plan…'];
- _subPlanCache.plans.forEach(p => {
- const usable = p.feeGrantActive && p.nodeCount > 0 && p.ownerSentAddr;
- const label = `Plan ${p.planId} — ${p.nodeCount} nodes${p.feeGrantActive ? '' : ' • no fee grant'}`;
- opts.push(``);
- });
- sel.innerHTML = opts.join('');
+ // Server accepted — clean canvas for the new run.
+ const _logBody = document.getElementById('logBody');
+ if (_logBody) _logBody.innerHTML = '';
+ resultsArr = [];
+ state.testedNodes = 0;
+ state.failedNodes = 0;
+ state.totalNodes = 0;
+ state.passed10 = 0;
+ state.passed15 = 0;
+ state.passedBaseline = 0;
+ state.nodeSpeedHistory = [];
+ state.baselineHistory = [];
+ renderTable();
+ applyState();
} catch (err) {
- sel.innerHTML = ``;
+ alert('Sub. Plan request failed: ' + err.message);
}
}
- function onSubPlanInlineChange() { /* selection stored via select.value; Start reads it */ }
-
- async function startCreateMode() {
- const btn = document.getElementById('btnCreateStart');
- const dryEl = document.getElementById('testRunDev');
- if (dryEl && dryEl.checked) {
- if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; }
- await devStart(true);
- return;
- }
- if (_createMode === 'p2p') {
- startAudit();
- return;
- }
- const sel = document.getElementById('subPlanInlineSelect');
- const planId = sel ? sel.value : '';
- if (!planId) {
- alert('Pick a subscription plan first.');
- return;
- }
- const plan = _subPlanCache.plans.find(p => String(p.planId) === String(planId));
- if (!plan) { alert('Plan not found in cache. Reopen and try again.'); return; }
- if (!plan.feeGrantActive || plan.nodeCount === 0 || !plan.ownerSentAddr) {
- alert('This plan is not testable: ' + (
- !plan.ownerSentAddr ? 'plan owner unknown.'
- : plan.nodeCount === 0 ? 'no active nodes.'
- : 'plan owner has not granted gas to this wallet.'
- ));
- return;
- }
- if (btn) { btn.disabled = true; btn.classList.add('loading'); }
- try {
- await runSubPlanTest(plan.planId, plan.subscriptionId, plan.ownerSentAddr);
- } finally {
- if (btn) { btn.disabled = false; btn.classList.remove('loading'); }
- }
- }
+ // ─── Create-Mode shadow chooser removed ─────────────────────────────────
+ // The setCreateMode / startCreateMode / loadSubPlansInline / _createMode
+ // / _subPlanCache surface was an unreachable shadow of the live New-Test
+ // chooser flow. It had no DOM caller (the active flow goes through
+ // setTestingMode + startAudit / runSubPlanTest / devStart). Removed to
+ // eliminate drift between two parallel mode-picker code paths.
async function stopAudit() {
const btn = document.getElementById('btnStop');
@@ -2218,41 +2832,83 @@ Search failed: ' + e.message + '';
+ 'Search failed: ' + escHtml(e.message || String(e)) + ' ';
+ const pager = document.getElementById('nsPager');
+ if (pager) pager.innerHTML = '';
}
}
- function nsRenderResults(nodes) {
+ let _nsAllNodes = [];
+ let _nsPage = 1;
+ const NS_PAGE_SIZE = 50;
+
+ function nsRenderPage() {
const tbody = document.getElementById('nsBody');
- document.getElementById('nsResultCount').textContent = nodes.length ? nodes.length + ' nodes' : '';
- if (!nodes.length) {
+ const total = _nsAllNodes.length;
+ document.getElementById('nsResultCount').textContent = total ? total + ' nodes' : '';
+ if (!total) {
tbody.innerHTML = 'No nodes found. ';
+ const pager = document.getElementById('nsPager');
+ if (pager) pager.innerHTML = '';
return;
}
- tbody.innerHTML = nodes.map(function(n) {
- const addrShort = n.addr ? n.addr.slice(0, 14) + '…' + n.addr.slice(-5) : '—';
+ const totalPages = Math.max(1, Math.ceil(total / NS_PAGE_SIZE));
+ if (_nsPage > totalPages) _nsPage = totalPages;
+ const start = (_nsPage - 1) * NS_PAGE_SIZE;
+ const slice = _nsAllNodes.slice(start, start + NS_PAGE_SIZE);
+ tbody.innerHTML = slice.map(function(n) {
+ const addrFull = n.addr || '';
+ const addrShort = addrFull ? addrFull.slice(0, 14) + '…' + addrFull.slice(-5) : '—';
const lastTested = n.last_tested ? new Date(n.last_tested).toLocaleString() : '—';
const badge = n.last_pass == null ? 'N/A'
: n.last_pass ? 'PASS'
: 'FAIL';
const mbps = n.actual_mbps != null ? n.actual_mbps.toFixed(2) + ' Mbps' : '—';
- const sa = (n.addr || '').replace(/'/g, '');
- return ''
- + '' + (n.moniker || '—') + ' '
- + '' + addrShort + ' '
- + '' + (n.country || '—') + ' '
- + '' + lastTested + ' '
+ + ''
+ + '' + escHtml(addrShort) + ' '
+ + ''
+ + '' + escHtml(lastTested) + ' '
+ '' + badge + ' '
- + '' + mbps + ' '
+ + '' + mbps + ' '
+ ' ';
}).join('');
+ nsRenderPager(total, totalPages);
}
+ function nsRenderPager(total, totalPages) {
+ const pager = document.getElementById('nsPager');
+ if (!pager) return;
+ if (totalPages <= 1) { pager.innerHTML = ''; return; }
+ const start = (_nsPage - 1) * NS_PAGE_SIZE + 1;
+ const end = Math.min(_nsPage * NS_PAGE_SIZE, total);
+ pager.innerHTML =
+ ''
+ + 'Page ' + _nsPage + ' / ' + totalPages + ' · ' + start + '–' + end + ' of ' + total + ''
+ + '';
+ }
+
+ function nsGoPage(n) {
+ const totalPages = Math.max(1, Math.ceil(_nsAllNodes.length / NS_PAGE_SIZE));
+ _nsPage = Math.min(Math.max(1, n), totalPages);
+ nsRenderPage();
+ }
+ window.nsGoPage = nsGoPage;
+
+ function nsOpenDrawerFromRow(ev) {
+ const tr = ev.currentTarget || ev.target.closest('tr');
+ if (!tr) return;
+ nsOpenDrawer(tr.dataset.addr || '');
+ }
+ window.nsOpenDrawerFromRow = nsOpenDrawerFromRow;
+
async function nsOpenDrawer(addr) {
if (!addr) return;
_nsCurrentAddr = addr;
@@ -2276,15 +2932,15 @@ ' + (nd.addr || addr) + ''],
- ['Country', nd.country || '—'],
- ['City', nd.city || '—'],
- ['Service', nd.service_type || '—'],
- ['Status', nd.status || '—'],
- ['Last Tested', nd.last_tested ? new Date(nd.last_tested).toLocaleString() : '—'],
+ ['Address', '' + escHtml(nd.addr || addr) + ''],
+ ['Country', escHtml(nd.country || '—')],
+ ['City', escHtml(nd.city || '—')],
+ ['Service', escHtml(nd.service_type || '—')],
+ ['Status', escHtml(nd.status || '—')],
+ ['Last Tested', escHtml(nd.last_tested ? new Date(nd.last_tested).toLocaleString() : '—')],
].map(function(pair) {
return ''
- + '' + pair[0] + ''
+ + '' + escHtml(pair[0]) + ''
+ '' + pair[1] + '';
}).join('');
@@ -2371,7 +3027,7 @@ { 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 = _fmtCapturedUtc(er.captured_at);
+ 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..e9c71e7 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)
@@ -305,7 +323,9 @@ async function _resolveSnapshot() {
* Main loop body — runs until stopRequested or unrecoverable error.
*/
async function _runLoop() {
- _ctrl.running = true;
+ // _ctrl.running is set synchronously in start() before this is fire-and-forget
+ // invoked, so a second start() call cannot race past the `if (_ctrl.running)`
+ // guard while we're still on the microtask queue.
_ctrl.stopRequested = false;
_ctrl.startedAt = Date.now();
_ctrl.iteration = 0;
@@ -331,9 +351,19 @@ async function _runLoop() {
mode: _ctrl.mode,
});
- // Fresh pipeline state per iteration so counters reset cleanly
+ // Fresh pipeline state per iteration so counters reset cleanly.
+ // Inherit SDK / pricingMode / runMode from _ctrl so per-node failure
+ // rows record the correct sdk and pipeline branches the right way —
+ // without this, every continuous-loop result fell back to sdk:'js'.
const loopState = createState();
loopState.stopRequested = false;
+ if (_ctrl.activeSDK) loopState.activeSDK = _ctrl.activeSDK;
+ if (_ctrl.pricingMode) loopState.pricingMode = _ctrl.pricingMode;
+ loopState.runMode = _ctrl.mode || 'p2p';
+ loopState.runPlanId = _ctrl.planId || null;
+ loopState.runSubscriptionId = _ctrl.subscriptionId || null;
+ loopState.runGranter = _ctrl.subscriptionGranter || null;
+ loopState.testRun = !!_ctrl.testRun;
let currentBatchId = 0;
let frozenNodes = null;
@@ -648,6 +678,13 @@ export async function start(opts = {}) {
_ctrl.minDelayMs = minDelayMs;
_ctrl.lastError = null;
_ctrl.iteration = 0;
+ _ctrl.activeSDK = opts.activeSDK || _ctrl.activeSDK || 'js';
+ _ctrl.pricingMode = opts.pricingMode || _ctrl.pricingMode || null;
+ _ctrl.testRun = !!opts.testRun;
+ // Mark running synchronously BEFORE the fire-and-forget so a second
+ // start() call cannot slip through the `if (_ctrl.running)` guard while
+ // _runLoop is still on the microtask queue (the C-2 race window).
+ _ctrl.running = true;
_emitScoped('loop:started', {
mode,
diff --git a/audit/pipeline.js b/audit/pipeline.js
index 12eada6..eedde10 100644
--- a/audit/pipeline.js
+++ b/audit/pipeline.js
@@ -14,13 +14,38 @@ import {
} from '../core/constants.js';
import { cachedWalletSetup, createFreshClient, signAndBroadcastRetry } from '../core/wallet.js';
import { getAllNodes, fetchPlanMembership, ensureLcd, getActiveLcd, getRpcClient, rpcFetchAllNodesForPlanPaginated } from '../core/chain.js';
-import { rpcQueryNode } from 'sentinel-dvpn-sdk';
+import { rpcQueryNode } from 'blue-js-sdk';
import {
submitBatchPayment, waitForBatchSessions, waitForSessionActive,
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.
+const _stopWaiters = new Set();
+let _pipelineStopFlag = false;
+function sleep(ms) {
+ if (_pipelineStopFlag) return Promise.resolve();
+ return new Promise((resolve) => {
+ let timer = setTimeout(() => { _stopWaiters.delete(wake); resolve(); }, ms);
+ const wake = () => { try { clearTimeout(timer); } catch {} _stopWaiters.delete(wake); resolve(); };
+ _stopWaiters.add(wake);
+ });
+}
+export function triggerPipelineStop() {
+ _pipelineStopFlag = true;
+ for (const w of _stopWaiters) { try { w(); } catch {} }
+ _stopWaiters.clear();
+}
+export function resetPipelineStop() {
+ _pipelineStopFlag = false;
+}
+
// Platform-aware imports — Windows has full implementation, others get stubs
let WG_AVAILABLE, IS_ADMIN, emergencyCleanupSync, uninstallWgTunnel, checkV2Ray;
if (process.platform === 'win32') {
@@ -324,6 +349,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;
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..0d38125
--- /dev/null
+++ b/bin/commands/agent.js
@@ -0,0 +1,325 @@
+/**
+ * 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: '--remote-url ', description: 'Node remote URL (https://host:port) for chain-status' },
+ { 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: 'sdk-verify-key', method: 'GET', path: '/api/sdk-verify/:key', auth: true, desc: 'SDK file content by verification key', params: ['key'] },
+ { 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' },
+ { sub: 'pub-test-start', method: 'POST', path: '/api/public/test/start', auth: false, desc: 'Start gated public test (requires ALLOW_PUBLIC_TEST=true)',
+ bodyFromFlags: (f) => ({ mode: f['--mode'] === 'subscription' ? 'subscription' : 'p2p' }) },
+ { sub: 'pub-test-stop', method: 'POST', path: '/api/public/test/stop', auth: false, desc: 'Stop gated public test (requires ALLOW_PUBLIC_TEST=true)' },
+
+ // 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' },
+
+ // 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 (requires --remote-url https://host:port)',
+ queryFromFlags: (f) => {
+ const url = f['--remote-url'] || f['--remoteUrl'];
+ return url ? { remoteUrl: url } : {};
+ } },
+
+ // 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);
+}
+
+// Map :paramName → fallback flag name when positional is omitted
+const PARAM_FLAG_FALLBACK = {
+ addr: '--addr',
+ num: '--num',
+};
+
+function fillPath(rawPath, params, positional, flags) {
+ if (!params || !params.length) return rawPath;
+ let p = rawPath;
+ for (let i = 0; i < params.length; i++) {
+ let tok = positional[i];
+ if (!tok && flags) {
+ const fallbackFlag = PARAM_FLAG_FALLBACK[params[i]];
+ if (fallbackFlag && typeof flags[fallbackFlag] === 'string') {
+ tok = flags[fallbackFlag];
+ }
+ }
+ 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, f);
+
+ // Build query / body
+ const query = {};
+ if (f['--limit']) query.limit = f['--limit'];
+ if (f['--country']) query.country = f['--country'];
+ if (ep.queryFromFlags) Object.assign(query, ep.queryFromFlags(f) || {});
+
+ 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/commands/node.js b/bin/commands/node.js
index 837df2c..5bbdf75 100644
--- a/bin/commands/node.js
+++ b/bin/commands/node.js
@@ -9,7 +9,7 @@ import {
getRpcClient,
ensureLcd,
} from '../../core/chain.js';
-import { rpcQueryNode } from 'sentinel-dvpn-sdk';
+import { rpcQueryNode } from 'blue-js-sdk';
// ─── Metadata ────────────────────────────────────────────────────────────────
diff --git a/bin/lib/args.js b/bin/lib/args.js
index 3398d3f..c327152 100644
--- a/bin/lib/args.js
+++ b/bin/lib/args.js
@@ -24,6 +24,11 @@ const ALIASES = {
const VALUE_FLAGS = new Set([
'--lcd', '--sdk', '--port', '--wallet', '--output',
'--limit', '--max', '--country', '--timeout', '--out',
+ // agent driver flags
+ '--base-url', '--token', '--watch',
+ '--plan-id', '--sub-id', '--sub-granter',
+ '--pricing-mode', '--addr', '--remote-url', '--num', '--mode',
+ '--dns', '--enabled',
]);
// ─── Parser ───────────────────────────────────────────────────────────────────
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..0cdbe28 100644
--- a/core/chain.js
+++ b/core/chain.js
@@ -15,6 +15,7 @@ import {
LCD_ENDPOINTS as SDK_LCD_ENDPOINTS,
createRpcQueryClientWithFallback,
rpcQueryNode,
+ rpcQueryNodes,
rpcQueryNodesForPlan,
rpcQueryPlan,
rpcQuerySubscriptionsForAccount,
@@ -23,7 +24,7 @@ import {
queryFeeGrant,
broadcastWithFeeGrant as sdkBroadcastWithFeeGrant,
disconnectRpc,
-} from 'sentinel-dvpn-sdk';
+} from 'blue-js-sdk';
// ─── LCD Endpoint Management ────────────────────────────────────────────────
const LCD_LIST = SDK_LCD_ENDPOINTS.map(e => e.url);
@@ -85,43 +86,15 @@ export function cleanupRpc() {
// ─── Node List (RPC primary, LCD fallback) ─────────────────────────────────
/**
- * Fetch all active nodes via RPC with raw ABCI pagination.
- * The chain caps ABCI queries at 100 per page, so we loop with next_key.
+ * Wrapper around SDK's rpcQueryNodes with broadcast logging.
* Returns null if RPC is unavailable (signals LCD fallback).
*/
async function rpcFetchAllNodes(broadcast) {
const client = await getRpcClient();
- if (!client) return null; // signal LCD fallback
- return rpcFetchAllNodesPaginated(client, broadcast);
-}
-
-/**
- * Raw ABCI paginated fetch for all active nodes.
- * Loops with next_key until all pages are fetched.
- *
- * Cosmos PageRequest proto fields:
- * 1 = key (bytes), 2 = offset (uint64), 3 = limit (uint64),
- * 4 = count_total (bool), 5 = reverse (bool)
- * NOTE: The SDK's encodePagination has a bug — it puts limit at field 2 (offset).
- * We use correct field numbers here.
- */
-async function rpcFetchAllNodesPaginated(client, broadcast) {
- // Sentinel v3 chain truncates at `limit` without emitting `next_key`.
- // A single large request returns the full set (chain has its own hard ceiling).
- const PAGE_SIZE = 10000;
+ if (!client) return null;
try {
- const pagination = encodeRpcVarintField(3, PAGE_SIZE); // limit at field 3
- const request = concatBytes([
- encodeRpcVarintField(1, 1), // status = active
- encodeRpcEmbedded(2, pagination), // pagination
- ]);
- const result = await client.queryClient.queryAbci(
- '/sentinel.node.v3.QueryService/QueryNodes',
- request,
- );
- const fields = decodeRpcProto(new Uint8Array(result.value));
- const nodes = (fields[1] || []).map(entry => decodeRpcNode(decodeRpcProto(entry.value)));
- if (broadcast) broadcast('log', { msg: ` RPC: ${nodes.length} nodes fetched (limit=${PAGE_SIZE})` });
+ const nodes = await rpcQueryNodes(client, { status: 1, limit: 10000 });
+ if (broadcast) broadcast('log', { msg: ` RPC: ${nodes.length} nodes fetched` });
return nodes;
} catch (err) {
if (broadcast) broadcast('log', { msg: ` RPC fetch failed: ${err.message}` });
@@ -130,37 +103,18 @@ async function rpcFetchAllNodesPaginated(client, broadcast) {
}
/**
- * Fetch ALL nodes linked to a plan via RPC with raw ABCI pagination.
- * Chain caps ABCI queries at 100 per page; we loop with next_key until done.
- *
- * QueryNodesForPlanRequest proto:
- * 1 = id (uint64 plan id), 2 = status (enum), 3 = pagination (PageRequest)
- * PageRequest fields: 1=key, 2=offset, 3=limit, 4=count_total, 5=reverse
+ * Wrapper around SDK's rpcQueryNodesForPlan with broadcast logging.
+ * Returns [] on failure (caller falls back to LCD).
*
* @param {{ queryClient }} client
* @param {number|string|bigint} planId
* @param {(channel:string, data:any)=>void} [broadcast]
- * @returns {Promise>} — empty array on failure
+ * @returns {Promise>}
*/
export async function rpcFetchAllNodesForPlanPaginated(client, planId, broadcast) {
- // Sentinel v3 chain truncates at `limit` without emitting `next_key`.
- // A single large request returns the full set (observed: plan 36 → 803 nodes
- // with limit=1000 but only 100 with limit=100, and no next_key either time).
- const PAGE_SIZE = 10000;
try {
- const pagination = encodeRpcVarintField(3, PAGE_SIZE); // limit at field 3
- const request = concatBytes([
- encodeRpcVarintField(1, BigInt(planId)), // plan id
- encodeRpcVarintField(2, 1), // status = active
- encodeRpcEmbedded(3, pagination), // pagination
- ]);
- const result = await client.queryClient.queryAbci(
- '/sentinel.node.v3.QueryService/QueryNodesForPlan',
- request,
- );
- const fields = decodeRpcProto(new Uint8Array(result.value));
- const nodes = (fields[1] || []).map(entry => decodeRpcNode(decodeRpcProto(entry.value)));
- if (broadcast) broadcast('log', { msg: ` RPC plan ${planId}: ${nodes.length} nodes (limit=${PAGE_SIZE})` });
+ const nodes = await rpcQueryNodesForPlan(client, BigInt(planId), { status: 1, limit: 10000 });
+ if (broadcast) broadcast('log', { msg: ` RPC plan ${planId}: ${nodes.length} nodes` });
return nodes;
} catch (err) {
if (broadcast) broadcast('log', { msg: ` RPC plan ${planId} fetch failed: ${err.message}` });
@@ -169,7 +123,8 @@ export async function rpcFetchAllNodesForPlanPaginated(client, planId, broadcast
}
// ─── Minimal Protobuf Helpers (for raw ABCI pagination) ────────────────────
-// Exported so session.js can reuse for RPC session queries.
+// Used by discoverPlans, queryPlanOwnerSent, queryFeeGrantRpcFirst, session.js,
+// and the scripts/probe-*.mjs tooling.
export function encodeRpcVarint(value) {
let n = BigInt(value);
@@ -258,24 +213,6 @@ export function decodeRpcString(data) {
return new TextDecoder().decode(data);
}
-function decodeRpcPrice(fields) {
- return {
- denom: fields[1]?.[0] ? decodeRpcString(fields[1][0].value) : '',
- base_value: fields[2]?.[0] ? decodeRpcString(fields[2][0].value) : '0',
- quote_value: fields[3]?.[0] ? decodeRpcString(fields[3][0].value) : '0',
- };
-}
-
-function decodeRpcNode(fields) {
- return {
- address: fields[1]?.[0] ? decodeRpcString(fields[1][0].value) : '',
- gigabyte_prices: (fields[2] || []).map(f => decodeRpcPrice(decodeRpcProto(f.value))),
- hourly_prices: (fields[3] || []).map(f => decodeRpcPrice(decodeRpcProto(f.value))),
- remote_addrs: (fields[4] || []).map(f => decodeRpcString(f.value)),
- status: fields[6]?.[0] ? Number(fields[6][0].value) : 0,
- };
-}
-
// ─── Node List (RPC primary → LCD fallback) ────────────────────────────────
/**
@@ -648,17 +585,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 +606,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 +636,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 +826,9 @@ export async function querySubscriberPlansEnriched(walletAddress) {
feeGrantActive,
feeGrantCheckFailed,
nodeCount,
+ viaAllocation: !!s.viaAllocation,
+ grantedBytes: s.grantedBytes || null,
+ subOwnerAddress: s.ownerAddress || null,
});
}
return results;
@@ -893,14 +874,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/constants.js b/core/constants.js
index 6292168..2d7bf2d 100644
--- a/core/constants.js
+++ b/core/constants.js
@@ -23,9 +23,12 @@ export const LOW_BALANCE_WARN = 500_000; // 0.5 P2P
export const PORT = parseInt(process.env.PORT || '3001', 10);
// ─── DNS Configuration ──────────────────────────────────────────────────────
-// Default: HNS (Handshake) — decentralized DNS, 0.01% error rate across 9,298 tests
+// Default: HNS (Handshake) — decentralized DNS, 0.01% error rate across 9,298 tests.
+// SDK uses key `handshake`; tester uses `hns` historically — both keys resolve to the same servers.
+const _hns = ['103.196.38.38', '103.196.38.39'];
export const DNS_PRESETS = {
- hns: ['103.196.38.38', '103.196.38.39'],
+ hns: _hns,
+ handshake: _hns,
google: ['8.8.8.8', '8.8.4.4'],
cloudflare: ['1.1.1.1', '1.0.0.1'],
quad9: ['9.9.9.9', '149.112.112.112'],
@@ -36,7 +39,7 @@ if (ACTIVE_DNS.length === 0) ACTIVE_DNS = DNS_PRESETS.hns;
export function setActiveDns(servers) { ACTIVE_DNS = servers; }
// ─── Protocol Message Types (from SDK — single source of truth) ─────────────
-import { MSG_TYPES } from 'sentinel-dvpn-sdk';
+import { MSG_TYPES } from 'blue-js-sdk';
export { MSG_TYPES };
export const V3_MSG_TYPE = MSG_TYPES.START_SESSION;
export const V3_SUB_TYPE = MSG_TYPES.START_SUBSCRIPTION;
diff --git a/core/countries.js b/core/countries.js
index 4d103a7..3c62306 100644
--- a/core/countries.js
+++ b/core/countries.js
@@ -1,65 +1,31 @@
/**
- * Country/Flag helpers — copied from Sentinel SDK js-sdk/app-helpers.js
- * 80+ countries confirmed on the Sentinel network as of 2026-03.
+ * Country/Flag helpers — base map from Blue JS SDK, layered with tester-only
+ * extras (Central Asia + Balkans) that should be upstreamed.
*/
-// ─── Country Name → ISO Code Map ────────────────────────────────────────────
+import {
+ COUNTRY_MAP as SDK_COUNTRY_MAP,
+ countryNameToCode as sdkCountryNameToCode,
+ getFlagUrl as sdkGetFlagUrl,
+ getFlagEmoji as sdkGetFlagEmoji,
+} from 'blue-js-sdk';
-export const COUNTRY_MAP = Object.freeze({
- // Standard names
- 'united states': 'US', 'germany': 'DE', 'france': 'FR', 'united kingdom': 'GB',
- 'netherlands': 'NL', 'canada': 'CA', 'japan': 'JP', 'singapore': 'SG',
- 'australia': 'AU', 'brazil': 'BR', 'india': 'IN', 'south korea': 'KR',
- 'turkey': 'TR', 'romania': 'RO', 'poland': 'PL', 'spain': 'ES',
- 'italy': 'IT', 'sweden': 'SE', 'norway': 'NO', 'finland': 'FI',
- 'switzerland': 'CH', 'austria': 'AT', 'ireland': 'IE', 'portugal': 'PT',
- 'czech republic': 'CZ', 'hungary': 'HU', 'bulgaria': 'BG', 'greece': 'GR',
- 'ukraine': 'UA', 'russia': 'RU', 'hong kong': 'HK', 'taiwan': 'TW',
- 'thailand': 'TH', 'vietnam': 'VN', 'indonesia': 'ID', 'philippines': 'PH',
- 'mexico': 'MX', 'argentina': 'AR', 'chile': 'CL', 'colombia': 'CO',
- 'south africa': 'ZA', 'israel': 'IL', 'united arab emirates': 'AE',
- 'nigeria': 'NG', 'latvia': 'LV', 'lithuania': 'LT', 'estonia': 'EE',
- 'croatia': 'HR', 'serbia': 'RS', 'denmark': 'DK', 'belgium': 'BE',
- 'luxembourg': 'LU', 'malta': 'MT', 'cyprus': 'CY', 'iceland': 'IS',
- 'new zealand': 'NZ', 'malaysia': 'MY', 'bangladesh': 'BD', 'pakistan': 'PK',
- 'egypt': 'EG', 'kenya': 'KE', 'morocco': 'MA', 'peru': 'PE',
- 'venezuela': 'VE', 'georgia': 'GE', 'guatemala': 'GT', 'puerto rico': 'PR',
- 'china': 'CN', 'saudi arabia': 'SA', 'kazakhstan': 'KZ', 'mongolia': 'MN',
- 'slovakia': 'SK', 'albania': 'AL', 'moldova': 'MD', 'jamaica': 'JM',
- 'bolivia': 'BO', 'ecuador': 'EC', 'uruguay': 'UY', 'bahrain': 'BH',
- 'dr congo': 'CD', 'costa rica': 'CR', 'panama': 'PA', 'paraguay': 'PY',
- 'dominican republic': 'DO', 'el salvador': 'SV', 'honduras': 'HN',
- 'nicaragua': 'NI', 'cuba': 'CU', 'haiti': 'HT', 'trinidad and tobago': 'TT',
+// ─── Country Name → ISO Code Map ────────────────────────────────────────────
+// Tester-only extras not yet in SDK COUNTRY_MAP. PR candidate upstream.
+const TESTER_COUNTRY_EXTRAS = {
'kyrgyzstan': 'KG', 'uzbekistan': 'UZ', 'tajikistan': 'TJ',
'bosnia and herzegovina': 'BA', 'north macedonia': 'MK', 'montenegro': 'ME',
'kosovo': 'XK', 'slovenia': 'SI',
+ // Short codes for the extras above
+ 'kg': 'KG', 'uz': 'UZ', 'tj': 'TJ',
+};
- // Variant names the chain returns
- 'the netherlands': 'NL', 'türkiye': 'TR', 'turkiye': 'TR', 'czechia': 'CZ',
- 'russian federation': 'RU', 'viet nam': 'VN', 'korea': 'KR',
- 'republic of korea': 'KR', 'uae': 'AE', 'uk': 'GB', 'usa': 'US',
- 'democratic republic of the congo': 'CD', 'congo': 'CD',
-
- // Short codes (some nodes return these)
- 'us': 'US', 'de': 'DE', 'fr': 'FR', 'gb': 'GB', 'nl': 'NL', 'ca': 'CA',
- 'jp': 'JP', 'sg': 'SG', 'au': 'AU', 'br': 'BR', 'in': 'IN', 'kr': 'KR',
- 'tr': 'TR', 'ro': 'RO', 'pl': 'PL', 'es': 'ES', 'it': 'IT', 'se': 'SE',
- 'no': 'NO', 'fi': 'FI', 'ch': 'CH', 'at': 'AT', 'ie': 'IE', 'pt': 'PT',
- 'cz': 'CZ', 'hu': 'HU', 'bg': 'BG', 'gr': 'GR', 'ua': 'UA', 'ru': 'RU',
- 'hk': 'HK', 'tw': 'TW', 'th': 'TH', 'vn': 'VN', 'id': 'ID', 'ph': 'PH',
- 'mx': 'MX', 'ar': 'AR', 'cl': 'CL', 'co': 'CO', 'za': 'ZA', 'il': 'IL',
- 'ae': 'AE', 'ng': 'NG', 'lv': 'LV', 'lt': 'LT', 'ee': 'EE', 'hr': 'HR',
- 'rs': 'RS', 'dk': 'DK', 'be': 'BE', 'lu': 'LU', 'mt': 'MT', 'cy': 'CY',
- 'is': 'IS', 'nz': 'NZ', 'my': 'MY', 'bd': 'BD', 'pk': 'PK', 'eg': 'EG',
- 'ke': 'KE', 'ma': 'MA', 'pe': 'PE', 've': 'VE', 'ge': 'GE', 'gt': 'GT',
- 'pr': 'PR', 'cn': 'CN', 'sa': 'SA', 'kz': 'KZ', 'mn': 'MN', 'sk': 'SK',
- 'al': 'AL', 'md': 'MD', 'jm': 'JM', 'bo': 'BO', 'ec': 'EC', 'uy': 'UY',
- 'bh': 'BH', 'cd': 'CD', 'kg': 'KG', 'uz': 'UZ', 'tj': 'TJ',
-});
+export const COUNTRY_MAP = Object.freeze({ ...SDK_COUNTRY_MAP, ...TESTER_COUNTRY_EXTRAS });
/**
* Convert a country name to ISO 3166-1 alpha-2 code.
* Handles standard names, chain variants, and short codes.
+ * Uses tester's superset map (SDK + extras) — falls through to SDK on miss.
*/
export function countryNameToCode(name) {
if (!name) return null;
@@ -70,25 +36,11 @@ export function countryNameToCode(name) {
for (const [key, code] of Object.entries(COUNTRY_MAP)) {
if (key.length > 2 && (lower.includes(key) || key.includes(lower))) return code;
}
- return null;
+ return sdkCountryNameToCode(name);
}
-/**
- * Get flag image URL from flagcdn.com (for native apps where emoji doesn't render).
- */
-export function getFlagUrl(code, width = 40) {
- if (!code || code.length !== 2) return '';
- return `https://flagcdn.com/w${width}/${code.toLowerCase()}.png`;
-}
-
-/**
- * Get emoji flag for a country code (for web/browser).
- */
-export function getFlagEmoji(code) {
- if (!code || code.length !== 2) return '';
- const upper = code.toUpperCase();
- return String.fromCodePoint(upper.charCodeAt(0) + 0x1F1A5, upper.charCodeAt(1) + 0x1F1A5);
-}
+export const getFlagUrl = sdkGetFlagUrl;
+export const getFlagEmoji = sdkGetFlagEmoji;
// ─── Country Code → Continent Map ──────────────────────────────────────────
diff --git a/core/db.js b/core/db.js
index 3729146..c4c8c48 100644
--- a/core/db.js
+++ b/core/db.js
@@ -274,7 +274,8 @@ function runMigrations(db) {
if (current < 7) {
db.transaction(() => {
- // ── Migration v7: rename legacy runs.mode='dry' to 'test' ────────────────
+ // ── 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();
})();
@@ -580,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
@@ -1012,8 +1013,7 @@ export function searchErrors({ q = null, stage = null, limit = 100, offset = 0 }
el.error_message,
el.log_snippet,
el.captured_at,
- r.run_id,
- r.iteration
+ r.run_id
FROM error_logs el
INNER JOIN results r ON el.result_id = r.id
${where}
diff --git a/core/errors.js b/core/errors.js
index caab52e..f6d14cd 100644
--- a/core/errors.js
+++ b/core/errors.js
@@ -10,7 +10,7 @@ import {
SentinelError, ValidationError, NodeError, ChainError as SdkChainError,
TunnelError as SdkTunnelError, SecurityError,
ErrorCodes, ERROR_SEVERITY, isRetryable, userMessage,
-} from 'sentinel-dvpn-sdk';
+} from 'blue-js-sdk';
// Re-export SDK error utilities for consumers
export { ErrorCodes, ERROR_SEVERITY, isRetryable, userMessage, SentinelError, ValidationError, NodeError, SecurityError };
diff --git a/core/sdk-verify.js b/core/sdk-verify.js
index bc34eae..7c53c4f 100644
--- a/core/sdk-verify.js
+++ b/core/sdk-verify.js
@@ -1,7 +1,7 @@
/**
* Sentinel Node Tester — SDK Verification
*
- * Verifies that the installed copies of `sentinel-dvpn-sdk` (Blue JS) and
+ * Verifies that the installed copies of `blue-js-sdk` (Blue JS) and
* `@sentinel-official/sentinel-js-sdk` (TKD) match the published GitHub
* source at their current tag.
*
@@ -34,7 +34,7 @@ import { Readable } from 'stream';
// ─── Config ──────────────────────────────────────────────────────────────────
const TRACKED_SDKS = [
- { key: 'blue-js', pkg: 'sentinel-dvpn-sdk' },
+ { key: 'blue-js', pkg: 'blue-js-sdk' },
{ key: 'tkd-js', pkg: '@sentinel-official/sentinel-js-sdk' },
];
diff --git a/core/session.js b/core/session.js
index ea1bce8..4891b91 100644
--- a/core/session.js
+++ b/core/session.js
@@ -7,46 +7,44 @@
* credential cache (local disk), session map (audit TTL), dedup guard.
*/
-import { readFileSync, writeFileSync, existsSync } from 'fs';
-import { DENOM, GIGS, CREDS_FILE, SESSION_MAP_TTL, V3_MSG_TYPE } from './constants.js';
+import { DENOM, GIGS, SESSION_MAP_TTL, V3_MSG_TYPE } from './constants.js';
import { signAndBroadcastRetry, assertIsDeliverTxSuccess } from './wallet.js';
-import {
- getActiveLcd, getRpcClient,
- encodeRpcVarint, encodeRpcVarintField, encodeRpcBytes, encodeRpcEmbedded,
- concatBytes, decodeRpcProto, decodeRpcString,
-} from './chain.js';
+import { getActiveLcd, getRpcClient } from './chain.js';
import { sleep } from '../protocol/speedtest.js';
import {
extractAllSessionIds as sdkExtractAllSessionIds,
- markSessionPoisoned as sdkMarkPoisoned,
- isSessionPoisoned as sdkIsPoisoned,
- querySessions as sdkQuerySessions,
- querySessionAllocation as sdkQueryAllocation,
-} from 'sentinel-dvpn-sdk';
-
-// ─── Session Credential Cache (disk-persistent) ─────────────────────────────
-let credentialCache = {};
-if (existsSync(CREDS_FILE)) {
- try { credentialCache = JSON.parse(readFileSync(CREDS_FILE, 'utf8')); } catch { credentialCache = {}; }
-}
+ rpcQuerySessionsForAccount,
+ rpcQuerySession as sdkRpcQuerySession,
+ saveCredentials as sdkSaveCredentials,
+ loadCredentials as sdkLoadCredentials,
+ clearCredentials as sdkClearCredentials,
+ clearAllCredentials as sdkClearAllCredentials,
+} from 'blue-js-sdk';
+
+// ─── Session Credential Cache (SDK encrypted store: ~/.sentinel-sdk/) ──────
+// Tester API stays { saveCredential, getCredential, clearCredential, clearAllCredentials }
+// — call sites pass a flat object containing `sessionId` + credential fields. We unpack
+// sessionId here and delegate to SDK's encrypted, pruned, sessionId-keyed credential store.
export function saveCredential(nodeAddr, data) {
- credentialCache[nodeAddr] = { ...data, savedAt: new Date().toISOString() };
- writeFileSync(CREDS_FILE, JSON.stringify(credentialCache, null, 2), 'utf8');
+ const { sessionId, ...creds } = data || {};
+ sdkSaveCredentials(nodeAddr, String(sessionId || ''), creds);
}
export function getCredential(nodeAddr) {
- return credentialCache[nodeAddr] || null;
+ const stored = sdkLoadCredentials(nodeAddr);
+ if (!stored) return null;
+ // Caller-side accesses .sessionId / .type / .uuid / .wgPrivateKey / etc. flat.
+ // SDK already returns flat shape with sessionId at top level — pass through.
+ return stored;
}
export function clearCredential(nodeAddr) {
- delete credentialCache[nodeAddr];
- writeFileSync(CREDS_FILE, JSON.stringify(credentialCache, null, 2), 'utf8');
+ sdkClearCredentials(nodeAddr);
}
export function clearAllCredentials() {
- credentialCache = {};
- writeFileSync(CREDS_FILE, '{}', 'utf8');
+ sdkClearAllCredentials();
}
// ─── Session Reuse Map ──────────────────────────────────────────────────────
@@ -92,93 +90,18 @@ export function addToSessionMap(nodeAddr, sessionId) {
sessionMap.set(nodeAddr, { sessionId, maxBytes: GIGS * 1_000_000_000, usedBytes: 0 });
}
-// ─── RPC Session Decoder ────────────────────────────────────────────────────
-// BaseSession proto fields (sentinel.session.v3.BaseSession):
-// 1=id(uint64), 2=acc_address(string), 3=node_address(string),
-// 4=download_bytes(string), 5=upload_bytes(string), 6=max_bytes(string),
-// 7=duration, 8=max_duration, 9=status(enum), 10=inactive_at, 11=start_at, 12=status_at
-// Session wrappers: field 1 = base_session (embedded)
-
-function decodeRpcBaseSession(buf) {
- const f = decodeRpcProto(buf);
- return {
- id: f[1]?.[0] ? f[1][0].value : 0n,
- acc_address: f[2]?.[0] ? decodeRpcString(f[2][0].value) : '',
- node_address: f[3]?.[0] ? decodeRpcString(f[3][0].value) : '',
- download_bytes: f[4]?.[0] ? decodeRpcString(f[4][0].value) : '0',
- upload_bytes: f[5]?.[0] ? decodeRpcString(f[5][0].value) : '0',
- max_bytes: f[6]?.[0] ? decodeRpcString(f[6][0].value) : '0',
- status: f[9]?.[0] ? Number(f[9][0].value) : 0,
- };
-}
-
-function decodeRpcSession(anyBuf) {
- // Unwrap google.protobuf.Any: field 1 = type_url, field 2 = value
- const anyFields = decodeRpcProto(anyBuf);
- const innerBuf = anyFields[2]?.[0]?.value;
- if (!innerBuf) return null;
- // Session wrapper: field 1 = base_session (embedded)
- const sessionFields = decodeRpcProto(innerBuf);
- if (!sessionFields[1]?.[0]) return null;
- return decodeRpcBaseSession(sessionFields[1][0].value);
-}
+// ─── RPC Session Fetch (delegates to SDK) ──────────────────────────────────
+// SDK's rpcQuerySessionsForAccount handles ABCI encoding + Any-unwrapping +
+// BaseSession decoding. Sentinel v3 truncates at `limit` without emitting
+// next_key, so a single large request returns the full set.
-/**
- * Fetch sessions for account via RPC (ABCI query) with pagination.
- * Returns array of decoded session objects, or null if RPC unavailable.
- */
async function rpcFetchAllSessions(walletAddress) {
const client = await getRpcClient();
if (!client) return null;
-
- const allSessions = [];
- let nextKeyBytes = null;
- let page = 0;
- const PAGE_SIZE = 100;
- const MAX_PAGES = 20;
-
try {
- do {
- const paginationParts = [];
- if (nextKeyBytes) {
- paginationParts.push(encodeRpcBytes(1, nextKeyBytes));
- }
- paginationParts.push(encodeRpcVarintField(3, PAGE_SIZE));
-
- const request = concatBytes([
- encodeRpcBytes(1, new TextEncoder().encode(walletAddress)), // address (string = field 1)
- encodeRpcEmbedded(2, concatBytes(paginationParts)), // pagination
- ]);
-
- const result = await client.queryClient.queryAbci(
- '/sentinel.session.v3.QueryService/QuerySessionsForAccount',
- request,
- );
- const resp = new Uint8Array(result.value);
- if (resp.length <= 2) break; // empty pagination-only response
-
- const fields = decodeRpcProto(resp);
-
- // Field 1 = repeated google.protobuf.Any (sessions)
- const sessions = (fields[1] || []).map(entry => decodeRpcSession(entry.value)).filter(Boolean);
- allSessions.push(...sessions);
- page++;
-
- // Extract pagination response (field 2) for next_key
- nextKeyBytes = null;
- if (fields[2]?.[0]) {
- const pagResp = decodeRpcProto(fields[2][0].value);
- if (pagResp[1]?.[0]?.value?.length > 0) {
- nextKeyBytes = pagResp[1][0].value;
- }
- }
-
- if (sessions.length < PAGE_SIZE) break;
- } while (nextKeyBytes && page < MAX_PAGES);
-
- return allSessions;
+ return await rpcQuerySessionsForAccount(client, walletAddress, { limit: 2000 });
} catch {
- return null; // signal LCD fallback
+ return null;
}
}
@@ -333,6 +256,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 +267,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 +300,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 +341,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)`;
@@ -458,24 +391,14 @@ export async function waitForBatchSessions(nodeAddrs, walletAddr, maxWaitMs = 20
}
/**
- * Query a single session by ID via RPC.
- * Returns decoded base session or null.
+ * Query a single session by ID via RPC. Delegates to SDK.
+ * Returns decoded session object or null.
*/
async function rpcQuerySession(sessionId) {
const client = await getRpcClient();
if (!client) return null;
try {
- const request = encodeRpcVarintField(1, Number(sessionId));
- const result = await client.queryClient.queryAbci(
- '/sentinel.session.v3.QueryService/QuerySession',
- request,
- );
- const resp = new Uint8Array(result.value);
- if (resp.length <= 2) return null;
- const fields = decodeRpcProto(resp);
- // Field 1 = google.protobuf.Any (session)
- if (!fields[1]?.[0]) return null;
- return decodeRpcSession(fields[1][0].value);
+ return await sdkRpcQuerySession(client, sessionId);
} catch {
return null;
}
diff --git a/core/transport-cache.js b/core/transport-cache.js
index 5ced6a6..070eb50 100644
--- a/core/transport-cache.js
+++ b/core/transport-cache.js
@@ -10,8 +10,21 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import path from 'path';
+import { recordTransportResult } from 'blue-js-sdk';
import { RESULTS_DIR } from './constants.js';
+// SDK rate-key format mirrors connection/tunnel.js _transportRateKey:
+// network=='grpc' or security=='tls' get suffix 'tls' (or special 'grpc/none'),
+// otherwise just the network. Tester records protocol-aware key locally and
+// dual-writes a normalized key into the SDK rate cache so embedded SDK calls
+// benefit from tester's broad-coverage observations.
+function sdkTransportKey(network, security) {
+ if (!network) return null;
+ if (security === 'tls') return `${network}/tls`;
+ if (network === 'grpc') return 'grpc/none';
+ return network;
+}
+
// ─── Cache File ──────────────────────────────────────────────────────────────
const CACHE_FILE = path.join(RESULTS_DIR, 'transport-cache.json');
@@ -68,6 +81,11 @@ export function recordTransportSuccess(nodeAddr, transport) {
cache.globalStats[key].attempts++;
cache.globalStats[key].rate = cache.globalStats[key].successes / cache.globalStats[key].attempts;
+ // Dual-write into SDK's getDynamicRate cache so embedded SDK code paths
+ // (setupV2Ray transport ordering) benefit from tester observations.
+ const sdkKey = sdkTransportKey(network, security);
+ if (sdkKey) try { recordTransportResult(sdkKey, true); } catch { }
+
dirty = true;
}
@@ -87,6 +105,9 @@ export function recordTransportFailure(transport) {
cache.globalStats[key].attempts++;
cache.globalStats[key].rate = cache.globalStats[key].successes / cache.globalStats[key].attempts;
+ const sdkKey = sdkTransportKey(network, security);
+ if (sdkKey) try { recordTransportResult(sdkKey, false); } catch { }
+
dirty = true;
}
diff --git a/core/types.js b/core/types.js
index 97e8364..e0a1a43 100644
--- a/core/types.js
+++ b/core/types.js
@@ -78,8 +78,16 @@
* @property {string|null} errorMessage
* @property {boolean} stopRequested
* @property {boolean} lowBalanceWarning
- * @property {boolean} economyMode
* @property {string|null} pauseReason - Why audit is paused (VPN interference, etc.)
+ * @property {'p2p'|'subscription'|'test'|null} runMode - Active run mode (persisted)
+ * @property {boolean} testRun - Skip-only TEST RUN demo flag (persisted)
+ * @property {string|number|null} runPlanId - Plan ID when runMode='p2p'
+ * @property {string|number|null} runSubscriptionId - Subscription ID when runMode='subscription'
+ * @property {string|null} runGranter - Fee-grant granter address (admin-only; never broadcast over public SSE)
+ * @property {string|null} pricingMode - Active pricing strategy
+ * @property {string|null} activeSDK - Active SDK identifier
+ * @property {boolean} continuousLoop - True when a continuous loop run is active
+ * @property {boolean} broadcastLive - When true, public SSE forwards live events; when false, public sees last-completed snapshot only
*/
export {};
diff --git a/core/wallet.js b/core/wallet.js
index 571d052..bf0eb80 100644
--- a/core/wallet.js
+++ b/core/wallet.js
@@ -15,9 +15,10 @@ import {
buildRegistry,
broadcast as sdkBroadcast,
createSafeBroadcaster,
+ clearWalletCache,
RPC_ENDPOINTS as SDK_RPC_ENDPOINTS,
SDK_VERSION,
-} from 'sentinel-dvpn-sdk';
+} from 'blue-js-sdk';
import { RPC_ENDPOINTS as LOCAL_RPC_ENDPOINTS, GAS_PRICE as GAS_PRICE_STR } from './constants.js';
// Use SDK RPC endpoints (5 endpoints), fall back to local constants
@@ -82,6 +83,7 @@ export async function getOrReconnectClient() {
export async function forceReconnect() {
if (_managedClient) { try { _managedClient.disconnect(); } catch { } }
_managedClient = null;
+ clearWalletCache();
_activeRpcIdx = (_activeRpcIdx + 1) % RPC_LIST.length;
return getOrReconnectClient();
}
diff --git a/dictator.html b/dictator.html
deleted file mode 100644
index 23902f1..0000000
--- a/dictator.html
+++ /dev/null
@@ -1,944 +0,0 @@
-
-
-
-
-
-
- Dictator Mode — Sentinel Network Audit
-
-
-
-
-
-
-
-
-
-
- Dictator Mode
- Sentinel Network · Google Accessibility Audit
-
-
-
- WINDOWS
-
-
- JS SDK
-
-
-
-
-
- Live
-
-
- ← Dashboard
-
-
-
-
-
-
-
- Countries by Google Accessibility
-
-
-
-
-
-
- Loading audit data
-
-
-
-
- Copied
-
-
-
-
-
diff --git a/docs/ARCHITECTURE-PUBLIC-LIVE.md b/docs/ARCHITECTURE-PUBLIC-LIVE.md
index 3ed680d..41686f4 100644
--- a/docs/ARCHITECTURE-PUBLIC-LIVE.md
+++ b/docs/ARCHITECTURE-PUBLIC-LIVE.md
@@ -55,9 +55,9 @@ One database: `audit.db`. All runs — live and dry — write to it.
- Normal audit run: `mode='live'` (or absent/null for legacy rows).
- 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-test.db` no longer exists.
+- `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.
@@ -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-test.db` — superseded by the `mode='test'` 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/docs/FRONTEND-SPEC.md b/docs/FRONTEND-SPEC.md
index 8fb34a0..b610c66 100644
--- a/docs/FRONTEND-SPEC.md
+++ b/docs/FRONTEND-SPEC.md
@@ -757,8 +757,6 @@ Integrator equivalent:
| GET | `/api/sdk` | Get SDK | Not used by frontend | `{ sdk }` |
| GET | `/api/dns` | Get DNS config | DNS startup loader | `{ servers, presets }` |
| POST | `/api/dns` | Set DNS | setDNS() | `{ ok, servers, preset }` |
-| GET | `/dictator` | Dictator page | Link in header | HTML |
-| GET | `/api/dictator` | Dictator data | Not used by main frontend | `{ sdk, countries, ... }` |
| GET | `/health` | Health check | Not used by frontend | `{ status, uptime }` |
---
@@ -944,9 +942,7 @@ Integrator equivalent:
5. **Low balance warning** -- `state.lowBalanceWarning` is set by server but never shown in frontend.
-6. **Dictator Mode link** -- EXISTS in header but leads to separate `dictator.html` (not part of main dashboard code review).
-
-7. **Moniker display** -- Result rows show truncated address, not moniker. The moniker field exists in results but is unused in the table.
+6. **Moniker display** -- Result rows show truncated address, not moniker. The moniker field exists in results but is unused in the table.
---
@@ -963,7 +959,6 @@ These exist in server.js but have no corresponding frontend function:
| `POST /api/auto-retest` | Server-side filtered retest | Frontend uses client-side filtered retest-fails |
| `GET /api/runs/:num` | Get run details without loading | Frontend loads runs directly |
| `GET /api/sdk` | Get current SDK | Frontend syncs via SSE state |
-| `GET /api/dictator` | Dictator data | Used by dictator.html, not main dashboard |
| `GET /health` | Health check | For monitoring, not UI |
---
diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md
index 78fa1e8..59ba770 100644
--- a/docs/INTEGRATION.md
+++ b/docs/INTEGRATION.md
@@ -191,7 +191,7 @@ es.addEventListener('state', e => {
```
┌─ Header ──────────────────────────────────────────────────────┐
-│ [Logo] SENTINEL AUDIT [JS|C#] [DNS▼] [Dictator Mode] │
+│ [Logo] SENTINEL AUDIT [JS|C#] [DNS▼] │
├─ Stats ───────────────────────────────────────────────────────┤
│ Total: 1002 | Tested: 975 | Remaining: 27 | Rate: 97.2% │
│ [New Test] [Resume] [Rescan] [Retest Failed] [Stop] [Economy] │
diff --git a/docs/TECHNICAL-BLUEPRINT.md b/docs/TECHNICAL-BLUEPRINT.md
index 9e97436..826d71f 100644
--- a/docs/TECHNICAL-BLUEPRINT.md
+++ b/docs/TECHNICAL-BLUEPRINT.md
@@ -472,7 +472,7 @@ testWithRetry(testFn, broadcast, state, nodeAddr):
## Dashboard UI (index.html)
### Layout Sections
-1. **Header:** Logo, Windows badge, SDK toggle (JS/C#), DNS dropdown, Dictator Mode link
+1. **Header:** Logo, Windows badge, SDK toggle (JS/C#), DNS dropdown
2. **Stats grid:** Total, Tested, Remaining, Pass Rate, >10Mbps, Balance
3. **Controls:** New Test, Resume, Rescan, Retest Failed, Stop, Economy, Plan Test, Reset
4. **Speed history:** Last 10 speeds as color-coded pills (green ≥15, yellow ≥5, red <5)
diff --git a/docs/UX-FEATURE-PARITY.md b/docs/UX-FEATURE-PARITY.md
index 68b6b92..78e3a18 100644
--- a/docs/UX-FEATURE-PARITY.md
+++ b/docs/UX-FEATURE-PARITY.md
@@ -16,7 +16,6 @@ Some features only apply to the standalone CLI/Browser tester. When embedded in
| Mnemonic in .env | ✓ Required | ✗ App manages auth | App handles credentials |
| SentinelAudit.vbs | ✓ Required | ✗ App has own launcher | App handles elevation |
| Port 3001 | ✓ Default | Configurable or embedded | May conflict with app |
-| Dictator Mode link | ✓ Optional | ✗ Remove | App-specific feature |
| Economy mode | ✓ Optional | ✓ Keep | Users want to limit spending |
| Plan Test | ✓ Optional | Only if app uses plans | Depends on app type |
diff --git a/easy.js b/easy.js
index b60ef0a..1097cf1 100644
--- a/easy.js
+++ b/easy.js
@@ -17,7 +17,7 @@
*/
import { getAllNodes, queryNodeStatusDirect, cleanupRpc } from './core/chain.js';
-import { cachedWalletSetup, createFreshClient, buildV3Registry } from './core/wallet.js';
+import { cachedWalletSetup, createFreshClient } from './core/wallet.js';
import { findExistingSession, submitBatchPayment, clearPaidNodes, buildSessionMap } from './core/session.js';
import { testNode } from './audit/node-test.js';
import { testWithRetry } from './audit/retry.js';
diff --git a/index.html b/index.html
index 03ae20d..bacfd61 100644
--- a/index.html
+++ b/index.html
@@ -535,7 +535,6 @@ SENTINEL AUDIT
- Dictator Mode
' + (nd.addr || addr) + ''],
- ['Country', nd.country || '—'],
- ['City', nd.city || '—'],
- ['Service', nd.service_type || '—'],
- ['Status', nd.status || '—'],
- ['Last Tested', nd.last_tested ? new Date(nd.last_tested).toLocaleString() : '—'],
+ ['Address', '' + escHtml(nd.addr || addr) + ''],
+ ['Country', escHtml(nd.country || '—')],
+ ['City', escHtml(nd.city || '—')],
+ ['Service', escHtml(nd.service_type || '—')],
+ ['Status', escHtml(nd.status || '—')],
+ ['Last Tested', escHtml(nd.last_tested ? new Date(nd.last_tested).toLocaleString() : '—')],
].map(function(pair) {
return ''
- + '' + pair[0] + ''
+ + '' + escHtml(pair[0]) + ''
+ '' + pair[1] + '';
}).join('');
@@ -2371,7 +3027,7 @@ { 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 = _fmtCapturedUtc(er.captured_at);
+ 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..e9c71e7 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)
@@ -305,7 +323,9 @@ async function _resolveSnapshot() {
* Main loop body — runs until stopRequested or unrecoverable error.
*/
async function _runLoop() {
- _ctrl.running = true;
+ // _ctrl.running is set synchronously in start() before this is fire-and-forget
+ // invoked, so a second start() call cannot race past the `if (_ctrl.running)`
+ // guard while we're still on the microtask queue.
_ctrl.stopRequested = false;
_ctrl.startedAt = Date.now();
_ctrl.iteration = 0;
@@ -331,9 +351,19 @@ async function _runLoop() {
mode: _ctrl.mode,
});
- // Fresh pipeline state per iteration so counters reset cleanly
+ // Fresh pipeline state per iteration so counters reset cleanly.
+ // Inherit SDK / pricingMode / runMode from _ctrl so per-node failure
+ // rows record the correct sdk and pipeline branches the right way —
+ // without this, every continuous-loop result fell back to sdk:'js'.
const loopState = createState();
loopState.stopRequested = false;
+ if (_ctrl.activeSDK) loopState.activeSDK = _ctrl.activeSDK;
+ if (_ctrl.pricingMode) loopState.pricingMode = _ctrl.pricingMode;
+ loopState.runMode = _ctrl.mode || 'p2p';
+ loopState.runPlanId = _ctrl.planId || null;
+ loopState.runSubscriptionId = _ctrl.subscriptionId || null;
+ loopState.runGranter = _ctrl.subscriptionGranter || null;
+ loopState.testRun = !!_ctrl.testRun;
let currentBatchId = 0;
let frozenNodes = null;
@@ -648,6 +678,13 @@ export async function start(opts = {}) {
_ctrl.minDelayMs = minDelayMs;
_ctrl.lastError = null;
_ctrl.iteration = 0;
+ _ctrl.activeSDK = opts.activeSDK || _ctrl.activeSDK || 'js';
+ _ctrl.pricingMode = opts.pricingMode || _ctrl.pricingMode || null;
+ _ctrl.testRun = !!opts.testRun;
+ // Mark running synchronously BEFORE the fire-and-forget so a second
+ // start() call cannot slip through the `if (_ctrl.running)` guard while
+ // _runLoop is still on the microtask queue (the C-2 race window).
+ _ctrl.running = true;
_emitScoped('loop:started', {
mode,
diff --git a/audit/pipeline.js b/audit/pipeline.js
index 12eada6..eedde10 100644
--- a/audit/pipeline.js
+++ b/audit/pipeline.js
@@ -14,13 +14,38 @@ import {
} from '../core/constants.js';
import { cachedWalletSetup, createFreshClient, signAndBroadcastRetry } from '../core/wallet.js';
import { getAllNodes, fetchPlanMembership, ensureLcd, getActiveLcd, getRpcClient, rpcFetchAllNodesForPlanPaginated } from '../core/chain.js';
-import { rpcQueryNode } from 'sentinel-dvpn-sdk';
+import { rpcQueryNode } from 'blue-js-sdk';
import {
submitBatchPayment, waitForBatchSessions, waitForSessionActive,
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.
+const _stopWaiters = new Set();
+let _pipelineStopFlag = false;
+function sleep(ms) {
+ if (_pipelineStopFlag) return Promise.resolve();
+ return new Promise((resolve) => {
+ let timer = setTimeout(() => { _stopWaiters.delete(wake); resolve(); }, ms);
+ const wake = () => { try { clearTimeout(timer); } catch {} _stopWaiters.delete(wake); resolve(); };
+ _stopWaiters.add(wake);
+ });
+}
+export function triggerPipelineStop() {
+ _pipelineStopFlag = true;
+ for (const w of _stopWaiters) { try { w(); } catch {} }
+ _stopWaiters.clear();
+}
+export function resetPipelineStop() {
+ _pipelineStopFlag = false;
+}
+
// Platform-aware imports — Windows has full implementation, others get stubs
let WG_AVAILABLE, IS_ADMIN, emergencyCleanupSync, uninstallWgTunnel, checkV2Ray;
if (process.platform === 'win32') {
@@ -324,6 +349,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;
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..0d38125
--- /dev/null
+++ b/bin/commands/agent.js
@@ -0,0 +1,325 @@
+/**
+ * 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: '--remote-url ', description: 'Node remote URL (https://host:port) for chain-status' },
+ { 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: 'sdk-verify-key', method: 'GET', path: '/api/sdk-verify/:key', auth: true, desc: 'SDK file content by verification key', params: ['key'] },
+ { 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' },
+ { sub: 'pub-test-start', method: 'POST', path: '/api/public/test/start', auth: false, desc: 'Start gated public test (requires ALLOW_PUBLIC_TEST=true)',
+ bodyFromFlags: (f) => ({ mode: f['--mode'] === 'subscription' ? 'subscription' : 'p2p' }) },
+ { sub: 'pub-test-stop', method: 'POST', path: '/api/public/test/stop', auth: false, desc: 'Stop gated public test (requires ALLOW_PUBLIC_TEST=true)' },
+
+ // 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' },
+
+ // 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 (requires --remote-url https://host:port)',
+ queryFromFlags: (f) => {
+ const url = f['--remote-url'] || f['--remoteUrl'];
+ return url ? { remoteUrl: url } : {};
+ } },
+
+ // 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);
+}
+
+// Map :paramName → fallback flag name when positional is omitted
+const PARAM_FLAG_FALLBACK = {
+ addr: '--addr',
+ num: '--num',
+};
+
+function fillPath(rawPath, params, positional, flags) {
+ if (!params || !params.length) return rawPath;
+ let p = rawPath;
+ for (let i = 0; i < params.length; i++) {
+ let tok = positional[i];
+ if (!tok && flags) {
+ const fallbackFlag = PARAM_FLAG_FALLBACK[params[i]];
+ if (fallbackFlag && typeof flags[fallbackFlag] === 'string') {
+ tok = flags[fallbackFlag];
+ }
+ }
+ 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, f);
+
+ // Build query / body
+ const query = {};
+ if (f['--limit']) query.limit = f['--limit'];
+ if (f['--country']) query.country = f['--country'];
+ if (ep.queryFromFlags) Object.assign(query, ep.queryFromFlags(f) || {});
+
+ 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/commands/node.js b/bin/commands/node.js
index 837df2c..5bbdf75 100644
--- a/bin/commands/node.js
+++ b/bin/commands/node.js
@@ -9,7 +9,7 @@ import {
getRpcClient,
ensureLcd,
} from '../../core/chain.js';
-import { rpcQueryNode } from 'sentinel-dvpn-sdk';
+import { rpcQueryNode } from 'blue-js-sdk';
// ─── Metadata ────────────────────────────────────────────────────────────────
diff --git a/bin/lib/args.js b/bin/lib/args.js
index 3398d3f..c327152 100644
--- a/bin/lib/args.js
+++ b/bin/lib/args.js
@@ -24,6 +24,11 @@ const ALIASES = {
const VALUE_FLAGS = new Set([
'--lcd', '--sdk', '--port', '--wallet', '--output',
'--limit', '--max', '--country', '--timeout', '--out',
+ // agent driver flags
+ '--base-url', '--token', '--watch',
+ '--plan-id', '--sub-id', '--sub-granter',
+ '--pricing-mode', '--addr', '--remote-url', '--num', '--mode',
+ '--dns', '--enabled',
]);
// ─── Parser ───────────────────────────────────────────────────────────────────
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..0cdbe28 100644
--- a/core/chain.js
+++ b/core/chain.js
@@ -15,6 +15,7 @@ import {
LCD_ENDPOINTS as SDK_LCD_ENDPOINTS,
createRpcQueryClientWithFallback,
rpcQueryNode,
+ rpcQueryNodes,
rpcQueryNodesForPlan,
rpcQueryPlan,
rpcQuerySubscriptionsForAccount,
@@ -23,7 +24,7 @@ import {
queryFeeGrant,
broadcastWithFeeGrant as sdkBroadcastWithFeeGrant,
disconnectRpc,
-} from 'sentinel-dvpn-sdk';
+} from 'blue-js-sdk';
// ─── LCD Endpoint Management ────────────────────────────────────────────────
const LCD_LIST = SDK_LCD_ENDPOINTS.map(e => e.url);
@@ -85,43 +86,15 @@ export function cleanupRpc() {
// ─── Node List (RPC primary, LCD fallback) ─────────────────────────────────
/**
- * Fetch all active nodes via RPC with raw ABCI pagination.
- * The chain caps ABCI queries at 100 per page, so we loop with next_key.
+ * Wrapper around SDK's rpcQueryNodes with broadcast logging.
* Returns null if RPC is unavailable (signals LCD fallback).
*/
async function rpcFetchAllNodes(broadcast) {
const client = await getRpcClient();
- if (!client) return null; // signal LCD fallback
- return rpcFetchAllNodesPaginated(client, broadcast);
-}
-
-/**
- * Raw ABCI paginated fetch for all active nodes.
- * Loops with next_key until all pages are fetched.
- *
- * Cosmos PageRequest proto fields:
- * 1 = key (bytes), 2 = offset (uint64), 3 = limit (uint64),
- * 4 = count_total (bool), 5 = reverse (bool)
- * NOTE: The SDK's encodePagination has a bug — it puts limit at field 2 (offset).
- * We use correct field numbers here.
- */
-async function rpcFetchAllNodesPaginated(client, broadcast) {
- // Sentinel v3 chain truncates at `limit` without emitting `next_key`.
- // A single large request returns the full set (chain has its own hard ceiling).
- const PAGE_SIZE = 10000;
+ if (!client) return null;
try {
- const pagination = encodeRpcVarintField(3, PAGE_SIZE); // limit at field 3
- const request = concatBytes([
- encodeRpcVarintField(1, 1), // status = active
- encodeRpcEmbedded(2, pagination), // pagination
- ]);
- const result = await client.queryClient.queryAbci(
- '/sentinel.node.v3.QueryService/QueryNodes',
- request,
- );
- const fields = decodeRpcProto(new Uint8Array(result.value));
- const nodes = (fields[1] || []).map(entry => decodeRpcNode(decodeRpcProto(entry.value)));
- if (broadcast) broadcast('log', { msg: ` RPC: ${nodes.length} nodes fetched (limit=${PAGE_SIZE})` });
+ const nodes = await rpcQueryNodes(client, { status: 1, limit: 10000 });
+ if (broadcast) broadcast('log', { msg: ` RPC: ${nodes.length} nodes fetched` });
return nodes;
} catch (err) {
if (broadcast) broadcast('log', { msg: ` RPC fetch failed: ${err.message}` });
@@ -130,37 +103,18 @@ async function rpcFetchAllNodesPaginated(client, broadcast) {
}
/**
- * Fetch ALL nodes linked to a plan via RPC with raw ABCI pagination.
- * Chain caps ABCI queries at 100 per page; we loop with next_key until done.
- *
- * QueryNodesForPlanRequest proto:
- * 1 = id (uint64 plan id), 2 = status (enum), 3 = pagination (PageRequest)
- * PageRequest fields: 1=key, 2=offset, 3=limit, 4=count_total, 5=reverse
+ * Wrapper around SDK's rpcQueryNodesForPlan with broadcast logging.
+ * Returns [] on failure (caller falls back to LCD).
*
* @param {{ queryClient }} client
* @param {number|string|bigint} planId
* @param {(channel:string, data:any)=>void} [broadcast]
- * @returns {Promise>} — empty array on failure
+ * @returns {Promise>}
*/
export async function rpcFetchAllNodesForPlanPaginated(client, planId, broadcast) {
- // Sentinel v3 chain truncates at `limit` without emitting `next_key`.
- // A single large request returns the full set (observed: plan 36 → 803 nodes
- // with limit=1000 but only 100 with limit=100, and no next_key either time).
- const PAGE_SIZE = 10000;
try {
- const pagination = encodeRpcVarintField(3, PAGE_SIZE); // limit at field 3
- const request = concatBytes([
- encodeRpcVarintField(1, BigInt(planId)), // plan id
- encodeRpcVarintField(2, 1), // status = active
- encodeRpcEmbedded(3, pagination), // pagination
- ]);
- const result = await client.queryClient.queryAbci(
- '/sentinel.node.v3.QueryService/QueryNodesForPlan',
- request,
- );
- const fields = decodeRpcProto(new Uint8Array(result.value));
- const nodes = (fields[1] || []).map(entry => decodeRpcNode(decodeRpcProto(entry.value)));
- if (broadcast) broadcast('log', { msg: ` RPC plan ${planId}: ${nodes.length} nodes (limit=${PAGE_SIZE})` });
+ const nodes = await rpcQueryNodesForPlan(client, BigInt(planId), { status: 1, limit: 10000 });
+ if (broadcast) broadcast('log', { msg: ` RPC plan ${planId}: ${nodes.length} nodes` });
return nodes;
} catch (err) {
if (broadcast) broadcast('log', { msg: ` RPC plan ${planId} fetch failed: ${err.message}` });
@@ -169,7 +123,8 @@ export async function rpcFetchAllNodesForPlanPaginated(client, planId, broadcast
}
// ─── Minimal Protobuf Helpers (for raw ABCI pagination) ────────────────────
-// Exported so session.js can reuse for RPC session queries.
+// Used by discoverPlans, queryPlanOwnerSent, queryFeeGrantRpcFirst, session.js,
+// and the scripts/probe-*.mjs tooling.
export function encodeRpcVarint(value) {
let n = BigInt(value);
@@ -258,24 +213,6 @@ export function decodeRpcString(data) {
return new TextDecoder().decode(data);
}
-function decodeRpcPrice(fields) {
- return {
- denom: fields[1]?.[0] ? decodeRpcString(fields[1][0].value) : '',
- base_value: fields[2]?.[0] ? decodeRpcString(fields[2][0].value) : '0',
- quote_value: fields[3]?.[0] ? decodeRpcString(fields[3][0].value) : '0',
- };
-}
-
-function decodeRpcNode(fields) {
- return {
- address: fields[1]?.[0] ? decodeRpcString(fields[1][0].value) : '',
- gigabyte_prices: (fields[2] || []).map(f => decodeRpcPrice(decodeRpcProto(f.value))),
- hourly_prices: (fields[3] || []).map(f => decodeRpcPrice(decodeRpcProto(f.value))),
- remote_addrs: (fields[4] || []).map(f => decodeRpcString(f.value)),
- status: fields[6]?.[0] ? Number(fields[6][0].value) : 0,
- };
-}
-
// ─── Node List (RPC primary → LCD fallback) ────────────────────────────────
/**
@@ -648,17 +585,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 +606,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 +636,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 +826,9 @@ export async function querySubscriberPlansEnriched(walletAddress) {
feeGrantActive,
feeGrantCheckFailed,
nodeCount,
+ viaAllocation: !!s.viaAllocation,
+ grantedBytes: s.grantedBytes || null,
+ subOwnerAddress: s.ownerAddress || null,
});
}
return results;
@@ -893,14 +874,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/constants.js b/core/constants.js
index 6292168..2d7bf2d 100644
--- a/core/constants.js
+++ b/core/constants.js
@@ -23,9 +23,12 @@ export const LOW_BALANCE_WARN = 500_000; // 0.5 P2P
export const PORT = parseInt(process.env.PORT || '3001', 10);
// ─── DNS Configuration ──────────────────────────────────────────────────────
-// Default: HNS (Handshake) — decentralized DNS, 0.01% error rate across 9,298 tests
+// Default: HNS (Handshake) — decentralized DNS, 0.01% error rate across 9,298 tests.
+// SDK uses key `handshake`; tester uses `hns` historically — both keys resolve to the same servers.
+const _hns = ['103.196.38.38', '103.196.38.39'];
export const DNS_PRESETS = {
- hns: ['103.196.38.38', '103.196.38.39'],
+ hns: _hns,
+ handshake: _hns,
google: ['8.8.8.8', '8.8.4.4'],
cloudflare: ['1.1.1.1', '1.0.0.1'],
quad9: ['9.9.9.9', '149.112.112.112'],
@@ -36,7 +39,7 @@ if (ACTIVE_DNS.length === 0) ACTIVE_DNS = DNS_PRESETS.hns;
export function setActiveDns(servers) { ACTIVE_DNS = servers; }
// ─── Protocol Message Types (from SDK — single source of truth) ─────────────
-import { MSG_TYPES } from 'sentinel-dvpn-sdk';
+import { MSG_TYPES } from 'blue-js-sdk';
export { MSG_TYPES };
export const V3_MSG_TYPE = MSG_TYPES.START_SESSION;
export const V3_SUB_TYPE = MSG_TYPES.START_SUBSCRIPTION;
diff --git a/core/countries.js b/core/countries.js
index 4d103a7..3c62306 100644
--- a/core/countries.js
+++ b/core/countries.js
@@ -1,65 +1,31 @@
/**
- * Country/Flag helpers — copied from Sentinel SDK js-sdk/app-helpers.js
- * 80+ countries confirmed on the Sentinel network as of 2026-03.
+ * Country/Flag helpers — base map from Blue JS SDK, layered with tester-only
+ * extras (Central Asia + Balkans) that should be upstreamed.
*/
-// ─── Country Name → ISO Code Map ────────────────────────────────────────────
+import {
+ COUNTRY_MAP as SDK_COUNTRY_MAP,
+ countryNameToCode as sdkCountryNameToCode,
+ getFlagUrl as sdkGetFlagUrl,
+ getFlagEmoji as sdkGetFlagEmoji,
+} from 'blue-js-sdk';
-export const COUNTRY_MAP = Object.freeze({
- // Standard names
- 'united states': 'US', 'germany': 'DE', 'france': 'FR', 'united kingdom': 'GB',
- 'netherlands': 'NL', 'canada': 'CA', 'japan': 'JP', 'singapore': 'SG',
- 'australia': 'AU', 'brazil': 'BR', 'india': 'IN', 'south korea': 'KR',
- 'turkey': 'TR', 'romania': 'RO', 'poland': 'PL', 'spain': 'ES',
- 'italy': 'IT', 'sweden': 'SE', 'norway': 'NO', 'finland': 'FI',
- 'switzerland': 'CH', 'austria': 'AT', 'ireland': 'IE', 'portugal': 'PT',
- 'czech republic': 'CZ', 'hungary': 'HU', 'bulgaria': 'BG', 'greece': 'GR',
- 'ukraine': 'UA', 'russia': 'RU', 'hong kong': 'HK', 'taiwan': 'TW',
- 'thailand': 'TH', 'vietnam': 'VN', 'indonesia': 'ID', 'philippines': 'PH',
- 'mexico': 'MX', 'argentina': 'AR', 'chile': 'CL', 'colombia': 'CO',
- 'south africa': 'ZA', 'israel': 'IL', 'united arab emirates': 'AE',
- 'nigeria': 'NG', 'latvia': 'LV', 'lithuania': 'LT', 'estonia': 'EE',
- 'croatia': 'HR', 'serbia': 'RS', 'denmark': 'DK', 'belgium': 'BE',
- 'luxembourg': 'LU', 'malta': 'MT', 'cyprus': 'CY', 'iceland': 'IS',
- 'new zealand': 'NZ', 'malaysia': 'MY', 'bangladesh': 'BD', 'pakistan': 'PK',
- 'egypt': 'EG', 'kenya': 'KE', 'morocco': 'MA', 'peru': 'PE',
- 'venezuela': 'VE', 'georgia': 'GE', 'guatemala': 'GT', 'puerto rico': 'PR',
- 'china': 'CN', 'saudi arabia': 'SA', 'kazakhstan': 'KZ', 'mongolia': 'MN',
- 'slovakia': 'SK', 'albania': 'AL', 'moldova': 'MD', 'jamaica': 'JM',
- 'bolivia': 'BO', 'ecuador': 'EC', 'uruguay': 'UY', 'bahrain': 'BH',
- 'dr congo': 'CD', 'costa rica': 'CR', 'panama': 'PA', 'paraguay': 'PY',
- 'dominican republic': 'DO', 'el salvador': 'SV', 'honduras': 'HN',
- 'nicaragua': 'NI', 'cuba': 'CU', 'haiti': 'HT', 'trinidad and tobago': 'TT',
+// ─── Country Name → ISO Code Map ────────────────────────────────────────────
+// Tester-only extras not yet in SDK COUNTRY_MAP. PR candidate upstream.
+const TESTER_COUNTRY_EXTRAS = {
'kyrgyzstan': 'KG', 'uzbekistan': 'UZ', 'tajikistan': 'TJ',
'bosnia and herzegovina': 'BA', 'north macedonia': 'MK', 'montenegro': 'ME',
'kosovo': 'XK', 'slovenia': 'SI',
+ // Short codes for the extras above
+ 'kg': 'KG', 'uz': 'UZ', 'tj': 'TJ',
+};
- // Variant names the chain returns
- 'the netherlands': 'NL', 'türkiye': 'TR', 'turkiye': 'TR', 'czechia': 'CZ',
- 'russian federation': 'RU', 'viet nam': 'VN', 'korea': 'KR',
- 'republic of korea': 'KR', 'uae': 'AE', 'uk': 'GB', 'usa': 'US',
- 'democratic republic of the congo': 'CD', 'congo': 'CD',
-
- // Short codes (some nodes return these)
- 'us': 'US', 'de': 'DE', 'fr': 'FR', 'gb': 'GB', 'nl': 'NL', 'ca': 'CA',
- 'jp': 'JP', 'sg': 'SG', 'au': 'AU', 'br': 'BR', 'in': 'IN', 'kr': 'KR',
- 'tr': 'TR', 'ro': 'RO', 'pl': 'PL', 'es': 'ES', 'it': 'IT', 'se': 'SE',
- 'no': 'NO', 'fi': 'FI', 'ch': 'CH', 'at': 'AT', 'ie': 'IE', 'pt': 'PT',
- 'cz': 'CZ', 'hu': 'HU', 'bg': 'BG', 'gr': 'GR', 'ua': 'UA', 'ru': 'RU',
- 'hk': 'HK', 'tw': 'TW', 'th': 'TH', 'vn': 'VN', 'id': 'ID', 'ph': 'PH',
- 'mx': 'MX', 'ar': 'AR', 'cl': 'CL', 'co': 'CO', 'za': 'ZA', 'il': 'IL',
- 'ae': 'AE', 'ng': 'NG', 'lv': 'LV', 'lt': 'LT', 'ee': 'EE', 'hr': 'HR',
- 'rs': 'RS', 'dk': 'DK', 'be': 'BE', 'lu': 'LU', 'mt': 'MT', 'cy': 'CY',
- 'is': 'IS', 'nz': 'NZ', 'my': 'MY', 'bd': 'BD', 'pk': 'PK', 'eg': 'EG',
- 'ke': 'KE', 'ma': 'MA', 'pe': 'PE', 've': 'VE', 'ge': 'GE', 'gt': 'GT',
- 'pr': 'PR', 'cn': 'CN', 'sa': 'SA', 'kz': 'KZ', 'mn': 'MN', 'sk': 'SK',
- 'al': 'AL', 'md': 'MD', 'jm': 'JM', 'bo': 'BO', 'ec': 'EC', 'uy': 'UY',
- 'bh': 'BH', 'cd': 'CD', 'kg': 'KG', 'uz': 'UZ', 'tj': 'TJ',
-});
+export const COUNTRY_MAP = Object.freeze({ ...SDK_COUNTRY_MAP, ...TESTER_COUNTRY_EXTRAS });
/**
* Convert a country name to ISO 3166-1 alpha-2 code.
* Handles standard names, chain variants, and short codes.
+ * Uses tester's superset map (SDK + extras) — falls through to SDK on miss.
*/
export function countryNameToCode(name) {
if (!name) return null;
@@ -70,25 +36,11 @@ export function countryNameToCode(name) {
for (const [key, code] of Object.entries(COUNTRY_MAP)) {
if (key.length > 2 && (lower.includes(key) || key.includes(lower))) return code;
}
- return null;
+ return sdkCountryNameToCode(name);
}
-/**
- * Get flag image URL from flagcdn.com (for native apps where emoji doesn't render).
- */
-export function getFlagUrl(code, width = 40) {
- if (!code || code.length !== 2) return '';
- return `https://flagcdn.com/w${width}/${code.toLowerCase()}.png`;
-}
-
-/**
- * Get emoji flag for a country code (for web/browser).
- */
-export function getFlagEmoji(code) {
- if (!code || code.length !== 2) return '';
- const upper = code.toUpperCase();
- return String.fromCodePoint(upper.charCodeAt(0) + 0x1F1A5, upper.charCodeAt(1) + 0x1F1A5);
-}
+export const getFlagUrl = sdkGetFlagUrl;
+export const getFlagEmoji = sdkGetFlagEmoji;
// ─── Country Code → Continent Map ──────────────────────────────────────────
diff --git a/core/db.js b/core/db.js
index 3729146..c4c8c48 100644
--- a/core/db.js
+++ b/core/db.js
@@ -274,7 +274,8 @@ function runMigrations(db) {
if (current < 7) {
db.transaction(() => {
- // ── Migration v7: rename legacy runs.mode='dry' to 'test' ────────────────
+ // ── 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();
})();
@@ -580,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
@@ -1012,8 +1013,7 @@ export function searchErrors({ q = null, stage = null, limit = 100, offset = 0 }
el.error_message,
el.log_snippet,
el.captured_at,
- r.run_id,
- r.iteration
+ r.run_id
FROM error_logs el
INNER JOIN results r ON el.result_id = r.id
${where}
diff --git a/core/errors.js b/core/errors.js
index caab52e..f6d14cd 100644
--- a/core/errors.js
+++ b/core/errors.js
@@ -10,7 +10,7 @@ import {
SentinelError, ValidationError, NodeError, ChainError as SdkChainError,
TunnelError as SdkTunnelError, SecurityError,
ErrorCodes, ERROR_SEVERITY, isRetryable, userMessage,
-} from 'sentinel-dvpn-sdk';
+} from 'blue-js-sdk';
// Re-export SDK error utilities for consumers
export { ErrorCodes, ERROR_SEVERITY, isRetryable, userMessage, SentinelError, ValidationError, NodeError, SecurityError };
diff --git a/core/sdk-verify.js b/core/sdk-verify.js
index bc34eae..7c53c4f 100644
--- a/core/sdk-verify.js
+++ b/core/sdk-verify.js
@@ -1,7 +1,7 @@
/**
* Sentinel Node Tester — SDK Verification
*
- * Verifies that the installed copies of `sentinel-dvpn-sdk` (Blue JS) and
+ * Verifies that the installed copies of `blue-js-sdk` (Blue JS) and
* `@sentinel-official/sentinel-js-sdk` (TKD) match the published GitHub
* source at their current tag.
*
@@ -34,7 +34,7 @@ import { Readable } from 'stream';
// ─── Config ──────────────────────────────────────────────────────────────────
const TRACKED_SDKS = [
- { key: 'blue-js', pkg: 'sentinel-dvpn-sdk' },
+ { key: 'blue-js', pkg: 'blue-js-sdk' },
{ key: 'tkd-js', pkg: '@sentinel-official/sentinel-js-sdk' },
];
diff --git a/core/session.js b/core/session.js
index ea1bce8..4891b91 100644
--- a/core/session.js
+++ b/core/session.js
@@ -7,46 +7,44 @@
* credential cache (local disk), session map (audit TTL), dedup guard.
*/
-import { readFileSync, writeFileSync, existsSync } from 'fs';
-import { DENOM, GIGS, CREDS_FILE, SESSION_MAP_TTL, V3_MSG_TYPE } from './constants.js';
+import { DENOM, GIGS, SESSION_MAP_TTL, V3_MSG_TYPE } from './constants.js';
import { signAndBroadcastRetry, assertIsDeliverTxSuccess } from './wallet.js';
-import {
- getActiveLcd, getRpcClient,
- encodeRpcVarint, encodeRpcVarintField, encodeRpcBytes, encodeRpcEmbedded,
- concatBytes, decodeRpcProto, decodeRpcString,
-} from './chain.js';
+import { getActiveLcd, getRpcClient } from './chain.js';
import { sleep } from '../protocol/speedtest.js';
import {
extractAllSessionIds as sdkExtractAllSessionIds,
- markSessionPoisoned as sdkMarkPoisoned,
- isSessionPoisoned as sdkIsPoisoned,
- querySessions as sdkQuerySessions,
- querySessionAllocation as sdkQueryAllocation,
-} from 'sentinel-dvpn-sdk';
-
-// ─── Session Credential Cache (disk-persistent) ─────────────────────────────
-let credentialCache = {};
-if (existsSync(CREDS_FILE)) {
- try { credentialCache = JSON.parse(readFileSync(CREDS_FILE, 'utf8')); } catch { credentialCache = {}; }
-}
+ rpcQuerySessionsForAccount,
+ rpcQuerySession as sdkRpcQuerySession,
+ saveCredentials as sdkSaveCredentials,
+ loadCredentials as sdkLoadCredentials,
+ clearCredentials as sdkClearCredentials,
+ clearAllCredentials as sdkClearAllCredentials,
+} from 'blue-js-sdk';
+
+// ─── Session Credential Cache (SDK encrypted store: ~/.sentinel-sdk/) ──────
+// Tester API stays { saveCredential, getCredential, clearCredential, clearAllCredentials }
+// — call sites pass a flat object containing `sessionId` + credential fields. We unpack
+// sessionId here and delegate to SDK's encrypted, pruned, sessionId-keyed credential store.
export function saveCredential(nodeAddr, data) {
- credentialCache[nodeAddr] = { ...data, savedAt: new Date().toISOString() };
- writeFileSync(CREDS_FILE, JSON.stringify(credentialCache, null, 2), 'utf8');
+ const { sessionId, ...creds } = data || {};
+ sdkSaveCredentials(nodeAddr, String(sessionId || ''), creds);
}
export function getCredential(nodeAddr) {
- return credentialCache[nodeAddr] || null;
+ const stored = sdkLoadCredentials(nodeAddr);
+ if (!stored) return null;
+ // Caller-side accesses .sessionId / .type / .uuid / .wgPrivateKey / etc. flat.
+ // SDK already returns flat shape with sessionId at top level — pass through.
+ return stored;
}
export function clearCredential(nodeAddr) {
- delete credentialCache[nodeAddr];
- writeFileSync(CREDS_FILE, JSON.stringify(credentialCache, null, 2), 'utf8');
+ sdkClearCredentials(nodeAddr);
}
export function clearAllCredentials() {
- credentialCache = {};
- writeFileSync(CREDS_FILE, '{}', 'utf8');
+ sdkClearAllCredentials();
}
// ─── Session Reuse Map ──────────────────────────────────────────────────────
@@ -92,93 +90,18 @@ export function addToSessionMap(nodeAddr, sessionId) {
sessionMap.set(nodeAddr, { sessionId, maxBytes: GIGS * 1_000_000_000, usedBytes: 0 });
}
-// ─── RPC Session Decoder ────────────────────────────────────────────────────
-// BaseSession proto fields (sentinel.session.v3.BaseSession):
-// 1=id(uint64), 2=acc_address(string), 3=node_address(string),
-// 4=download_bytes(string), 5=upload_bytes(string), 6=max_bytes(string),
-// 7=duration, 8=max_duration, 9=status(enum), 10=inactive_at, 11=start_at, 12=status_at
-// Session wrappers: field 1 = base_session (embedded)
-
-function decodeRpcBaseSession(buf) {
- const f = decodeRpcProto(buf);
- return {
- id: f[1]?.[0] ? f[1][0].value : 0n,
- acc_address: f[2]?.[0] ? decodeRpcString(f[2][0].value) : '',
- node_address: f[3]?.[0] ? decodeRpcString(f[3][0].value) : '',
- download_bytes: f[4]?.[0] ? decodeRpcString(f[4][0].value) : '0',
- upload_bytes: f[5]?.[0] ? decodeRpcString(f[5][0].value) : '0',
- max_bytes: f[6]?.[0] ? decodeRpcString(f[6][0].value) : '0',
- status: f[9]?.[0] ? Number(f[9][0].value) : 0,
- };
-}
-
-function decodeRpcSession(anyBuf) {
- // Unwrap google.protobuf.Any: field 1 = type_url, field 2 = value
- const anyFields = decodeRpcProto(anyBuf);
- const innerBuf = anyFields[2]?.[0]?.value;
- if (!innerBuf) return null;
- // Session wrapper: field 1 = base_session (embedded)
- const sessionFields = decodeRpcProto(innerBuf);
- if (!sessionFields[1]?.[0]) return null;
- return decodeRpcBaseSession(sessionFields[1][0].value);
-}
+// ─── RPC Session Fetch (delegates to SDK) ──────────────────────────────────
+// SDK's rpcQuerySessionsForAccount handles ABCI encoding + Any-unwrapping +
+// BaseSession decoding. Sentinel v3 truncates at `limit` without emitting
+// next_key, so a single large request returns the full set.
-/**
- * Fetch sessions for account via RPC (ABCI query) with pagination.
- * Returns array of decoded session objects, or null if RPC unavailable.
- */
async function rpcFetchAllSessions(walletAddress) {
const client = await getRpcClient();
if (!client) return null;
-
- const allSessions = [];
- let nextKeyBytes = null;
- let page = 0;
- const PAGE_SIZE = 100;
- const MAX_PAGES = 20;
-
try {
- do {
- const paginationParts = [];
- if (nextKeyBytes) {
- paginationParts.push(encodeRpcBytes(1, nextKeyBytes));
- }
- paginationParts.push(encodeRpcVarintField(3, PAGE_SIZE));
-
- const request = concatBytes([
- encodeRpcBytes(1, new TextEncoder().encode(walletAddress)), // address (string = field 1)
- encodeRpcEmbedded(2, concatBytes(paginationParts)), // pagination
- ]);
-
- const result = await client.queryClient.queryAbci(
- '/sentinel.session.v3.QueryService/QuerySessionsForAccount',
- request,
- );
- const resp = new Uint8Array(result.value);
- if (resp.length <= 2) break; // empty pagination-only response
-
- const fields = decodeRpcProto(resp);
-
- // Field 1 = repeated google.protobuf.Any (sessions)
- const sessions = (fields[1] || []).map(entry => decodeRpcSession(entry.value)).filter(Boolean);
- allSessions.push(...sessions);
- page++;
-
- // Extract pagination response (field 2) for next_key
- nextKeyBytes = null;
- if (fields[2]?.[0]) {
- const pagResp = decodeRpcProto(fields[2][0].value);
- if (pagResp[1]?.[0]?.value?.length > 0) {
- nextKeyBytes = pagResp[1][0].value;
- }
- }
-
- if (sessions.length < PAGE_SIZE) break;
- } while (nextKeyBytes && page < MAX_PAGES);
-
- return allSessions;
+ return await rpcQuerySessionsForAccount(client, walletAddress, { limit: 2000 });
} catch {
- return null; // signal LCD fallback
+ return null;
}
}
@@ -333,6 +256,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 +267,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 +300,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 +341,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)`;
@@ -458,24 +391,14 @@ export async function waitForBatchSessions(nodeAddrs, walletAddr, maxWaitMs = 20
}
/**
- * Query a single session by ID via RPC.
- * Returns decoded base session or null.
+ * Query a single session by ID via RPC. Delegates to SDK.
+ * Returns decoded session object or null.
*/
async function rpcQuerySession(sessionId) {
const client = await getRpcClient();
if (!client) return null;
try {
- const request = encodeRpcVarintField(1, Number(sessionId));
- const result = await client.queryClient.queryAbci(
- '/sentinel.session.v3.QueryService/QuerySession',
- request,
- );
- const resp = new Uint8Array(result.value);
- if (resp.length <= 2) return null;
- const fields = decodeRpcProto(resp);
- // Field 1 = google.protobuf.Any (session)
- if (!fields[1]?.[0]) return null;
- return decodeRpcSession(fields[1][0].value);
+ return await sdkRpcQuerySession(client, sessionId);
} catch {
return null;
}
diff --git a/core/transport-cache.js b/core/transport-cache.js
index 5ced6a6..070eb50 100644
--- a/core/transport-cache.js
+++ b/core/transport-cache.js
@@ -10,8 +10,21 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import path from 'path';
+import { recordTransportResult } from 'blue-js-sdk';
import { RESULTS_DIR } from './constants.js';
+// SDK rate-key format mirrors connection/tunnel.js _transportRateKey:
+// network=='grpc' or security=='tls' get suffix 'tls' (or special 'grpc/none'),
+// otherwise just the network. Tester records protocol-aware key locally and
+// dual-writes a normalized key into the SDK rate cache so embedded SDK calls
+// benefit from tester's broad-coverage observations.
+function sdkTransportKey(network, security) {
+ if (!network) return null;
+ if (security === 'tls') return `${network}/tls`;
+ if (network === 'grpc') return 'grpc/none';
+ return network;
+}
+
// ─── Cache File ──────────────────────────────────────────────────────────────
const CACHE_FILE = path.join(RESULTS_DIR, 'transport-cache.json');
@@ -68,6 +81,11 @@ export function recordTransportSuccess(nodeAddr, transport) {
cache.globalStats[key].attempts++;
cache.globalStats[key].rate = cache.globalStats[key].successes / cache.globalStats[key].attempts;
+ // Dual-write into SDK's getDynamicRate cache so embedded SDK code paths
+ // (setupV2Ray transport ordering) benefit from tester observations.
+ const sdkKey = sdkTransportKey(network, security);
+ if (sdkKey) try { recordTransportResult(sdkKey, true); } catch { }
+
dirty = true;
}
@@ -87,6 +105,9 @@ export function recordTransportFailure(transport) {
cache.globalStats[key].attempts++;
cache.globalStats[key].rate = cache.globalStats[key].successes / cache.globalStats[key].attempts;
+ const sdkKey = sdkTransportKey(network, security);
+ if (sdkKey) try { recordTransportResult(sdkKey, false); } catch { }
+
dirty = true;
}
diff --git a/core/types.js b/core/types.js
index 97e8364..e0a1a43 100644
--- a/core/types.js
+++ b/core/types.js
@@ -78,8 +78,16 @@
* @property {string|null} errorMessage
* @property {boolean} stopRequested
* @property {boolean} lowBalanceWarning
- * @property {boolean} economyMode
* @property {string|null} pauseReason - Why audit is paused (VPN interference, etc.)
+ * @property {'p2p'|'subscription'|'test'|null} runMode - Active run mode (persisted)
+ * @property {boolean} testRun - Skip-only TEST RUN demo flag (persisted)
+ * @property {string|number|null} runPlanId - Plan ID when runMode='p2p'
+ * @property {string|number|null} runSubscriptionId - Subscription ID when runMode='subscription'
+ * @property {string|null} runGranter - Fee-grant granter address (admin-only; never broadcast over public SSE)
+ * @property {string|null} pricingMode - Active pricing strategy
+ * @property {string|null} activeSDK - Active SDK identifier
+ * @property {boolean} continuousLoop - True when a continuous loop run is active
+ * @property {boolean} broadcastLive - When true, public SSE forwards live events; when false, public sees last-completed snapshot only
*/
export {};
diff --git a/core/wallet.js b/core/wallet.js
index 571d052..bf0eb80 100644
--- a/core/wallet.js
+++ b/core/wallet.js
@@ -15,9 +15,10 @@ import {
buildRegistry,
broadcast as sdkBroadcast,
createSafeBroadcaster,
+ clearWalletCache,
RPC_ENDPOINTS as SDK_RPC_ENDPOINTS,
SDK_VERSION,
-} from 'sentinel-dvpn-sdk';
+} from 'blue-js-sdk';
import { RPC_ENDPOINTS as LOCAL_RPC_ENDPOINTS, GAS_PRICE as GAS_PRICE_STR } from './constants.js';
// Use SDK RPC endpoints (5 endpoints), fall back to local constants
@@ -82,6 +83,7 @@ export async function getOrReconnectClient() {
export async function forceReconnect() {
if (_managedClient) { try { _managedClient.disconnect(); } catch { } }
_managedClient = null;
+ clearWalletCache();
_activeRpcIdx = (_activeRpcIdx + 1) % RPC_LIST.length;
return getOrReconnectClient();
}
diff --git a/dictator.html b/dictator.html
deleted file mode 100644
index 23902f1..0000000
--- a/dictator.html
+++ /dev/null
@@ -1,944 +0,0 @@
-
-
-
-
-
-
- Dictator Mode — Sentinel Network Audit
-
-
-
-
-
-
-
-
-
-
- Dictator Mode
- Sentinel Network · Google Accessibility Audit
-
-
-
- WINDOWS
-
-
- JS SDK
-
-
-
-
-
- Live
-
-
- ← Dashboard
-
-
-
-
-
-
-
- Countries by Google Accessibility
-
-
-
-
-
-
- Loading audit data
-
-
-
-
- Copied
-
-
-
-
-
diff --git a/docs/ARCHITECTURE-PUBLIC-LIVE.md b/docs/ARCHITECTURE-PUBLIC-LIVE.md
index 3ed680d..41686f4 100644
--- a/docs/ARCHITECTURE-PUBLIC-LIVE.md
+++ b/docs/ARCHITECTURE-PUBLIC-LIVE.md
@@ -55,9 +55,9 @@ One database: `audit.db`. All runs — live and dry — write to it.
- Normal audit run: `mode='live'` (or absent/null for legacy rows).
- 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-test.db` no longer exists.
+- `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.
@@ -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-test.db` — superseded by the `mode='test'` 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/docs/FRONTEND-SPEC.md b/docs/FRONTEND-SPEC.md
index 8fb34a0..b610c66 100644
--- a/docs/FRONTEND-SPEC.md
+++ b/docs/FRONTEND-SPEC.md
@@ -757,8 +757,6 @@ Integrator equivalent:
| GET | `/api/sdk` | Get SDK | Not used by frontend | `{ sdk }` |
| GET | `/api/dns` | Get DNS config | DNS startup loader | `{ servers, presets }` |
| POST | `/api/dns` | Set DNS | setDNS() | `{ ok, servers, preset }` |
-| GET | `/dictator` | Dictator page | Link in header | HTML |
-| GET | `/api/dictator` | Dictator data | Not used by main frontend | `{ sdk, countries, ... }` |
| GET | `/health` | Health check | Not used by frontend | `{ status, uptime }` |
---
@@ -944,9 +942,7 @@ Integrator equivalent:
5. **Low balance warning** -- `state.lowBalanceWarning` is set by server but never shown in frontend.
-6. **Dictator Mode link** -- EXISTS in header but leads to separate `dictator.html` (not part of main dashboard code review).
-
-7. **Moniker display** -- Result rows show truncated address, not moniker. The moniker field exists in results but is unused in the table.
+6. **Moniker display** -- Result rows show truncated address, not moniker. The moniker field exists in results but is unused in the table.
---
@@ -963,7 +959,6 @@ These exist in server.js but have no corresponding frontend function:
| `POST /api/auto-retest` | Server-side filtered retest | Frontend uses client-side filtered retest-fails |
| `GET /api/runs/:num` | Get run details without loading | Frontend loads runs directly |
| `GET /api/sdk` | Get current SDK | Frontend syncs via SSE state |
-| `GET /api/dictator` | Dictator data | Used by dictator.html, not main dashboard |
| `GET /health` | Health check | For monitoring, not UI |
---
diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md
index 78fa1e8..59ba770 100644
--- a/docs/INTEGRATION.md
+++ b/docs/INTEGRATION.md
@@ -191,7 +191,7 @@ es.addEventListener('state', e => {
```
┌─ Header ──────────────────────────────────────────────────────┐
-│ [Logo] SENTINEL AUDIT [JS|C#] [DNS▼] [Dictator Mode] │
+│ [Logo] SENTINEL AUDIT [JS|C#] [DNS▼] │
├─ Stats ───────────────────────────────────────────────────────┤
│ Total: 1002 | Tested: 975 | Remaining: 27 | Rate: 97.2% │
│ [New Test] [Resume] [Rescan] [Retest Failed] [Stop] [Economy] │
diff --git a/docs/TECHNICAL-BLUEPRINT.md b/docs/TECHNICAL-BLUEPRINT.md
index 9e97436..826d71f 100644
--- a/docs/TECHNICAL-BLUEPRINT.md
+++ b/docs/TECHNICAL-BLUEPRINT.md
@@ -472,7 +472,7 @@ testWithRetry(testFn, broadcast, state, nodeAddr):
## Dashboard UI (index.html)
### Layout Sections
-1. **Header:** Logo, Windows badge, SDK toggle (JS/C#), DNS dropdown, Dictator Mode link
+1. **Header:** Logo, Windows badge, SDK toggle (JS/C#), DNS dropdown
2. **Stats grid:** Total, Tested, Remaining, Pass Rate, >10Mbps, Balance
3. **Controls:** New Test, Resume, Rescan, Retest Failed, Stop, Economy, Plan Test, Reset
4. **Speed history:** Last 10 speeds as color-coded pills (green ≥15, yellow ≥5, red <5)
diff --git a/docs/UX-FEATURE-PARITY.md b/docs/UX-FEATURE-PARITY.md
index 68b6b92..78e3a18 100644
--- a/docs/UX-FEATURE-PARITY.md
+++ b/docs/UX-FEATURE-PARITY.md
@@ -16,7 +16,6 @@ Some features only apply to the standalone CLI/Browser tester. When embedded in
| Mnemonic in .env | ✓ Required | ✗ App manages auth | App handles credentials |
| SentinelAudit.vbs | ✓ Required | ✗ App has own launcher | App handles elevation |
| Port 3001 | ✓ Default | Configurable or embedded | May conflict with app |
-| Dictator Mode link | ✓ Optional | ✗ Remove | App-specific feature |
| Economy mode | ✓ Optional | ✓ Keep | Users want to limit spending |
| Plan Test | ✓ Optional | Only if app uses plans | Depends on app type |
diff --git a/easy.js b/easy.js
index b60ef0a..1097cf1 100644
--- a/easy.js
+++ b/easy.js
@@ -17,7 +17,7 @@
*/
import { getAllNodes, queryNodeStatusDirect, cleanupRpc } from './core/chain.js';
-import { cachedWalletSetup, createFreshClient, buildV3Registry } from './core/wallet.js';
+import { cachedWalletSetup, createFreshClient } from './core/wallet.js';
import { findExistingSession, submitBatchPayment, clearPaidNodes, buildSessionMap } from './core/session.js';
import { testNode } from './audit/node-test.js';
import { testWithRetry } from './audit/retry.js';
diff --git a/index.html b/index.html
index 03ae20d..bacfd61 100644
--- a/index.html
+++ b/index.html
@@ -535,7 +535,6 @@ SENTINEL AUDIT
- Dictator Mode
Node Error Details
+ × +${escapeHtml(er.log_snippet)}
+ Dictator Mode
-SENTINEL AUDIT
- Dictator ModeSENTINEL 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'); } - if (state.economyMode != null) updateEconomyBtn(state.economyMode); - // Sync SDK toggle with server state (don't override user's click) if (state.activeSDK && state.activeSDK !== activeSDK) { setSDK(state.activeSDK); @@ -1235,34 +1231,6 @@SENTINEL AUDIT
if (body.children.length > 500) body.removeChild(body.firstChild); } - async function toggleEconomy() { - try { - const res = await fetch('/api/economy', { method: 'POST' }); - const data = await res.json(); - updateEconomyBtn(data.economyMode); - } catch {} - } - - function updateEconomyBtn(on) { - const btn = document.getElementById('btnEconomy'); - if (on) { - btn.style.background = 'rgba(0,200,83,0.15)'; - btn.style.borderColor = 'var(--accent-green)'; - btn.style.color = 'var(--accent-green)'; - btn.textContent = '♻ Economy ON'; - } else { - btn.style.background = 'transparent'; - btn.style.borderColor = 'rgba(255,255,255,0.15)'; - btn.style.color = '#888'; - btn.textContent = '♻ Economy'; - } - } - - // Sync economy state on page load - fetch('/api/state').then(r => r.json()).then(d => { - if (d.state?.economyMode != null) updateEconomyBtn(d.state.economyMode); - }).catch(() => {}); - // ─── Test Run Management ──────────────────────────────────────────────── async function loadRunsList() { try { @@ -1503,15 +1471,10 @@
- Stop Audit
+ Stop Test
Gracefully stops the current scan after the active node finishes. Results are saved. You can Resume later.
-
- Economy Mode
- Caps the number of nodes tested to what the wallet can afford. Prevents running out of P2P mid-scan.
-
-
Test via Plan (WIP)
Plan testing is under development. Currently hidden from the dashboard. Coming in a future release.
@@ -1571,7 +1534,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 b712959..fa7c238 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;
@@ -267,18 +302,35 @@
.table-wrap { overflow-y: auto; flex: 1 1 auto; min-height: 0; padding: 0 8px 8px 8px; margin-top: 8px; }
- table { width: 100%; border-collapse: separate; border-spacing: 0 4px; }
+ table { width: 100%; border-collapse: separate; border-spacing: 0 4px; table-layout: fixed; }
+ td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ td.td-num, th.th-num { font-variant-numeric: tabular-nums; text-align: right; }
+ td.td-moniker { max-width: 180px; }
+ td.td-error { max-width: 240px; }
+
+ .pager .btn-paginate {
+ background: var(--bg-input);
+ color: var(--text);
+ border: 1px solid var(--border);
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-size: 11px;
+ cursor: pointer;
+ }
+ .pager .btn-paginate:disabled { opacity: 0.4; cursor: not-allowed; }
+ .pager .btn-paginate:not(:disabled):hover { border-color: var(--border-hover); }
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;
}
@@ -460,7 +512,7 @@
position: absolute; right: -6px; bottom: -6px; z-index: 2;
width: 46px; height: 46px; border-radius: 50%;
background: var(--yellow);
- color: #000;
+ color: var(--bg);
display: flex; align-items: center; justify-content: center;
border: 3px solid var(--bg);
box-shadow: 0 0 0 0 rgba(255, 200, 0, 0.45);
@@ -563,18 +615,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 +708,12 @@ dVPN Node Test
Current Batch
-
0 / 0 nodes tested
-
Batch size: —
@@ -693,6 +758,11 @@ dVPN Node Test
+
@@ -732,7 +802,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 +840,45 @@ 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();
+ const _liveResultsPrev = document.getElementById('liveResultsPrev');
+ const _liveResultsNext = document.getElementById('liveResultsNext');
+ if (_liveResultsPrev) _liveResultsPrev.addEventListener('click', () => liveGoPage(-1));
+ if (_liveResultsNext) _liveResultsNext.addEventListener('click', () => liveGoPage(1));
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 +891,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 +910,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() {
@@ -834,7 +975,8 @@ dVPN Node Test
// ─── State ───
let resultsArr = [];
let activeFilter = 'all';
- const MAX_ROWS = 500;
+ const LIVE_PAGE_SIZE = 50;
+ let _livePage = 1;
const MAX_LOG = 400;
// ─── Helpers ───
@@ -843,6 +985,19 @@ dVPN Node Test
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
+ function fmtUtc(ts) {
+ if (!ts) return '';
+ const n = Number(ts);
+ const ms = Number.isFinite(n) ? (n < 1e12 ? n * 1000 : n) : new Date(ts).getTime();
+ if (!Number.isFinite(ms)) return '';
+ const d = new Date(ms);
+ const yyyy = d.getUTCFullYear();
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(d.getUTCDate()).padStart(2, '0');
+ const hh = String(d.getUTCHours()).padStart(2, '0');
+ const mi = String(d.getUTCMinutes()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`;
+ }
function shortAddr(a) {
if (!a) return '--';
return a.length > 22 ? a.slice(0, 12) + '…' + a.slice(-5) : a;
@@ -888,6 +1043,7 @@ dVPN Node Test
// ─── Filter ───
function setFilter(f) {
activeFilter = f;
+ _livePage = 1;
for (const id of ['filterAll','filterPass','filterFail','filterSlow','filterWg','filterV2']) {
const el = document.getElementById(id);
if (el) {
@@ -915,30 +1071,66 @@ dVPN Node Test
// ─── Table render ───
function renderTable() {
const tbody = document.getElementById('resultsBody');
+ const wrap = tbody.closest('.table-wrap');
+ const prevScroll = wrap ? wrap.scrollTop : 0;
tbody.innerHTML = '';
- const filtered = applyFilter(resultsArr);
- if (filtered.length === 0) {
+ const filtered = applyFilter(resultsArr).slice().reverse();
+ const total = filtered.length;
+ const totalPages = Math.max(1, Math.ceil(total / LIVE_PAGE_SIZE));
+ if (_livePage > totalPages) _livePage = totalPages;
+ const start = (_livePage - 1) * LIVE_PAGE_SIZE;
+ const slice = filtered.slice(start, start + LIVE_PAGE_SIZE);
+ if (total === 0) {
tbody.innerHTML = 'No results match current filter. ';
} else {
- filtered.slice(-MAX_ROWS).reverse().forEach(r => appendRowHtml(r, tbody));
+ slice.forEach(r => appendRowHtml(r, tbody));
}
const label = activeFilter === 'all'
? resultsArr.length + ' Entries'
: filtered.length + '/' + resultsArr.length + ' (' + activeFilter.toUpperCase() + ')';
document.getElementById('resultsCountLabel').textContent = label;
+ renderLivePager(total, totalPages);
+ if (wrap) wrap.scrollTop = prevScroll;
+ }
+
+ function renderLivePager(total, totalPages) {
+ const pager = document.getElementById('liveResultsPager');
+ if (!pager) return;
+ if (totalPages <= 1) { pager.style.display = 'none'; return; }
+ pager.style.display = 'flex';
+ const startIdx = (_livePage - 1) * LIVE_PAGE_SIZE + 1;
+ const endIdx = Math.min(_livePage * LIVE_PAGE_SIZE, total);
+ document.getElementById('liveResultsPageInfo').textContent =
+ `Page ${_livePage} of ${totalPages} — ${startIdx}–${endIdx} of ${total}`;
+ document.getElementById('liveResultsPrev').disabled = _livePage <= 1;
+ document.getElementById('liveResultsNext').disabled = _livePage >= totalPages;
+ }
+
+ function liveGoPage(delta) {
+ const filtered = applyFilter(resultsArr);
+ const totalPages = Math.max(1, Math.ceil(filtered.length / LIVE_PAGE_SIZE));
+ const next = Math.min(totalPages, Math.max(1, _livePage + delta));
+ if (next === _livePage) return;
+ _livePage = next;
+ renderTable();
}
function addSingleRow(r) {
- const tbody = document.getElementById('resultsBody');
- // Clear placeholder row if present
- const placeholder = tbody.querySelector('tr td[colspan]');
- if (placeholder) tbody.innerHTML = '';
- for (const row of tbody.querySelectorAll('tr')) {
- if (row.dataset.addr === r.address) { row.remove(); break; }
+ // Re-render current page only when the affected row falls on the visible page;
+ // otherwise just bump the count label.
+ const filtered = applyFilter(resultsArr).slice().reverse();
+ const idxInFiltered = filtered.findIndex(x => x.address === r.address);
+ const onVisiblePage = idxInFiltered >= 0
+ && idxInFiltered >= (_livePage - 1) * LIVE_PAGE_SIZE
+ && idxInFiltered < _livePage * LIVE_PAGE_SIZE;
+ if (onVisiblePage) {
+ renderTable();
+ } else {
+ document.getElementById('resultsCountLabel').textContent = resultsArr.length + ' Entries';
+ const total = filtered.length;
+ const totalPages = Math.max(1, Math.ceil(total / LIVE_PAGE_SIZE));
+ renderLivePager(total, totalPages);
}
- appendRowHtml(r, tbody, true);
- document.getElementById('resultsCountLabel').textContent = resultsArr.length + ' Entries';
- if (tbody.children.length > MAX_ROWS) tbody.removeChild(tbody.lastChild);
}
function appendRowHtml(r, tbody, prepend = false) {
@@ -962,8 +1154,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,18 +1186,19 @@ dVPN Node Test
const isFail = r.actualMbps == null && !r.skipped;
const copyCell = isFail && addrFull
- ? ``
+ ? `
+ `
: '—';
tr.innerHTML = `
${proto} ${transportHtml}
- ${escHtml(monikerText)}
+ ${escHtml(monikerText)}
${addr}
${countryCol}
${cityCol}
- ${peersHtml}
- ${act}
- ${blAt}
+ ${peersHtml}
+ ${act}
+ ${blAt}
${badge}
${copyCell}
`;
@@ -1102,6 +1299,7 @@ dVPN Node Test
if (o.cat === 'fail' && o.addr) {
parts.push(``);
+ parts.push(``);
}
div.innerHTML = parts.join('');
@@ -1208,7 +1406,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 +1422,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 isTestRun = !!(_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 = isTestRun ? '' : 'none';
+ const testRunBanner = document.getElementById('testRunBanner');
+ if (testRunBanner) testRunBanner.classList.toggle('is-visible', isTestRun);
+
+ // 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 (isTestRun) {
+ 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 (isTestRun) {
+ 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 (isTestRun) {
+ 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 (isTestRun) {
+ 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 +1552,8 @@ dVPN Node Test
setInterval(() => {
if (_cb.startedAt && _cb.tested >= 0) cbRender();
- else renderLiveStats();
+ renderLiveStats();
+ applyHeaderStatsFromState();
}, 5000);
// ─── Upsert ───
@@ -1317,6 +1562,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 +1724,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 +1769,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 +1792,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,6 +1809,7 @@ 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; test-run fanout uses camelCase.
const addr = d.node_address ?? d.address ?? null;
@@ -1424,7 +1869,7 @@ dVPN Node Test
errorCode: err || null,
skipped,
inPlan: d.inPlan === true,
- testRun: err === 'TEST_RUN_SKIP',
+ testRun: err === 'TEST_RUN_SKIP',
testedAt: tested,
});
cbApplyNodeResult({ passed: resultMbps != null && resultMbps >= 10 });
@@ -1456,7 +1901,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,18 +1913,137 @@ 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;
}
}
+ let _sseEverConnected = false;
function connectSSE() {
if (!_broadcastLive) return;
if (_sse) { try { _sse.close(); } catch (_) {} }
@@ -1488,7 +2055,15 @@ dVPN Node Test
if (!d || !d.type) return;
try { handleEvent(d); } catch (_) {}
};
- _sse.onopen = () => { _sseRetry = 1000; };
+ _sse.onopen = () => {
+ _sseRetry = 1000;
+ // On reconnect, re-fetch current batch via REST so the table doesn't
+ // sit on stale data until the next live event arrives.
+ if (_sseEverConnected) {
+ try { loadCurrentBatch(); } catch (_) {}
+ }
+ _sseEverConnected = true;
+ };
_sse.onerror = () => {
try { _sse.close(); } catch (_) {}
_sse = null;
@@ -1518,6 +2093,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 +2120,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;
@@ -1564,6 +2155,15 @@ dVPN Node Test
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 (_) {}
}
@@ -1597,7 +2197,7 @@ dVPN Node Test
'Stage: ' + (er.stage || '—'),
'Error Code: ' + (er.error_code || '—'),
'Message: ' + (er.error_message || '—'),
- 'Captured: ' + (er.captured_at || '—'),
+ 'Captured: ' + (er.captured_at ? fmtUtc(er.captured_at) : '—'),
'',
];
if (er.log_snippet) { lines.push('Log Snippet:'); lines.push(er.log_snippet); }
@@ -1619,6 +2219,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 ? fmtUtc(er.captured_at) : '—';
+ 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
+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 +840,45 @@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(); + const _liveResultsPrev = document.getElementById('liveResultsPrev'); + const _liveResultsNext = document.getElementById('liveResultsNext'); + if (_liveResultsPrev) _liveResultsPrev.addEventListener('click', () => liveGoPage(-1)); + if (_liveResultsNext) _liveResultsNext.addEventListener('click', () => liveGoPage(1)); 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 +891,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 +910,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() { @@ -834,7 +975,8 @@dVPN Node Test
// ─── State ─── let resultsArr = []; let activeFilter = 'all'; - const MAX_ROWS = 500; + const LIVE_PAGE_SIZE = 50; + let _livePage = 1; const MAX_LOG = 400; // ─── Helpers ─── @@ -843,6 +985,19 @@dVPN Node Test
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + function fmtUtc(ts) { + if (!ts) return ''; + const n = Number(ts); + const ms = Number.isFinite(n) ? (n < 1e12 ? n * 1000 : n) : new Date(ts).getTime(); + if (!Number.isFinite(ms)) return ''; + const d = new Date(ms); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const hh = String(d.getUTCHours()).padStart(2, '0'); + const mi = String(d.getUTCMinutes()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`; + } function shortAddr(a) { if (!a) return '--'; return a.length > 22 ? a.slice(0, 12) + '…' + a.slice(-5) : a; @@ -888,6 +1043,7 @@dVPN Node Test
// ─── Filter ─── function setFilter(f) { activeFilter = f; + _livePage = 1; for (const id of ['filterAll','filterPass','filterFail','filterSlow','filterWg','filterV2']) { const el = document.getElementById(id); if (el) { @@ -915,30 +1071,66 @@dVPN Node Test
// ─── Table render ─── function renderTable() { const tbody = document.getElementById('resultsBody'); + const wrap = tbody.closest('.table-wrap'); + const prevScroll = wrap ? wrap.scrollTop : 0; tbody.innerHTML = ''; - const filtered = applyFilter(resultsArr); - if (filtered.length === 0) { + const filtered = applyFilter(resultsArr).slice().reverse(); + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / LIVE_PAGE_SIZE)); + if (_livePage > totalPages) _livePage = totalPages; + const start = (_livePage - 1) * LIVE_PAGE_SIZE; + const slice = filtered.slice(start, start + LIVE_PAGE_SIZE); + if (total === 0) { tbody.innerHTML = '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,18 +1186,19 @@dVPN Node Test
const isFail = r.actualMbps == null && !r.skipped; const copyCell = isFail && addrFull - ? `` + ? ` + ` : '—'; tr.innerHTML = `dVPN Node Test
if (o.cat === 'fail' && o.addr) { parts.push(``); + parts.push(``); } div.innerHTML = parts.join(''); @@ -1208,7 +1406,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 +1422,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 isTestRun = !!(_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 = isTestRun ? '' : 'none'; + const testRunBanner = document.getElementById('testRunBanner'); + if (testRunBanner) testRunBanner.classList.toggle('is-visible', isTestRun); + + // 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 (isTestRun) { + 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 (isTestRun) { + 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 (isTestRun) { + 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 (isTestRun) { + 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 +1552,8 @@dVPN Node Test
setInterval(() => { if (_cb.startedAt && _cb.tested >= 0) cbRender(); - else renderLiveStats(); + renderLiveStats(); + applyHeaderStatsFromState(); }, 5000); // ─── Upsert ─── @@ -1317,6 +1562,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 +1724,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 +1769,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 +1792,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,6 +1809,7 @@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; test-run fanout uses camelCase. const addr = d.node_address ?? d.address ?? null; @@ -1424,7 +1869,7 @@dVPN Node Test
errorCode: err || null, skipped, inPlan: d.inPlan === true, - testRun: err === 'TEST_RUN_SKIP', + testRun: err === 'TEST_RUN_SKIP', testedAt: tested, }); cbApplyNodeResult({ passed: resultMbps != null && resultMbps >= 10 }); @@ -1456,7 +1901,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,18 +1913,137 @@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; } } + let _sseEverConnected = false; function connectSSE() { if (!_broadcastLive) return; if (_sse) { try { _sse.close(); } catch (_) {} } @@ -1488,7 +2055,15 @@dVPN Node Test
if (!d || !d.type) return; try { handleEvent(d); } catch (_) {} }; - _sse.onopen = () => { _sseRetry = 1000; }; + _sse.onopen = () => { + _sseRetry = 1000; + // On reconnect, re-fetch current batch via REST so the table doesn't + // sit on stale data until the next live event arrives. + if (_sseEverConnected) { + try { loadCurrentBatch(); } catch (_) {} + } + _sseEverConnected = true; + }; _sse.onerror = () => { try { _sse.close(); } catch (_) {} _sse = null; @@ -1518,6 +2093,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 +2120,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; @@ -1564,6 +2155,15 @@dVPN Node Test
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 (_) {} } @@ -1597,7 +2197,7 @@dVPN Node Test
'Stage: ' + (er.stage || '—'), 'Error Code: ' + (er.error_code || '—'), 'Message: ' + (er.error_message || '—'), - 'Captured: ' + (er.captured_at || '—'), + 'Captured: ' + (er.captured_at ? fmtUtc(er.captured_at) : '—'), '', ]; if (er.log_snippet) { lines.push('Log Snippet:'); lines.push(er.log_snippet); } @@ -1619,6 +2219,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)}
+