From 4100ce0187b1f93d5d251d78d2553aa4110af900 Mon Sep 17 00:00:00 2001 From: Human and Agent dVPN <271368948+Sentinel-Autonomybuilder@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:17:38 -0700 Subject: [PATCH 1/4] Force-stop, fee-grant log fix, error-detail popup, resume preserves plan - Add triggerPipelineStop()/resetPipelineStop() so Stop wakes pending sleeps and snaps state.status='stopped' before V2Ray/WG cleanup - Decode full RPC fee-grant proto chain (Grant -> Any -> BasicAllowance -> Coin) so log shows real type and spend limit instead of "type=unknown limit=null" - Add per-row error-detail popup button next to copy across admin/public/live - Persist run mode so resume keeps same plan/subscription/granter - Consolidate mode/pay/loop chooser; minor handoff and docs updates --- CLAUDE.md | 10 +- admin.html | 582 +++++++++++++++++++++++-- audit/continuous.js | 18 + audit/node-test.js | 69 ++- audit/pipeline.js | 128 +++++- bin/cli.js | 5 + bin/commands/agent.js | 305 +++++++++++++ bin/lib/http.js | 108 +++++ core/chain.js | 142 ++++-- core/db.js | 15 +- core/session.js | 22 +- docs/ARCHITECTURE-PUBLIC-LIVE.md | 10 +- index.html | 10 +- live.html | 727 +++++++++++++++++++++++++++---- memory/handoff-node-tester.md | 42 +- public.html | 73 +++- scripts/cleanup-runaway-runs.mjs | 2 +- server.js | 294 +++++++++++-- 18 files changed, 2325 insertions(+), 237 deletions(-) create mode 100644 bin/commands/agent.js create mode 100644 bin/lib/http.js diff --git a/CLAUDE.md b/CLAUDE.md index d204562..3b72109 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,14 +42,14 @@ The dual-mode (dev/bundled/public) system has been collapsed. There is now **one - Read via `GET /api/broadcast` — returns `{ broadcastLive: boolean }`. - No mode cookie, no `requireMode` middleware, no `_currentMode` client state. Those are gone. -### TEST RUN (dry run) +### TEST RUN (test run) TEST RUN is an optional skip-only demo — it is NOT a separate mode and it does NOT use a separate database. -- Pass `dryRun: true` in the request body **or** `?dryRun=1` as a query parameter on `POST /api/start`. +- Pass `testRun: true` in the request body **or** `?testRun=1` as a query parameter on `POST /api/start`. - The pipeline skips plan membership check, online scan, chain operations, and payments. - Every node row gets `actualMbps: null, errorCode: 'TEST_RUN_SKIP'`. -- The run row is written to `audit.db` with `mode='dry'` so it is visually distinguishable in the admin table. +- The run row is written to `audit.db` with `mode='test'` so it is visually distinguishable in the admin table. - No second database. No `audit-dry.db`. One file on disk. ## Theme (Dark/Light) @@ -73,7 +73,7 @@ TEST RUN is an optional skip-only demo — it is NOT a separate mode and it does - `sentinel.css` — design tokens + theme. ## Existing Server Infrastructure (2026-04-25 audit) -- `POST /api/start` (adminOnly) — body `{ planId?, subscriptionId?, subscriptionGranter? }`. Accepts optional `dryRun: true` in body or `?dryRun=1` query param to run a skip-only demo audit. +- `POST /api/start` (adminOnly) — body `{ planId?, subscriptionId?, subscriptionGranter? }`. Accepts optional `testRun: true` in body or `?testRun=1` query param to run a skip-only demo audit. - `POST /api/broadcast` (adminOnly) — flips `state.broadcastLive`. No body required. - `GET /api/broadcast` — returns `{ broadcastLive: boolean }`. Open. - `GET /api/public/events` (SSE — forwards live events only when `broadcastLive=true`) @@ -85,7 +85,7 @@ TEST RUN is an optional skip-only demo — it is NOT a separate mode and it does - DONE 2026-04-23: Port search to `public.html` (#20). - DONE 2026-04-23: Build `/live` page + route (Option B). - DONE 2026-04-25: Collapsed dual-mode (dev/bundled/public) → single mode + `broadcastLive` toggle. Removed mode cookie, `requireMode` middleware, `_currentMode`, `_applyModeUI`, `selectMode`, `switchMode`, mode overlay, public-test endpoints. Added `POST /GET /api/broadcast`. -- DONE 2026-04-25: Consolidated `audit-dry.db` into `audit.db`. TEST RUN is now `?dryRun=1` on `/api/start`, writes `mode='dry'` rows to the single DB. +- DONE 2026-04-25: Consolidated `audit-dry.db` into `audit.db`. TEST RUN is now `?testRun=1` on `/api/start`, writes `mode='test'` rows to the single DB. - DONE 2026-04-25: TEST RUN parity — runs the real pipeline end-to-end, short-circuits per-node after price discovery with `errorCode='TEST_RUN_SKIP'`. Stripped all `public-test:*` SSE prefixes, `_pipelinePublicMode`, three `/api/admin/public-test/*` endpoints, `DRY_RUN_SKIP`, `dry-run:log`, `#dryRunLoop`. Broadcast Live toggle moved to top action cluster. - DONE: Theme toggle on `public.html` (#22) — `#btnTheme` + `toggleTheme()` already wired. diff --git a/admin.html b/admin.html index b5be1b4..07be108 100644 --- a/admin.html +++ b/admin.html @@ -787,24 +787,44 @@

SENTINEL NODE TEST

+
+ + ›_ + CLI + +
- + -
-
+ + + +
+
@@ -1036,7 +1056,7 @@

SENTINEL NODE TEST

} catch {} window.location.href = '/'; } - let state = { status: 'idle', dryRun: false, totalNodes: 0, testedNodes: 0, failedNodes: 0, retryCount: 0, passed15: 0, passedBaseline: 0, baselineMbps: null, baselineHistory: [], nodeSpeedHistory: [], startedAt: null, pauseReason: null }; + let state = { status: 'idle', testRun: false, totalNodes: 0, testedNodes: 0, failedNodes: 0, retryCount: 0, passed15: 0, passedBaseline: 0, baselineMbps: null, baselineHistory: [], nodeSpeedHistory: [], startedAt: null, pauseReason: null }; let resultsArr = []; // Full country map — 80+ countries from Sentinel SDK (core/countries.js) const _CC = { @@ -1199,7 +1219,7 @@

SENTINEL NODE TEST

done = state.retestTested || 0; total = state.retestTotal || 0; } else { - done = (state.testedNodes || 0) + (state.failedNodes || 0); + done = (state.testedNodes || 0) + (state.failedNodes || 0) + (state.skippedNodes || 0); total = state.totalNodes || 0; } const remaining = total - done; @@ -1216,6 +1236,47 @@

SENTINEL NODE TEST

const active = running || paused; document.getElementById('headerDot').className = 'pulse-dot' + (running ? ' running' : paused ? ' paused' : ''); + // ─── Mode badge: clear "what's running right now" ────────────────── + // Three states the operator and viewer must distinguish at a glance: + // • Plan #N — testing only nodes inside a subscription plan + // • P2P — testing every online node, paying each session ourselves + // • Test Run — sample/skip data, no real measurements + const modeBadge = document.getElementById('modeBadge'); + if (modeBadge) { + if (!active) { + modeBadge.style.display = 'none'; + } else { + const lbl = document.getElementById('modeBadgeLabel'); + const det = document.getElementById('modeBadgeDetail'); + const dot = document.getElementById('modeBadgeDot'); + let label, detail, color; + if (state.testRun || state.runMode === 'test') { + label = 'Test Run'; + detail = 'sample data — no real measurements, no payments'; + color = 'var(--accent)'; + } else if (state.runMode === 'subscription' && state.runPlanId) { + label = 'Plan #' + state.runPlanId; + detail = 'subscription-allocated sessions, plan-scoped node set'; + color = 'var(--green)'; + } else { + label = 'P2P'; + detail = 'all online nodes, per-session payments from this wallet'; + color = 'var(--accent-bright, var(--accent))'; + } + modeBadge.style.display = ''; + modeBadge.style.borderColor = color; + modeBadge.style.color = color; + if (dot) dot.style.background = color; + if (lbl) lbl.textContent = label; + if (det) det.textContent = detail; + } + } + + // Mode/pay/loop UI lives in the New Test chooser popup now — the toolbar + // stubs are display:none. Mode is locked by virtue of the chooser only + // opening when the operator clicks New Test, which is itself disabled + // while a run is active. + const btnStart = document.getElementById('btnStart'); const btnResume = document.getElementById('btnResume'); const btnRetest = document.getElementById('btnRetestFails'); @@ -1229,13 +1290,13 @@

SENTINEL NODE TEST

const label = paused ? 'Paused' : 'Running...'; btnStart.textContent = label; btnStart.classList.add('loading'); - btnStop.textContent = 'Stop Audit'; + btnStop.textContent = 'Stop Test'; btnStop.classList.remove('loading'); } else { btnStart.textContent = 'New Test'; btnStart.classList.remove('loading'); if (btnResume) { btnResume.textContent = 'Resume'; btnResume.classList.remove('loading'); btnResume.disabled = false; } - btnStop.textContent = 'Stop Audit'; + btnStop.textContent = 'Stop Test'; btnStop.classList.remove('loading'); } @@ -1274,34 +1335,52 @@

SENTINEL NODE TEST

} const t = state.totalNodes || 0; - const done = state.testedNodes || 0; const fail = state.failedNodes || 0; + const skipped = state.skippedNodes || 0; const p10 = state.passed10 || 0; - const processed = done + fail; - - document.getElementById('statTested').textContent = processed; + // The SSE `result` event delivers every row once and we upsert by + // address — `resultsArr.length` is the authoritative processed count. + // Don't sum state counters; they double-count whenever the pipeline + // increments a counter before the next recomputeCounters pass. + const processed = Array.isArray(resultsArr) ? resultsArr.length : 0; + const done = resultsArr ? resultsArr.filter(r => r && r.actualMbps != null).length : (state.testedNodes || 0); + if (t > 0) { + document.getElementById('statTested').textContent = `${processed.toLocaleString()} / ${t.toLocaleString()}`; + } else { + document.getElementById('statTested').textContent = processed.toLocaleString(); + } if (t > 0 && t > processed) { const remaining = t - processed; - document.getElementById('statTestedSub').textContent = `of ${t.toLocaleString()} total | ${remaining.toLocaleString()} remaining`; + document.getElementById('statTestedSub').textContent = `${remaining.toLocaleString()} remaining`; } else if (t > 0 && t <= processed) { - document.getElementById('statTestedSub').textContent = `of ${t.toLocaleString()} total | complete`; + document.getElementById('statTestedSub').textContent = 'complete'; } else { document.getElementById('statTestedSub').textContent = processed > 0 ? `${processed} tested | hit Rescan for total` : 'no data yet'; } + // TEST RUN detection — mirrors /live's logic so SLA-derived tiles can + // dash out (those metrics are not measured during a skip-only demo). + const isDryRun = !!state.testRun || + resultsArr.some(r => r && r.errorCode === 'TEST_RUN_SKIP'); + const DASH = '—'; + // Total Failed - document.getElementById('statTotalFailed').textContent = fail; + document.getElementById('statTotalFailed').textContent = isDryRun ? DASH : fail; const failPct = processed > 0 ? (fail / processed * 100).toFixed(1) : 0; - document.getElementById('statTotalFailedPct').textContent = failPct + '% failure rate'; + document.getElementById('statTotalFailedPct').textContent = isDryRun + ? 'not measured in test run' + : failPct + '% failure rate'; // 10 Mbps SLA + pass rate combined - document.getElementById('statPassed').textContent = p10; + document.getElementById('statPassed').textContent = isDryRun ? DASH : p10; const passRate = done > 0 ? (p10 / done * 100).toFixed(1) : 0; - document.getElementById('statPassedPct').textContent = passRate + '% of connected'; + document.getElementById('statPassedPct').textContent = isDryRun + ? 'not measured in test run' + : passRate + '% of connected'; // Pass Rate = successful connections / total tested const connRate = processed > 0 ? (done / processed * 100).toFixed(1) : 0; - document.getElementById('statPassRate').textContent = connRate + '%'; + document.getElementById('statPassRate').textContent = isDryRun ? DASH : connRate + '%'; // Not Online: failed nodes with 0 peers or null peers const notOnline = resultsArr.filter(r => r.actualMbps == null && (r.peers === 0 || r.peers == null)).length; @@ -1330,11 +1409,13 @@

SENTINEL NODE TEST

const currentSDK = state.activeSDK || 'js'; const currentRunResults = resultsArr.filter(r => !r.sdk || r.sdk === currentSDK); const deadPlan = currentRunResults.filter(r => r.inPlan && r.actualMbps == null).length; - document.getElementById('statDeadPlan').textContent = deadPlan; + document.getElementById('statDeadPlan').textContent = isDryRun ? DASH : deadPlan; const totalPlanTested = currentRunResults.filter(r => r.inPlan).length; - document.getElementById('statDeadPlanPct').textContent = totalPlanTested > 0 - ? `${deadPlan}/${totalPlanTested} plan nodes failed` - : 'No plan failures'; + document.getElementById('statDeadPlanPct').textContent = isDryRun + ? 'not measured in test run' + : (totalPlanTested > 0 + ? `${deadPlan}/${totalPlanTested} plan nodes failed` + : 'No plan failures'); const retryCount = state.retryCount || 0; const fill = document.getElementById('progressFill'); @@ -1553,7 +1634,8 @@

SENTINEL NODE TEST

const isFail = r.actualMbps == null && !r.skipped; const copyCell = isFail && addrFull - ? `` + ? ` + ` : ''; tr.innerHTML = ` @@ -1684,11 +1766,94 @@

SENTINEL NODE TEST

} }).catch(() => {}); - async function devStart(dryRun) { + let _pricingMode = 'gigabytes'; + function setPricingMode(mode) { + _pricingMode = mode === 'hours' ? 'hours' : 'gigabytes'; + const gb = document.getElementById('priceModeGB'); + const hr = document.getElementById('priceModeHr'); + const active = 'background:var(--accent);color:var(--bg-card-solid);border:0;padding:0 12px;height:100%;cursor:pointer;font:inherit;letter-spacing:inherit;'; + const idle = 'background:transparent;color:var(--text-dim);border:0;padding:0 12px;height:100%;cursor:pointer;font:inherit;letter-spacing:inherit;'; + const idleR = idle + 'border-left:1px solid var(--border);'; + if (gb && hr) { + gb.setAttribute('style', _pricingMode === 'gigabytes' ? active : idle); + hr.setAttribute('style', _pricingMode === 'hours' ? active + 'border-left:1px solid var(--border);' : idleR); + } + try { localStorage.setItem('sntPricingMode', _pricingMode); } catch {} + } + try { + const saved = localStorage.getItem('sntPricingMode'); + if (saved === 'hours') setPricingMode('hours'); + } catch {} + + // ─── Testing Mode (PLAN / P2P / TEST RUN) ──────────────────────────────── + // One mutually-exclusive mode drives Start Test, Resume, and Retest Failed. + // ∞ LOOP is orthogonal — it applies to whichever mode is active. + // _selectedPlanId: when PLAN mode is active and a plan has been picked from + // the modal, the MODE box shows "PLAN #N" instead of just "PLAN". + let _testingMode = 'p2p'; + let _selectedPlanId = null; + let _selectedSubscriptionId = null; + let _selectedGranter = null; + function _renderPlanLabel() { + const lbl = document.getElementById('modePlanLabel'); + if (!lbl) return; + lbl.textContent = (_testingMode === 'plan' && _selectedPlanId) ? ('PLAN #' + _selectedPlanId) : 'PLAN'; + } + function setTestingMode(mode, opts) { + const prev = _testingMode; + _testingMode = (mode === 'plan' || mode === 'testrun') ? mode : 'p2p'; + const ids = { plan: 'modePlan', p2p: 'modeP2P', testrun: 'modeTestRun' }; + Object.entries(ids).forEach(([m, id]) => { + const el = document.getElementById(id); + if (!el) return; + if (m === _testingMode) { + el.style.background = 'var(--accent)'; + el.style.color = 'var(--bg-card-solid)'; + } else { + el.style.background = 'transparent'; + el.style.color = 'var(--text-dim)'; + } + }); + // Pricing toggle is meaningless in PLAN mode (subscription-allocated sessions) + const priceWrap = document.getElementById('pricingModeWrap'); + if (priceWrap) priceWrap.style.opacity = _testingMode === 'plan' ? '0.45' : '1'; + // Switching away from PLAN clears the previously-picked plan id. + if (_testingMode !== 'plan') { + _selectedPlanId = null; + _selectedSubscriptionId = null; + _selectedGranter = null; + } + _renderPlanLabel(); + try { localStorage.setItem('sntTestingMode', _testingMode); } catch {} + } + + // Clicking PLAN should open the picker WITHOUT pre-committing the mode. + // If the operator dismisses the modal without picking a plan, the toggle + // stays on whatever was previously active (P2P, TEST RUN, or a previously + // chosen plan). Only runSubPlanTest() (called when the operator actually + // picks a plan from the modal) commits the mode via setTestingMode('plan', + // { fromPick: true }). + function onClickPlanMode() { + try { openSubPlanModal(); } catch {} + } + try { + const savedMode = localStorage.getItem('sntTestingMode'); + if (savedMode) setTestingMode(savedMode, { fromPick: true }); + } catch {} + + function isTestRunMode() { return _testingMode === 'testrun'; } + function isPlanMode() { return _testingMode === 'plan'; } + + async function devStart(testRun) { + const loopEl = document.getElementById('infiniteLoop'); + const infiniteLoop = !!(loopEl && loopEl.checked); + const body = { pricingMode: _pricingMode }; + if (testRun) body.testRun = true; + if (infiniteLoop) body.infiniteLoop = true; const res = await fetch('/api/start', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Request': '1' }, - body: JSON.stringify(dryRun ? { dryRun: true } : {}), + body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); if (data.error) { appendLog('Start failed: ' + data.error); return false; } @@ -1697,8 +1862,118 @@

SENTINEL NODE TEST

async function startAudit() { if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; } - const dryEl = document.getElementById('dryRunDev'); - const isDryRun = !!(dryEl && dryEl.checked); + // Open the mode chooser — operator picks Subscription Plan / Peer to Peer / Test Run. + // Each tile commits the testing mode and then calls _startAuditAfterChoice(). + openModeChooser(); + } + + function openModeChooser() { + // Tear down any previous chooser + const existing = document.getElementById('modeChooserBackdrop'); + if (existing) existing.remove(); + + const backdrop = document.createElement('div'); + backdrop.id = 'modeChooserBackdrop'; + backdrop.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9998;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);'; + + const modal = document.createElement('div'); + modal.style.cssText = 'background:var(--bg-card-solid);border:1px solid var(--border-hover);border-radius:10px;padding:28px;max-width:920px;width:92%;max-height:90vh;overflow:auto;color:var(--text);font-family:var(--font-display);'; + + const close = () => backdrop.remove(); + backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); }); + const onKey = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } }; + document.addEventListener('keydown', onKey); + + modal.innerHTML = ` +
+
Choose Test Mode
+ +
+
Mode is locked once a test starts. To change, stop and start a new test.
+ +
+
+
SUBSCRIPTION PLAN
+
Test only nodes inside a subscription plan. Sessions are subscription-allocated; the plan owner pays gas via fee grant.
+
Opens the plan picker.
+
+
+
PEER TO PEER
+
Test every online node, paying each session from this wallet directly.
+
+ PRICING: + + +
+
Note: refund-on-disconnect (closing the locked escrow with the node after each connection) is under research — current behavior locks the per-session quote until the chain settles it.
+
+
+
TEST RUN
+
Skip-only demo. Runs the full pipeline but skips per-node connect + payment. Every node row gets TEST_RUN_SKIP.
+
No real measurements. No payments. One database.
+
+
+ `; + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + modal.querySelector('#modeChooserClose').addEventListener('click', close); + + // Hover styling + const tiles = modal.querySelectorAll('.mode-tile'); + tiles.forEach(t => { + t.addEventListener('mouseenter', () => { t.style.borderColor = 'var(--accent)'; t.style.transform = 'translateY(-1px)'; }); + t.addEventListener('mouseleave', () => { t.style.borderColor = 'var(--border-hover)'; t.style.transform = ''; }); + }); + + // P2P pricing toggle inside the chooser + let chooserPricingMode = (typeof _pricingMode === 'string' && _pricingMode) ? _pricingMode : 'gigabytes'; + const pmGB = modal.querySelector('#p2pPriceGB'); + const pmHr = modal.querySelector('#p2pPriceHr'); + const syncPm = () => { + if (chooserPricingMode === 'hours') { + pmHr.style.background = 'var(--accent)'; pmHr.style.color = 'var(--bg-card-solid)'; pmHr.style.border = '0'; + pmGB.style.background = 'transparent'; pmGB.style.color = 'var(--text-dim)'; pmGB.style.border = '1px solid var(--border)'; + } else { + pmGB.style.background = 'var(--accent)'; pmGB.style.color = 'var(--bg-card-solid)'; pmGB.style.border = '0'; + pmHr.style.background = 'transparent'; pmHr.style.color = 'var(--text-dim)'; pmHr.style.border = '1px solid var(--border)'; + } + }; + syncPm(); + pmGB.addEventListener('click', (e) => { e.stopPropagation(); chooserPricingMode = 'gigabytes'; syncPm(); }); + pmHr.addEventListener('click', (e) => { e.stopPropagation(); chooserPricingMode = 'hours'; syncPm(); }); + + // Sync chooser's ∞ LOOP into the hidden #infiniteLoop the start path reads. + const syncLoop = () => { + const live = document.getElementById('infiniteLoop'); + const choice = modal.querySelector('#chooserInfiniteLoop'); + if (live && choice) live.checked = !!choice.checked; + }; + + modal.querySelector('#tileSubPlan').addEventListener('click', () => { + syncLoop(); + close(); + // setTestingMode is committed by the picker callback (fromPick:true). + try { openSubPlanModal(); } catch {} + }); + modal.querySelector('#tileP2P').addEventListener('click', async () => { + syncLoop(); + close(); + try { setPricingMode(chooserPricingMode); } catch {} + setTestingMode('p2p', { fromPick: true }); + await _startAuditAfterChoice(false); + }); + modal.querySelector('#tileTestRun').addEventListener('click', async () => { + syncLoop(); + close(); + setTestingMode('testrun', { fromPick: true }); + await _startAuditAfterChoice(true); + }); + } + + async function _startAuditAfterChoice(isDryRun) { if (!isDryRun && !confirm('Start a NEW test? Current results will be saved and a fresh test begins.')) return; // Clear previous test data from dashboard resultsArr = []; @@ -1728,25 +2003,26 @@

SENTINEL NODE TEST

try { if (!await devStart(isDryRun)) { btn.classList.remove('loading'); - btn.textContent = 'New Test'; + btn.textContent = 'Start Test'; btn.disabled = false; return; } loadRunsList(); } catch (e) { btn.classList.remove('loading'); - btn.textContent = 'New Test'; + btn.textContent = 'Start Test'; btn.disabled = false; } } async function resumeAudit() { - const dryEl = document.getElementById('dryRunDev'); - if (dryEl && dryEl.checked) { + if (isTestRunMode()) { if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; } await devStart(true); return; } + // Plan mode: server-side resume is mode-aware and uses the persisted + // planId/subId/granter from the run that was paused — DO NOT re-prompt. const btn = document.getElementById('btnResume'); btn.classList.add('loading'); btn.textContent = 'Resuming...'; @@ -1773,8 +2049,7 @@

SENTINEL NODE TEST

} async function retestFails() { - const dryEl = document.getElementById('dryRunDev'); - if (dryEl && dryEl.checked) { + if (isTestRunMode()) { if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; } await devStart(true); return; @@ -1829,6 +2104,118 @@

SENTINEL NODE TEST

} } + // Sentinel Audit CLI reference popup. Mirrors the bundled `bin/cli.js` + // (npm bin: `sentinel-audit`). Closeable via × button, Escape, or + // backdrop click — same UX as showInfoPopup(). + function showCliPopup() { + const existing = document.getElementById('cliOverlay'); + if (existing) { existing.remove(); return; } + + const overlay = document.createElement('div'); + overlay.id = 'cliOverlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:var(--glass-bg);backdrop-filter:blur(8px);z-index:9999;display:flex;align-items:center;justify-content:center;'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + const popup = document.createElement('div'); + popup.style.cssText = 'background:var(--bg-card-solid);border:1px solid var(--border-hover);border-radius:16px;padding:28px 32px;max-width:760px;width:92%;max-height:86vh;overflow-y:auto;color:var(--text);font-family:var(--font-display);line-height:1.55;box-shadow:var(--shadow-lg)'; + + const codeStyle = 'font-family:var(--font-mono,ui-monospace,Menlo,Consolas,monospace);background:var(--bg-input);color:var(--text);padding:2px 6px;border-radius:4px;font-size:12px;border:1px solid var(--border);'; + const blockStyle = 'font-family:var(--font-mono,ui-monospace,Menlo,Consolas,monospace);background:var(--bg-input);color:var(--text);padding:10px 12px;border-radius:6px;font-size:12px;border:1px solid var(--border);white-space:pre;overflow-x:auto;line-height:1.6;'; + + popup.innerHTML = ` +
+
+

Sentinel Audit CLI

+
Headless control of the same audit pipeline that powers this dashboard
+
+ × +
+ +
+
+
Install & Invoke
+
Bundled in this repo as bin/cli.js and exposed via the npm bin sentinel-audit.
+
# From a clone of the repo +npm install +node bin/cli.js list + +# Or install globally and use the bin directly +npm install -g . +sentinel-audit --help
+
+ +
+
Global Flags
+
--help, -h Show help for the command (or top-level) +--version, -v Print version +--json Force JSON output (default for most commands) +--pretty Human-readable output +--lcd <url> Override LCD endpoint +--sdk <js|tkd> SDK for chain queries (default: js)
+
+ +
+
Discovery
+
list — Every command with description + flags. AI agents use this as a registry.
+
functions — Every exported SDK function grouped by module.
+
verify-sdks — Verify installed SDKs match their GitHub tag (byte-for-byte).
+
+ +
+
Read (chain queries — no tokens spent)
+
nodes — List all active chain nodes.
+
node <addr> — Single node details.
+
balance [addr] — P2P token balance for a wallet (default: configured mnemonic).
+
subscriptions — List subscriptions for the configured wallet.
+
plans — List all on-chain plans.
+
+ +
+
Action (spends real tokens / starts processes)
+
speed — Baseline internet speed test (no VPN).
+
test <addr> — End-to-end test one node. Pays a real session fee.
+
audit — Full audit loop across every node. Long-running.
+
serve — Start the browser dashboard server (this UI).
+
+ +
+
Agent
+
agent — End-to-end driver hitting every server function over HTTP. Useful for AI / automation. Run sentinel-audit agent map --pretty for the registry.
+
+ +
+
Examples
+
sentinel-audit list +sentinel-audit nodes --pretty +sentinel-audit node sentnode1abc... --pretty +sentinel-audit balance --pretty +sentinel-audit test sentnode1abc... --pretty +sentinel-audit serve --port 3001 + +# AI quick-start (machine-readable) +sentinel-audit list +sentinel-audit functions
+
+ +
+
Notes
+
• Reads .env from the package root for MNEMONIC, RPC_ENDPOINTS, etc.
+
• Default output is JSON so agents can pipe it. Add --pretty for human reading.
+
• The serve command starts the same Express server this dashboard runs on (port 3001 by default).
+
+
+ +
+ Press Escape or click outside to close +
+ `; + overlay.appendChild(popup); + document.body.appendChild(overlay); + document.addEventListener('keydown', function esc(e) { + if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', esc); } + }); + } + function showInfoPopup() { const existing = document.getElementById('infoOverlay'); if (existing) { existing.remove(); return; } @@ -1868,7 +2255,7 @@

-
Stop Audit
+
Stop Test
Gracefully stops the current scan after the active node finishes. Results are saved. You can Resume later.

@@ -1928,13 +2315,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 +2348,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 +2365,7 @@

Plan ${p.planId} ${badge} + ${allocBadge}

Subscription #${p.subscriptionId} @@ -1995,8 +2385,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 = er.captured_at ? new Date(typeof er.captured_at === 'number' ? er.captured_at : Number(er.captured_at) || er.captured_at).toLocaleString() : '—'; + const row = (label, value) => ` +
+
${label}
+
${value || '—'}
+
`; + body.innerHTML = ` + ${row('Node', escapeHtml(moniker))} + ${row('Address', `${escapeHtml(addr)}`)} + ${row('Stage', escapeHtml(er.stage))} + ${row('Error Code', `${escapeHtml(er.error_code)}`)} + ${row('Captured', escapeHtml(captured))} + ${row('Message', `${escapeHtml(er.error_message)}`)} + ${er.log_snippet ? ` +
+
Log Snippet
+
${escapeHtml(er.log_snippet)}
+
` : ''} +
+ + +
+ `; + } catch (err) { + const body = document.getElementById('errorPopupBody'); + if (body) body.innerHTML = '
Failed to load: ' + (err.message || 'unknown') + '
'; + } + } + // ═══════════════════════════════════════════════════════════════════════ // ─── Broadcast Live ────────────────────────────────────────────────── // ═══════════════════════════════════════════════════════════════════════ diff --git a/audit/continuous.js b/audit/continuous.js index 7a423fb..fda5129 100644 --- a/audit/continuous.js +++ b/audit/continuous.js @@ -243,9 +243,27 @@ async function _runOnePass(loopState, batchId, frozenNodes = null) { }; function batchBroadcast(type, data) { + // Forward live log lines + state snapshots through the scoped emitter so + // the admin dashboard AND /live (when broadcastLive=true) see real-time + // activity during continuous-loop runs — not just batch checkpoints. + if (type === 'log') { + _emitScoped('log', data || {}); + return; + } + if (type === 'state') { + _emitScoped('state', data || {}); + return; + } + if (type === 'progress') { + _emitScoped('progress', data || {}); + return; + } if (type !== 'result') return; // only care about per-node results const raw = data?.result; if (!raw) return; + // Forward the full per-node result to the global stream so the live + // dashboard upserts rows identically to admin. Public SSE will sanitize. + _emitScoped('result', { result: raw, batchId }); const payload = _sanitizeBatchNodeResult(raw, batchId); _emitScoped('batch:node:result', payload); // Persist to batch_results (non-blocking, non-fatal) diff --git a/audit/node-test.js b/audit/node-test.js index 96b752e..55b395b 100644 --- a/audit/node-test.js +++ b/audit/node-test.js @@ -64,6 +64,30 @@ function logFailure(nodeAddr, error, context = {}) { appendFileSync(FAILURE_LOG, JSON.stringify(entry) + '\n', 'utf8'); } +// Sleep but break out within ~250ms when state.stopRequested flips. +async function stopAwareSleep(ms, state) { + const tick = 250; + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (state?.stopRequested) throw new Error('Stop requested'); + await sleep(Math.min(tick, deadline - Date.now())); + } + if (state?.stopRequested) throw new Error('Stop requested'); +} + +// Race a promise against state.stopRequested polling so long awaits abort fast. +async function withStopGuard(promise, state) { + let cancelled = false; + const stopPoll = new Promise((_, reject) => { + const iv = setInterval(() => { + if (cancelled) { clearInterval(iv); return; } + if (state?.stopRequested) { clearInterval(iv); reject(new Error('Stop requested')); } + }, 250); + }); + try { return await Promise.race([promise, stopPoll]); } + finally { cancelled = true; } +} + /** * Test a single node. Returns a TestResult or null if fundamentally untestable. * With the zero-skip system, null is only returned for truly untestable cases @@ -164,15 +188,29 @@ export async function testNode(client, account, privkey, node, opts, preSessionI } // ─── Price check ────────────────────────────────────────────────────────── - const priceEntry = (node.gigabyte_prices || []).find(p => p.denom === denom); - if (!priceEntry) { + // Pricing mode: 'gigabytes' (default) or 'hours'. + // In subscription-plan flows the pipeline supplies its own messages — this + // toggle only affects the P2P MsgStartSession path below. + const pricingMode = state.pricingMode === 'hours' ? 'hours' : 'gigabytes'; + const gigabytePrice = (node.gigabyte_prices || []).find(p => p.denom === denom); + const hourlyPrice = (node.hourly_prices || []).find(p => p.denom === denom); + + if (pricingMode === 'hours' && !hourlyPrice) { + throw new Error('No hourly udvpn pricing available (node has no hourly_prices entry)'); + } + if (pricingMode === 'gigabytes' && !gigabytePrice) { throw new Error('No udvpn pricing available'); } + const priceEntry = pricingMode === 'hours' ? hourlyPrice : gigabytePrice; + const sessionGigabytes = pricingMode === 'hours' ? 0 : gigabytes; + const sessionHours = pricingMode === 'hours' ? 1 : 0; + const priceUnits = pricingMode === 'hours' ? sessionHours : sessionGigabytes; + const nodePriceUdvpn = Math.round(parseFloat(priceEntry.quote_value) || 0); - const thisCostUdvpn = nodePriceUdvpn * gigabytes; + const thisCostUdvpn = nodePriceUdvpn * priceUnits; - if (state.dryRun) { + if (state.testRun) { if (broadcast) broadcast('log', { msg: ' 🧪 TEST RUN — skipping payment + handshake + speedtest.' }); const _reportedDownloadMbps = status.bandwidth.download * 8 / 1_000_000; return { @@ -241,7 +279,8 @@ export async function testNode(client, account, privkey, node, opts, preSessionI if (broadcast) broadcast('log', { msg: `⚠ LOW BALANCE: ${(remainingBalance / 1_000_000).toFixed(4)} P2P` }); } - const costLabel = preSessionId ? 'pre-paid' : sessionId ? '0 (reuse)' : thisCostUdvpn + ' udvpn'; + const unitLabel = pricingMode === 'hours' ? `${sessionHours}h` : `${sessionGigabytes}GB`; + const costLabel = preSessionId ? 'pre-paid' : sessionId ? '0 (reuse)' : `${thisCostUdvpn} udvpn (${unitLabel})`; if (broadcast) broadcast('log', { msg: `→ ${typeName} | ${status.location.city}, ${status.location.country} | ${reportedDownloadMbps.toFixed(1)} Mbps | Cost: ${costLabel}`, }); @@ -257,7 +296,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes, hours: 0, + gigabytes: sessionGigabytes, hours: sessionHours, max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value }, }, }], fee, broadcast); @@ -280,7 +319,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes, hours: 0, + gigabytes: sessionGigabytes, hours: sessionHours, max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value }, }, }], fee, broadcast); @@ -295,7 +334,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes, hours: 0, + gigabytes: sessionGigabytes, hours: sessionHours, max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value }, }, }], fee, broadcast); @@ -315,7 +354,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes, hours: 0, + gigabytes: sessionGigabytes, hours: sessionHours, }, }], fee, broadcast); assertIsDeliverTxSuccess(txResult); @@ -338,11 +377,11 @@ export async function testNode(client, account, privkey, node, opts, preSessionI markPaid(node.address); if (broadcast) broadcast('log', { msg: ` Session ${sessionId} — polling for chain confirmation…` }); - await waitForSessionActive(node.address, account.address, 20_000, sessionId); + await withStopGuard(waitForSessionActive(node.address, account.address, 20_000, sessionId), state); // Extra delay: node needs time to index the session into its own DB // Without this, handshake races with node indexing → 409 "already exists" if (broadcast) broadcast('log', { msg: ` Waiting 5s for node to index session…` }); - await sleep(5_000); + await stopAwareSleep(5_000, state); } // ─── Handshake + Connect ────────────────────────────────────────────────── @@ -360,7 +399,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes, hours: 0, + gigabytes: sessionGigabytes, hours: sessionHours, max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value }, }, }], fee, broadcast); @@ -370,7 +409,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI if (broadcast) broadcast('log', { msg: ` ⚠ "invalid price" — retrying fresh session without max_price...` }); txResult = await signAndBroadcastRetry(client, account.address, [{ typeUrl: V3_MSG_TYPE, - value: { from: account.address, node_address: node.address, gigabytes, hours: 0 }, + value: { from: account.address, node_address: node.address, gigabytes: sessionGigabytes, hours: sessionHours }, }], fee, broadcast); assertIsDeliverTxSuccess(txResult); } else { @@ -388,8 +427,8 @@ export async function testNode(client, account, privkey, node, opts, preSessionI state.estimatedTotalCost = `${(state.spentUdvpn / 1_000_000).toFixed(4)} P2P`; if (broadcast) broadcast('state', { state }); if (broadcast) broadcast('log', { msg: ` Fresh session ${sessionId} — waiting for chain + node indexing...` }); - await waitForSessionActive(node.address, account.address, 20_000, sessionId); - await sleep(5_000); + await withStopGuard(waitForSessionActive(node.address, account.address, 20_000, sessionId), state); + await stopAwareSleep(5_000, state); return sessionId; } diff --git a/audit/pipeline.js b/audit/pipeline.js index bd5565c..5b8ec26 100644 --- a/audit/pipeline.js +++ b/audit/pipeline.js @@ -20,7 +20,48 @@ import { clearPoisonedSessions, clearPaidNodes, clearAllCredentials, invalidateSessionCache, parseNodePriceUdvpn, } from '../core/session.js'; import { nodeStatusV3 } from '../protocol/v3protocol.js'; -import { speedtestDirect, sleep, resolveCfHost } from '../protocol/speedtest.js'; +import { speedtestDirect, sleep as _rawSleep, resolveCfHost } from '../protocol/speedtest.js'; + +// ─── Stop-aware sleep ──────────────────────────────────────────────────────── +// Every sleep in the pipeline races against a module-scope stop signal. +// When `triggerPipelineStop()` is called (from /api/stop), every pending +// sleep resolves instantly so the loop drops back to its `if (state.stopRequested) break` +// check on the next tick — cutting Stop latency from "until next timer fires" +// (up to 5 minutes for sleep(5*60_000)) to single-digit ms. +const _stopWaiters = new Set(); +let _pipelineStopFlag = false; +function sleep(ms) { + if (_pipelineStopFlag) return Promise.resolve(); + return new Promise((resolve) => { + let timer = setTimeout(() => { _stopWaiters.delete(resolve); resolve(); }, ms); + const wake = () => { try { clearTimeout(timer); } catch {} resolve(); }; + _stopWaiters.add(wake); + }); +} +export function triggerPipelineStop() { + _pipelineStopFlag = true; + for (const w of _stopWaiters) { try { w(); } catch {} } + _stopWaiters.clear(); +} +export function resetPipelineStop() { + _pipelineStopFlag = false; +} +// Race any awaitable against the stop signal. The promise rejects with a +// `_stopRequested` marker the per-node loop already catches and breaks on. +function raceStop(promise) { + return new Promise((resolve, reject) => { + if (_pipelineStopFlag) { + const e = new Error('Stop requested'); e._stopRequested = true; return reject(e); + } + let done = false; + const wake = () => { if (done) return; done = true; _stopWaiters.delete(wake); const e = new Error('Stop requested'); e._stopRequested = true; reject(e); }; + _stopWaiters.add(wake); + promise.then( + (v) => { if (done) return; done = true; _stopWaiters.delete(wake); resolve(v); }, + (e) => { if (done) return; done = true; _stopWaiters.delete(wake); reject(e); }, + ); + }); +} // Platform-aware imports — Windows has full implementation, others get stubs let WG_AVAILABLE, IS_ADMIN, emergencyCleanupSync, uninstallWgTunnel, checkV2Ray; if (process.platform === 'win32') { @@ -39,7 +80,9 @@ import { checkAndPauseIfInterference, classifyFailure } from '../protocol/diagno import { loadTransportCache, getCacheStats, saveTransportCache } from '../core/transport-cache.js'; import { testNode } from './node-test.js'; import { testWithRetry } from './retry.js'; -import { insertResult as _dbInsertResult, insertErrorLog as _dbInsertErrorLog } from '../core/db.js'; +import { insertResult as _dbInsertResult, insertErrorLog as _dbInsertErrorLog, + insertBatch as _dbInsertBatch, insertBatchResult as _dbInsertBatchResult, + updateBatchOnFinish as _dbUpdateBatchOnFinish } from '../core/db.js'; // ─── Internet Health Check & Auto-Resume ───────────────────────────────────── const INTERNET_CHECK_TARGETS = ['https://www.google.com', 'https://1.1.1.1', 'https://www.cloudflare.com']; @@ -237,6 +280,13 @@ export function createState() { stopRequested: false, lowBalanceWarning: false, pauseReason: null, + testRun: false, + continuousLoop: false, + pricingMode: 'gigabytes', + runMode: null, + runPlanId: null, + runSubscriptionId: null, + runGranter: null, }; } @@ -324,6 +374,7 @@ function buildFailResult(node, status, state, errMsg, diag = {}) { // ─── Main Audit ───────────────────────────────────────────────────────────── export async function runAudit(resume, state, broadcast, preloadedNodes = null, opts = {}) { + resetPipelineStop(); state.status = 'running'; state.startedAt = new Date().toISOString(); state.errorMessage = null; @@ -332,7 +383,8 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null, state.retestPassed = null; state.retestFailed = null; - state.dryRun = !!opts.dryRun; + state.testRun = !!opts.testRun; + state.pricingMode = (opts.pricingMode === 'hours') ? 'hours' : 'gigabytes'; clearPoisonedSessions(); clearPaidNodes(); @@ -468,6 +520,27 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null, } state.totalNodes = (resume ? results.length : 0) + viableNodes.length; + + // ── Batch row: drives /api/public/runs/current → /live livestream ────── + // Without this, getActiveBatch() never sees the in-flight run and /live + // keeps showing the previous (finished) batch. Created on every fresh run + // (not on resume — the previous batch row stays open until completion). + let _currentBatchId = null; + if (!resume) { + try { + const snapshotAddrs = viableNodes.map(({ node }) => node.address).filter(Boolean); + _currentBatchId = _dbInsertBatch({ + started_at: Date.now(), + snapshot_size: viableNodes.length, + mode: state.testRun ? 'test' : 'p2p', + snapshot_addresses: snapshotAddrs, + }); + state.activeBatchId = _currentBatchId; + } catch (batchErr) { + broadcast('log', { msg: `[batch] insert failed: ${batchErr.message}` }); + } + } + const estCostUdvpn = viableNodes.reduce( (sum, { node }) => sum + parseNodePriceUdvpn(node.gigabyte_prices) * GIGS, 0 ); @@ -511,7 +584,7 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null, if (!canProceed) { broadcast('log', { msg: '⏹ Aborting — VPN interference not cleared.' }); break; } let batchSessionMap; - if (state.dryRun) { + if (state.testRun) { batchSessionMap = new Map(); } else { try { @@ -618,6 +691,7 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null, if (result.pass10mbps) state.passed10++; if (result.passBaseline) state.passedBaseline++; upsertResult(result); // success — no snippet needed + if (_currentBatchId) { try { _dbInsertBatchResult(_currentBatchId, { ...result, testedAt: Date.now() }); } catch {} } saveResults(); broadcast('result', { result, state }); if (result.actualMbps != null) { @@ -681,6 +755,7 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null, const failResult = buildFailResult(node, status, state, errMsg, error?.diag || {}); state.failedNodes++; upsertResult(failResult, _logSnippet); + if (_currentBatchId) { try { _dbInsertBatchResult(_currentBatchId, { ...failResult, testedAt: Date.now() }); } catch {} } saveResults(); broadcast('result', { result: failResult, state }); const retryLabel = retried > 0 ? ` (${retried} retries)` : ''; @@ -853,6 +928,18 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null, state.currentNode = null; broadcast('state', { state }); const finalFailed = results.filter(r => r.actualMbps == null && r.error).length; + if (_currentBatchId) { + try { + _dbUpdateBatchOnFinish(_currentBatchId, { + finished_at: Date.now(), + passed: state.testedNodes, + failed: finalFailed, + }); + } catch (finishErr) { + broadcast('log', { msg: `[batch] finalize failed: ${finishErr.message}` }); + } + state.activeBatchId = null; + } broadcast('log', { msg: `✅ Audit complete. Tested ${state.testedNodes}, Failed ${finalFailed}. ${state.retryCount} retries total.` }); broadcast('log', { msg: `🧠 Transport cache: ${finalCache.nodesCached} nodes learned for next scan.` }); } @@ -1355,13 +1442,17 @@ export async function runPlanTest(planId, state, broadcast) { * This mirrors how Android/iOS consumer apps ship — the end user never holds * P2P, the plan operator covers all on-chain fees via a pre-granted feegrant. */ -export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, broadcast) { +export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, broadcast, opts = {}) { + resetPipelineStop(); + const resume = !!opts.resume; state.status = 'running'; state.startedAt = new Date().toISOString(); state.errorMessage = null; - state.totalNodes = 0; - state.testedNodes = 0; - state.failedNodes = 0; + if (!resume) { + state.totalNodes = 0; + state.testedNodes = 0; + state.failedNodes = 0; + } state.retryCount = 0; recomputeCounters(state); clearPoisonedSessions(); @@ -1407,7 +1498,14 @@ export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, // Surface allowance details in log for operator debugging const allowanceType = allowance['@type'] || allowance.type_url || null; const spendLimit = allowance.spend_limit || allowance.allowance?.spend_limit || null; - broadcast('log', { msg: ` ✓ Fee grant verified — type=${allowanceType || 'unknown'} limit=${JSON.stringify(spendLimit) || 'none'}` }); + const typeShort = allowanceType ? allowanceType.split('.').pop().replace(/^\//, '') : 'unknown'; + let limitLabel = 'unlimited'; + if (Array.isArray(spendLimit) && spendLimit.length) { + limitLabel = spendLimit + .map(c => `${(parseInt(c.amount || '0', 10) / 1_000_000).toFixed(2)} ${(c.denom || '').replace(/^u/, '').toUpperCase() || 'DVPN'}`) + .join(', '); + } + broadcast('log', { msg: ` ✓ Fee grant verified — ${typeShort}, limit ${limitLabel}` }); } catch (err) { // If the query itself throws (network error), abort rather than silently proceeding — // we cannot know whether the grant exists, and every session TX would fail. @@ -1488,9 +1586,17 @@ export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, } broadcast('log', { msg: ` Scanning plan nodes for online status...` }); - const onlineNodes = await scanNodesParallel(planNodes, 20, broadcast, state); + let onlineNodes = await scanNodesParallel(planNodes, 20, broadcast, state); broadcast('log', { msg: ` ${onlineNodes.length}/${planNodes.length} plan nodes are online` }); + // Resume mode: filter already-tested addresses + if (resume) { + const testedAddrs = new Set(results.map(r => r.address)); + const before = onlineNodes.length; + onlineNodes = onlineNodes.filter(({ node }) => !testedAddrs.has(node.address)); + broadcast('log', { msg: `Resume: skipping ${before - onlineNodes.length} already-tested, ${onlineNodes.length} remaining.` }); + } + if (onlineNodes.length === 0) { state.status = 'done'; state.completedAt = new Date().toISOString(); @@ -1498,7 +1604,7 @@ export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, return; } - state.totalNodes = onlineNodes.length; + state.totalNodes = (resume ? results.length : 0) + onlineNodes.length; broadcast('state', { state }); broadcast('log', { msg: `📡 Running baseline...` }); diff --git a/bin/cli.js b/bin/cli.js index 22a1f7a..6ea4452 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -52,6 +52,7 @@ const COMMAND_GROUPS = { Discovery: ['list', 'functions', 'verify-sdks'], Read: ['nodes', 'node', 'balance', 'subscriptions', 'plans'], Action: ['speed', 'test', 'audit', 'serve'], + Agent: ['agent'], }; // Flat list for quick lookup @@ -95,6 +96,10 @@ function printHelp() { audit Run full audit loop across all nodes serve Start the browser dashboard server + Agent + agent End-to-end driver hitting every server function over HTTP + (run "sentinel-audit agent map --pretty" for the registry) + Examples: sentinel-audit list sentinel-audit nodes --pretty diff --git a/bin/commands/agent.js b/bin/commands/agent.js new file mode 100644 index 0000000..a82c7fa --- /dev/null +++ b/bin/commands/agent.js @@ -0,0 +1,305 @@ +/** + * agent — End-to-end CLI server driver for Sentinel Node Tester. + * + * Lets an AI / automation drive every operator-facing function over HTTP + * against a running `serve` instance. Every subcommand prints structured JSON + * to stdout and exits non-zero on error so it composes cleanly in pipelines. + * + * Discovery: + * sentinel-audit agent map # full machine-readable endpoint registry + * sentinel-audit agent --help # short-form usage + * + * Auth: + * --token prefer this + * $SENTINEL_AUDIT_TOKEN else this + * $ADMIN_TOKEN else this + * (no token) single-user/local mode is allowed by server + * + * Target: + * --base-url http://host:port prefer this + * $SENTINEL_AUDIT_URL else this + * --port else http://localhost: + * (default) http://localhost:3001 + */ + +import { api, apiRequest, resolveBaseUrl, resolveToken } from '../lib/http.js'; +import { printJson } from '../lib/output.js'; + +export const name = 'agent'; +export const description = 'End-to-end agentic driver: hit every Sentinel Node Tester server function from the CLI.'; +export const usage = 'sentinel-audit agent [...args] [--base-url URL] [--token T] [--pretty]'; +export const flags = [ + { flag: '--base-url ', description: 'Server URL (default http://localhost:3001 or $SENTINEL_AUDIT_URL)' }, + { flag: '--port ', description: 'Shorthand to set localhost port (default 3001)' }, + { flag: '--token ', description: 'Bearer admin token (default $SENTINEL_AUDIT_TOKEN or $ADMIN_TOKEN)' }, + { flag: '--pretty', description: 'Pretty-print JSON output' }, + { flag: '--timeout ', description: 'HTTP timeout in seconds (default 30)' }, + { flag: '--watch ', description: 'For events/state subcommands: stream / poll for N seconds' }, + // Pass-through bodies + { flag: '--plan-id ', description: 'Plan ID (start, test-plan, sub-plans)' }, + { flag: '--sub-id ', description: 'Subscription ID (start, test-sub-plan)' }, + { flag: '--sub-granter ', description: 'Subscription granter address (start)' }, + { flag: '--test-run', description: 'Use TEST RUN mode for /api/start' }, + { flag: '--pricing-mode ',description: 'gigabytes | hours (default gigabytes)' }, + { flag: '--addr ', description: 'Node address (sentnode1...) for node, retest, etc.' }, + { flag: '--country ', description: 'Country filter (audit, retest)' }, + { flag: '--limit ', description: 'Result limit (errors, results)' }, + { flag: '--num ', description: 'Run number (runs/save, runs/load)' }, + { flag: '--sdk ', description: 'SDK selection (js | tkd)' }, +]; + +// ─── Endpoint registry ─────────────────────────────────────────────────────── +// +// Single source of truth. `agent map` prints this verbatim so an AI calling +// the CLI can introspect every function the server exposes. + +const ENDPOINTS = [ + // Discovery / health + { sub: 'health', method: 'GET', path: '/health', auth: false, desc: 'Public health probe' }, + { sub: 'admin-health', method: 'GET', path: '/api/health', auth: true, desc: 'Admin-side health (chain + balance)' }, + { sub: 'stats', method: 'GET', path: '/api/stats', auth: true, desc: 'Admin run/network stats' }, + { sub: 'state', method: 'GET', path: '/api/state', auth: true, desc: 'Full server runtime state' }, + { sub: 'sdk-versions', method: 'GET', path: '/api/sdk-versions', auth: true, desc: 'Installed SDK versions' }, + { sub: 'sdk-verify', method: 'GET', path: '/api/sdk-verify', auth: true, desc: 'Byte-for-byte SDK verification' }, + { sub: 'cross-sdk', method: 'GET', path: '/api/cross-sdk', auth: true, desc: 'Cross-SDK comparison data' }, + { sub: 'failure-analysis',method: 'GET', path: '/api/failure-analysis', auth: true, desc: 'Failure breakdown' }, + { sub: 'transport-cache', method: 'GET', path: '/api/transport-cache', auth: true, desc: 'Inspect transport cache (wg/v2ray)' }, + + // Public reads (no auth) + { sub: 'pub-nodes', method: 'GET', path: '/api/public/nodes', auth: false, desc: 'Public node list' }, + { sub: 'pub-node', method: 'GET', path: '/api/public/node/:addr', auth: false, desc: 'Public single node detail', params: ['addr'] }, + { sub: 'pub-node-errors', method: 'GET', path: '/api/public/node/:addr/errors', auth: false, desc: 'Public per-node error log', params: ['addr'] }, + { sub: 'pub-bandwidth', method: 'GET', path: '/api/public/node/:addr/bandwidth', auth: false, desc: 'Public per-node bandwidth history', params: ['addr'] }, + { sub: 'pub-errors', method: 'GET', path: '/api/public/errors', auth: false, desc: 'Recent failure log entries' }, + { sub: 'pub-countries', method: 'GET', path: '/api/public/countries', auth: false, desc: 'Country breakdown' }, + { sub: 'pub-stats', method: 'GET', path: '/api/public/stats', auth: false, desc: 'Public network stats' }, + { sub: 'pub-run-current', method: 'GET', path: '/api/public/runs/current', auth: false, desc: 'In-flight run snapshot (when broadcast on)' }, + { sub: 'pub-run-last', method: 'GET', path: '/api/public/runs/last', auth: false, desc: 'Last completed run snapshot' }, + { sub: 'pub-runs', method: 'GET', path: '/api/public/runs', auth: false, desc: 'List historical runs' }, + { sub: 'pub-batches', method: 'GET', path: '/api/public/batches', auth: false, desc: 'List broadcast batches' }, + { sub: 'pub-batch', method: 'GET', path: '/api/public/batch/:id', auth: false, desc: 'Single batch detail', params: ['id'] }, + { sub: 'pub-logs', method: 'GET', path: '/api/public/logs', auth: false, desc: 'Public broadcast log buffer' }, + { sub: 'pub-live-state', method: 'GET', path: '/api/public/live-state', auth: false, desc: 'Cold-refresh hydration for /live' }, + { sub: 'pub-test-status', method: 'GET', path: '/api/public/test/status', auth: false, desc: 'Public test status probe' }, + + // Broadcast toggle + { sub: 'broadcast', method: 'GET', path: '/api/broadcast', auth: false, desc: 'Read current broadcastLive value' }, + { sub: 'broadcast-toggle',method: 'POST', path: '/api/broadcast', auth: true, desc: 'Flip broadcastLive (no body)' }, + + // Audit lifecycle + { sub: 'start', method: 'POST', path: '/api/start', auth: true, desc: 'Start audit run', + bodyFromFlags: (f) => { + const body = {}; + if (f['--plan-id']) body.planId = f['--plan-id']; + if (f['--sub-id']) body.subscriptionId = f['--sub-id']; + if (f['--sub-granter'])body.subscriptionGranter = f['--sub-granter']; + if (f['--test-run']) body.testRun = true; + if (f['--pricing-mode']) body.pricingMode = f['--pricing-mode']; + return body; + } }, + { sub: 'resume', method: 'POST', path: '/api/resume', auth: true, desc: 'Resume the last audit run' }, + { sub: 'stop', method: 'POST', path: '/api/stop', auth: true, desc: 'Stop in-flight audit' }, + { sub: 'rescan', method: 'POST', path: '/api/rescan', auth: true, desc: 'Rescan chain node list' }, + { sub: 'retest-skips', method: 'POST', path: '/api/retest-skips', auth: true, desc: 'Retest only skipped nodes' }, + { sub: 'retest-fails', method: 'POST', path: '/api/retest-fails', auth: true, desc: 'Retest only failed nodes' }, + { sub: 'auto-retest', method: 'POST', path: '/api/auto-retest', auth: true, desc: 'Trigger background auto-retest sweep' }, + { sub: 'clear', method: 'POST', path: '/api/clear', auth: true, desc: 'Clear current results buffer' }, + { sub: 'economy', method: 'POST', path: '/api/economy', auth: true, desc: 'Toggle economy mode', + bodyFromFlags: () => ({}) }, + + // Plans & subscriptions + { sub: 'plans', method: 'GET', path: '/api/plans', auth: true, desc: 'List wallet plans' }, + { sub: 'subscriptions', method: 'GET', path: '/api/subscriptions', auth: true, desc: 'List wallet subscriptions' }, + { sub: 'sub-plans', method: 'GET', path: '/api/sub-plans', auth: true, desc: 'List subscription-plan pairings' }, + { sub: 'admin-plans', method: 'GET', path: '/api/admin/plans', auth: true, desc: 'Admin plan inspector' }, + { sub: 'test-plan', method: 'POST', path: '/api/test-plan', auth: true, desc: 'Run a plan-mode audit', + bodyFromFlags: (f) => ({ planId: f['--plan-id'] }) }, + { sub: 'test-sub-plan', method: 'POST', path: '/api/test-sub-plan', auth: true, desc: 'Run a subscription-plan-mode audit', + bodyFromFlags: (f) => ({ subscriptionId: f['--sub-id'], planId: f['--plan-id'], subscriptionGranter: f['--sub-granter'] }) }, + + // Chain queries + { sub: 'chain-nodes', method: 'GET', path: '/api/chain/nodes', auth: true, desc: 'Direct chain node fetch' }, + { sub: 'chain-status', method: 'GET', path: '/api/chain/node-status', auth: true, desc: 'Per-node status snapshot' }, + + // Run history + { sub: 'runs', method: 'GET', path: '/api/runs', auth: true, desc: 'List saved audit runs' }, + { sub: 'run-get', method: 'GET', path: '/api/runs/:num', auth: true, desc: 'Read a specific saved run', params: ['num'] }, + { sub: 'run-save', method: 'POST', path: '/api/runs/save', auth: true, desc: 'Save the current results buffer' }, + { sub: 'run-load', method: 'POST', path: '/api/runs/load/:num', auth: true, desc: 'Load run #N back into the buffer', params: ['num'] }, + + // Results & errors + { sub: 'results', method: 'GET', path: '/api/results', auth: true, desc: 'Current results buffer' }, + + // SDK + DNS knobs + { sub: 'sdk-get', method: 'GET', path: '/api/sdk', auth: true, desc: 'Read active SDK selection' }, + { sub: 'sdk-set', method: 'POST', path: '/api/sdk', auth: true, desc: 'Set active SDK', + bodyFromFlags: (f) => ({ sdk: f['--sdk'] }) }, + { sub: 'dns-get', method: 'GET', path: '/api/dns', auth: true, desc: 'Read DNS configuration' }, + { sub: 'dns-set', method: 'POST', path: '/api/dns', auth: true, desc: 'Update DNS configuration', + bodyFromFlags: (f) => { + const b = {}; + if (f['--dns']) b.servers = String(f['--dns']).split(',').map(s => s.trim()).filter(Boolean); + if (f['--enabled'] !== undefined) b.enabled = f['--enabled'] === 'true' || f['--enabled'] === true; + return b; + } }, + + // Streaming + { sub: 'events', method: 'GET', path: '/api/events', auth: true, desc: 'Admin SSE event stream (use --watch )' }, + { sub: 'pub-events', method: 'GET', path: '/api/public/events', auth: false, desc: 'Public SSE event stream (use --watch )' }, +]; + +// ─── Resolution helpers ────────────────────────────────────────────────────── + +function findEndpoint(sub) { + return ENDPOINTS.find(e => e.sub === sub); +} + +function fillPath(rawPath, params, positional) { + if (!params || !params.length) return rawPath; + let p = rawPath; + for (let i = 0; i < params.length; i++) { + const tok = positional[i]; + if (!tok) { + throw new Error(`Missing positional argument for :${params[i]} in ${rawPath}`); + } + p = p.replace(`:${params[i]}`, encodeURIComponent(tok)); + } + return p; +} + +// ─── Top-level help / map output ───────────────────────────────────────────── + +function printAgentHelp() { + console.log(` + sentinel-audit agent — End-to-end CLI server driver + + Usage: + sentinel-audit agent [args...] [flags] + + Discovery: + agent map Machine-readable endpoint registry (JSON) + agent --help This text + + Common subcommands: + health Public health probe + state Server runtime state (admin) + plans / subscriptions Wallet plans / subs + start [--test-run] [--pricing-mode hours|gigabytes] + stop / resume / rescan + retest-skips / retest-fails + broadcast / broadcast-toggle + pub-nodes / pub-node / pub-node-errors + runs / run-get / run-save / run-load + events --watch 30 Stream admin SSE for 30s + pub-events --watch 30 Stream public SSE for 30s + + Run "agent map --pretty" for the full registry with HTTP method / path / auth. +`); +} + +// ─── SSE streaming ─────────────────────────────────────────────────────────── + +async function streamSse(path, flags) { + const watchSecs = parseInt(flags['--watch'] || '30', 10); + const res = await apiRequest('GET', path, { flags, raw: true, timeoutMs: (watchSecs + 5) * 1000 }); + if (!res.ok || !res.body) { + throw new Error(`SSE ${path} → HTTP ${res.status}`); + } + + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const start = Date.now(); + const events = []; + + while (Date.now() - start < watchSecs * 1000) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + + let idx; + while ((idx = buf.indexOf('\n\n')) !== -1) { + const chunk = buf.slice(0, idx); + buf = buf.slice(idx + 2); + let event = 'message'; + let data = ''; + for (const line of chunk.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data += line.slice(5).trim(); + } + let parsed = data; + try { parsed = JSON.parse(data); } catch {} + const rec = { t: Date.now() - start, event, data: parsed }; + events.push(rec); + // Live tap to stderr so the user sees progress; stdout is reserved for + // the final aggregate JSON. + console.error(JSON.stringify(rec)); + } + } + + try { await reader.cancel(); } catch {} + return { source: path, watchSeconds: watchSecs, events }; +} + +// ─── Runner ────────────────────────────────────────────────────────────────── + +export async function run({ positional, flags: f }) { + const sub = positional[0]; + + if (!sub || sub === 'help') { + printAgentHelp(); + return; + } + + // Discovery: print the full endpoint registry as JSON + if (sub === 'map' || sub === 'list') { + const base = resolveBaseUrl(f); + const tokenSource = f['--token'] ? '--token' + : process.env.SENTINEL_AUDIT_TOKEN ? '$SENTINEL_AUDIT_TOKEN' + : process.env.ADMIN_TOKEN ? '$ADMIN_TOKEN' + : 'none'; + return { + baseUrl: base, + tokenSource, + tokenPresent: Boolean(resolveToken(f)), + total: ENDPOINTS.length, + endpoints: ENDPOINTS.map(e => ({ + sub: e.sub, + method: e.method, + path: e.path, + auth: e.auth, + params: e.params || [], + description: e.desc, + })), + }; + } + + // Streaming subcommands + if (sub === 'events' || sub === 'pub-events') { + const path = sub === 'events' ? '/api/events' : '/api/public/events'; + return await streamSse(path, f); + } + + // Lookup + const ep = findEndpoint(sub); + if (!ep) { + throw new Error(`unknown agent subcommand: "${sub}". Run "agent map --pretty" for the registry.`); + } + + const tail = positional.slice(1); + const path = fillPath(ep.path, ep.params, tail); + + // Build query / body + const query = {}; + if (f['--limit']) query.limit = f['--limit']; + if (f['--country']) query.country = f['--country']; + + let body; + if (ep.method !== 'GET' && ep.method !== 'HEAD') { + body = ep.bodyFromFlags ? ep.bodyFromFlags(f) : {}; + } + + const timeoutMs = parseInt(f['--timeout'] || '30', 10) * 1000; + + return await apiRequest(ep.method, path, { flags: f, body, query, timeoutMs }); +} diff --git a/bin/lib/http.js b/bin/lib/http.js new file mode 100644 index 0000000..a407d30 --- /dev/null +++ b/bin/lib/http.js @@ -0,0 +1,108 @@ +/** + * Sentinel Node Tester — HTTP client for the agent CLI + * + * Thin wrapper over fetch() that: + * - Resolves base URL from --base-url, SENTINEL_AUDIT_URL, or http://localhost:PORT + * - Adds Bearer auth from --token / SENTINEL_AUDIT_TOKEN / ADMIN_TOKEN + * - Sets X-Admin-Request: 1 on every non-GET (CSRF gate in core/auth.js) + * - Returns parsed JSON, never swallows errors + */ + +export function resolveBaseUrl(flags = {}) { + const fromFlag = flags['--base-url'] || flags['--url']; + if (fromFlag) return String(fromFlag).replace(/\/+$/, ''); + if (process.env.SENTINEL_AUDIT_URL) return process.env.SENTINEL_AUDIT_URL.replace(/\/+$/, ''); + const port = flags['--port'] || process.env.PORT || '3001'; + return `http://localhost:${port}`; +} + +export function resolveToken(flags = {}) { + return ( + flags['--token'] || + process.env.SENTINEL_AUDIT_TOKEN || + process.env.ADMIN_TOKEN || + null + ); +} + +function buildHeaders(method, token, extra = {}) { + const h = { 'Accept': 'application/json', ...extra }; + if (method && method !== 'GET' && method !== 'HEAD') { + h['Content-Type'] = 'application/json'; + h['X-Admin-Request'] = '1'; + } + if (token) h['Authorization'] = `Bearer ${token}`; + return h; +} + +/** + * @param {string} method - GET/POST/DELETE etc + * @param {string} pathOrUrl - "/api/state" or full URL + * @param {object} opts + * - flags: parsed CLI flags (used for base url + token) + * - body: request body (will be JSON.stringify'd) + * - query: object of query string params + * - timeoutMs: abort after N ms (default 30s) + * - raw: if true, return Response untouched + */ +export async function apiRequest(method, pathOrUrl, opts = {}) { + const { flags = {}, body, query, timeoutMs = 30_000, raw = false } = opts; + const base = resolveBaseUrl(flags); + const token = resolveToken(flags); + + let url = pathOrUrl.startsWith('http') ? pathOrUrl : `${base}${pathOrUrl}`; + if (query && typeof query === 'object') { + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(query)) { + if (v === undefined || v === null) continue; + usp.append(k, String(v)); + } + const qs = usp.toString(); + if (qs) url += (url.includes('?') ? '&' : '?') + qs; + } + + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + + let res; + try { + res = await fetch(url, { + method, + headers: buildHeaders(method, token), + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: ac.signal, + }); + } catch (err) { + clearTimeout(timer); + throw new Error(`HTTP ${method} ${url} failed: ${err.message || err}`); + } + clearTimeout(timer); + + if (raw) return res; + + const ct = res.headers.get('content-type') || ''; + let payload; + if (ct.includes('application/json')) { + payload = await res.json().catch(() => null); + } else { + payload = await res.text().catch(() => ''); + } + + if (!res.ok) { + const msg = (payload && typeof payload === 'object' && payload.error) + ? payload.error + : (typeof payload === 'string' ? payload.slice(0, 240) : `HTTP ${res.status}`); + const e = new Error(`${method} ${url} → ${res.status} ${msg}`); + e.status = res.status; + e.payload = payload; + throw e; + } + + return payload; +} + +export const api = { + get: (p, o = {}) => apiRequest('GET', p, o), + post: (p, body, o = {}) => apiRequest('POST', p, { ...o, body }), + del: (p, o = {}) => apiRequest('DELETE', p, o), +}; diff --git a/core/chain.js b/core/chain.js index a4ab502..29d2b1b 100644 --- a/core/chain.js +++ b/core/chain.js @@ -648,17 +648,20 @@ function isActiveStatus(status) { } export async function querySubscriptions(walletAddress) { - // RPC-first: rpcQuerySubscriptionsForAccount returns raw Any-bytes per sub. - // Decode each entry to extract plan_id and subscription_id via _decodePlanSubscription. + // RPC-first for direct subs (fast). RPC's QuerySubscriptionsForAccount does + // NOT return shared-plan allocations (e.g. plan 36 where wallet is allocatee + // not acc_address) — those only appear via LCD. So we always do an LCD pass + // afterwards and merge any IDs RPC missed. + const rpcOut = []; try { const rpcClient = await getRpcClient(); if (rpcClient) { - const rawEntries = await rpcQuerySubscriptionsForAccount(rpcClient, walletAddress); + // Bug fix: SDK default limit is 100 — wallets with long sub history (older + // expired subs still iterated by chain) silently drop newer subscriptions. + const rawEntries = await rpcQuerySubscriptionsForAccount(rpcClient, walletAddress, { limit: 5000 }); if (rawEntries && rawEntries.length > 0) { - const out = []; for (const entry of rawEntries) { try { - // Each entry may be an Any-wrapped bytes blob or already-decoded object const bytes = entry instanceof Uint8Array ? entry : entry.value instanceof Uint8Array ? entry.value : null; @@ -666,24 +669,25 @@ export async function querySubscriptions(walletAddress) { const anyDecoded = _decodeAny(bytes); if (!anyDecoded.valueBytes) continue; const sub = _decodePlanSubscription(anyDecoded.valueBytes); - if (!sub.planId) continue; // not a plan subscription - out.push({ + if (!sub.planId) continue; + rpcOut.push({ id: sub.subscriptionId, plan_id: sub.planId, - status: 'STATUS_ACTIVE', // RPC only returns active subs when status=1 + status: 'STATUS_ACTIVE', expiry: null, + ownerAddress: walletAddress, // RPC only returns direct subs + viaAllocation: false, + grantedBytes: null, }); - } catch { /* skip malformed entries */ } + } catch { /* skip malformed */ } } - if (out.length > 0) return out; - // RPC returned entries but none decoded as plan subs — fall through to LCD } } } catch (rpcErr) { console.warn('[querySubscriptions] RPC failed, falling back to LCD:', rpcErr.message); } - // LCD fallback: direct query with ?status=1 so chain pre-filters at source. + // LCD pass: direct query with ?status=1 so chain pre-filters at source. // Also filter client-side with isActiveStatus() to handle both chain v2 // ("active") and chain v3 ("STATUS_ACTIVE") response formats. const lcd = await ensureLcd(); @@ -695,17 +699,54 @@ export async function querySubscriptions(walletAddress) { if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); const raw = Array.isArray(data.subscriptions) ? data.subscriptions : []; - return raw - .filter(s => s.acc_address === walletAddress) - .filter(s => isActiveStatus(s.status)) - .map(s => ({ - id: s.id, + // /accounts/{addr}/subscriptions returns BOTH direct subs and subs where + // addr is an allocatee on a shared plan. For non-direct subs, verify a + // non-zero allocation via v2 /subscriptions/{id}/allocations. + const active = raw.filter(s => isActiveStatus(s.status)); + const haveIds = new Set(rpcOut.map(s => String(s.id))); + const out = [...rpcOut]; + for (const s of active) { + const isOwner = s.acc_address === walletAddress; + const sid = String(s.id); + // Enrich expiry on RPC entries + if (haveIds.has(sid)) { + const existing = out.find(x => String(x.id) === sid); + if (existing && !existing.expiry) existing.expiry = s.inactive_at || null; + if (existing && s.acc_address) existing.ownerAddress = s.acc_address; + continue; + } + let viaAllocation = false; + let grantedBytes = null; + if (!isOwner) { + try { + const ar = await fetch( + `${lcd}/sentinel/subscription/v2/subscriptions/${sid}/allocations?pagination.limit=500`, + { signal: AbortSignal.timeout(10_000) }, + ); + if (ar.ok) { + const aj = await ar.json(); + const mine = (aj.allocations || []).find(a => a.address === walletAddress); + if (mine && mine.granted_bytes && mine.granted_bytes !== '0') { + viaAllocation = true; + grantedBytes = mine.granted_bytes; + } + } + } catch { /* skip */ } + if (!viaAllocation) continue; + } + out.push({ + id: sid, plan_id: s.plan_id, status: s.status, expiry: s.inactive_at || null, - })); + ownerAddress: s.acc_address, + viaAllocation, + grantedBytes, + }); + } + return out; } catch { - return []; + return rpcOut; } } @@ -848,6 +889,9 @@ export async function querySubscriberPlansEnriched(walletAddress) { feeGrantActive, feeGrantCheckFailed, nodeCount, + viaAllocation: !!s.viaAllocation, + grantedBytes: s.grantedBytes || null, + subOwnerAddress: s.ownerAddress || null, }); } return results; @@ -893,14 +937,60 @@ export async function queryFeeGrantRpcFirst(client, lcd, granter, grantee) { request, ); if (result?.value && result.value.length > 0) { - // Successfully got a response — the allowance exists. - // Decode the response: field 1 = Grant proto (embedded) - const fields = decodeRpcProto(new Uint8Array(result.value)); - if (fields[1]?.[0]) { - // Minimal: return a truthy object indicating active grant. - // Full decode would require the Grant proto schema; enough to signal "exists". - return { exists: true, _rpcSource: true }; + // QueryAllowanceResponse { Grant allowance = 1 } + // Grant { string granter = 1; string grantee = 2; Any allowance = 3 } + // Any { string type_url = 1; bytes value = 2 } + const top = decodeRpcProto(new Uint8Array(result.value)); + const grantBytes = top[1]?.[0]?.value; + if (!grantBytes) return null; + const grant = decodeRpcProto(grantBytes); + const anyBytes = grant[3]?.[0]?.value; + if (!anyBytes) return { exists: true, _rpcSource: true }; + const anyFields = decodeRpcProto(anyBytes); + const typeUrl = anyFields[1]?.[0]?.value ? decodeRpcString(anyFields[1][0].value) : null; + const innerBytes = anyFields[2]?.[0]?.value; + + // Walk through wrapping allowances (PeriodicAllowance/AllowedMsgAllowance) to + // reach the BasicAllowance that carries spend_limit. + let outerType = typeUrl; + let basicBytes = innerBytes; + for (let depth = 0; depth < 3 && basicBytes; depth++) { + if (outerType?.endsWith('BasicAllowance')) break; + const wrap = decodeRpcProto(basicBytes); + // PeriodicAllowance.basic = 1 (BasicAllowance) | AllowedMsgAllowance.allowance = 1 (Any) + const innerAnyBytes = wrap[1]?.[0]?.value; + if (!innerAnyBytes) break; + if (outerType?.endsWith('PeriodicAllowance')) { + outerType = '/cosmos.feegrant.v1beta1.BasicAllowance'; + basicBytes = innerAnyBytes; + break; + } + // AllowedMsgAllowance: inner is Any + const innerAny = decodeRpcProto(innerAnyBytes); + outerType = innerAny[1]?.[0]?.value ? decodeRpcString(innerAny[1][0].value) : null; + basicBytes = innerAny[2]?.[0]?.value; } + + // BasicAllowance { repeated Coin spend_limit = 1; Timestamp expiration = 2 } + // Coin { string denom = 1; string amount = 2 } + const spend_limit = []; + if (outerType?.endsWith('BasicAllowance') && basicBytes) { + const basic = decodeRpcProto(basicBytes); + for (const coinEntry of (basic[1] || [])) { + const coin = decodeRpcProto(coinEntry.value); + spend_limit.push({ + denom: coin[1]?.[0]?.value ? decodeRpcString(coin[1][0].value) : '', + amount: coin[2]?.[0]?.value ? decodeRpcString(coin[2][0].value) : '0', + }); + } + } + + return { + exists: true, + _rpcSource: true, + '@type': typeUrl, + spend_limit: spend_limit.length ? spend_limit : null, + }; } // Empty response = no grant found return null; diff --git a/core/db.js b/core/db.js index 29fa7ee..31bd872 100644 --- a/core/db.js +++ b/core/db.js @@ -27,7 +27,7 @@ const _handles = { real: null }; /** * Returns the open Database instance, creating it on first call. Runs all - * migrations automatically. Any scope param ('real', 'dry', or a path) is + * migrations automatically. Any scope param ('real', 'test', or a path) is * accepted for back-compat but always returns the single audit.db handle. * * @param {string} [which] - Ignored (back-compat). Pass ':memory:' for tests. @@ -271,6 +271,15 @@ function runMigrations(db) { db.prepare('UPDATE schema_version SET version = 6').run(); })(); } + + if (current < 7) { + db.transaction(() => { + // ── Migration v7: rename run mode 'dry' → 'test' ──────────────────────── + // Idempotent: WHERE mode='dry' matches nothing if already migrated. + db.exec(`UPDATE runs SET mode='test' WHERE mode='dry'`); + db.prepare('UPDATE schema_version SET version = 7').run(); + })(); + } } // ─── Run Mutations ─────────────────────────────────────────────────────────── @@ -395,7 +404,7 @@ export function insertResult(run_id, result, which) { * * @param {number} run_id * @param {object[]} results - * @param {'real'|'dry'} [which='real'] + * @param {'real'|'test'} [which='real'] */ export function insertResultsBatch(run_id, results, which) { const db = getDb(which); @@ -572,7 +581,7 @@ export function getNetworkStats(which) { const db = getDb(which); // Latest result per node (MAX(id) tiebreaker for equal tested_at). - // No mode filter needed: real and dry data live in separate DB files. + // No mode filter needed: real and test data live in separate DB files. const rows = db.prepare(` SELECT r.actual_mbps, r.session_ok FROM results r diff --git a/core/session.js b/core/session.js index ea1bce8..616c16c 100644 --- a/core/session.js +++ b/core/session.js @@ -333,6 +333,9 @@ export function extractSessionMap(txResult, nodeAddrs) { * Returns Map for all NEW sessions. */ export async function submitBatchPayment(client, account, denom, gigabytes, batch, state, broadcast) { + const pricingMode = state?.pricingMode === 'hours' ? 'hours' : 'gigabytes'; + const sessionGigabytes = pricingMode === 'hours' ? 0 : gigabytes; + const sessionHours = pricingMode === 'hours' ? 1 : 0; const result = new Map(); const reusedAddrs = new Set(); const toPayBatch = []; @@ -341,15 +344,20 @@ export async function submitBatchPayment(client, account, denom, gigabytes, batc if (broadcast) broadcast('log', { msg: ` ⏭ Skip ${node.address.slice(0, 20)}… — already paid this run` }); continue; } - const priceEntry = (node.gigabyte_prices || []).find(p => p.denom === denom); - if (priceEntry) toPayBatch.push({ node, priceEntry }); + const priceList = pricingMode === 'hours' ? (node.hourly_prices || []) : (node.gigabyte_prices || []); + const priceEntry = priceList.find(p => p.denom === denom); + if (priceEntry) { + toPayBatch.push({ node, priceEntry }); + } else if (broadcast && pricingMode === 'hours') { + broadcast('log', { msg: ` ⏭ Skip ${node.address.slice(0, 20)}… — no hourly price` }); + } } if (toPayBatch.length > 0) { const messages = toPayBatch.map(({ node, priceEntry }) => ({ typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes, hours: 0, + gigabytes: sessionGigabytes, hours: sessionHours, max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value }, }, })); @@ -369,7 +377,7 @@ export async function submitBatchPayment(client, account, denom, gigabytes, batc typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes, hours: 0, + gigabytes: sessionGigabytes, hours: sessionHours, }, })); txResult = await signAndBroadcastRetry(client, account.address, messagesNoMax, fee, broadcast); @@ -410,8 +418,10 @@ export async function submitBatchPayment(client, account, denom, gigabytes, batc } if (broadcast) broadcast('log', { msg: ` Batch tx (${n} msgs): ${txResult.transactionHash.slice(0, 16)}…` }); toPayBatch.forEach(({ node }) => { - const priceEntry = (node.gigabyte_prices || []).find(p => p.denom === denom); - if (priceEntry) state.spentUdvpn += Math.round(parseFloat(priceEntry.quote_value) || 0) * gigabytes; + const list = pricingMode === 'hours' ? (node.hourly_prices || []) : (node.gigabyte_prices || []); + const priceEntry = list.find(p => p.denom === denom); + const units = pricingMode === 'hours' ? sessionHours : gigabytes; + if (priceEntry) state.spentUdvpn += Math.round(parseFloat(priceEntry.quote_value) || 0) * units; }); state.spentUdvpn += 200000 * n; state.balance = `${(Math.max(0, state.balanceUdvpn - state.spentUdvpn) / 1_000_000).toFixed(4)} P2P (est. remaining)`; diff --git a/docs/ARCHITECTURE-PUBLIC-LIVE.md b/docs/ARCHITECTURE-PUBLIC-LIVE.md index bca4a66..41686f4 100644 --- a/docs/ARCHITECTURE-PUBLIC-LIVE.md +++ b/docs/ARCHITECTURE-PUBLIC-LIVE.md @@ -54,17 +54,17 @@ admin.html ──POST /api/broadcast──► state.broadcastLive = true/false One database: `audit.db`. All runs — live and dry — write to it. - Normal audit run: `mode='live'` (or absent/null for legacy rows). -- Dry-run (`?dryRun=1` on `/api/start`): `mode='dry'`. Every node row gets `actualMbps: null, errorCode: 'TEST_RUN_SKIP'`. Visually distinguishable in the admin table but stored in the same file. +- Test-run (`?testRun=1` on `/api/start`): `mode='test'`. Every node row gets `actualMbps: null, errorCode: 'TEST_RUN_SKIP'`. Visually distinguishable in the admin table but stored in the same file. - `audit-dry.db` no longer exists. -## TEST RUN (Dry Run) +## TEST RUN (Test Run) TEST RUN is not a mode — it is an optional parameter on the normal start endpoint. -- `POST /api/start` with `dryRun: true` in body, or `POST /api/start?dryRun=1`. +- `POST /api/start` with `testRun: true` in body, or `POST /api/start?testRun=1`. - Pipeline skips: plan membership check, online scan, chain operations, payments. - Every node row: `actualMbps: null, errorCode: 'TEST_RUN_SKIP'`. -- Run row: `mode='dry'` in `audit.db`. +- Run row: `mode='test'` in `audit.db`. - Useful for verifying the pipeline plumbing and UI without spending DVPN. ## SSE Event Contract @@ -97,5 +97,5 @@ No wallet, plan ID, or fee-grant internals are forwarded on the public SSE strea - Mode overlay UI in `admin.html`. - `POST /api/admin/public-test/start`, `POST /api/admin/public-test/stop`, `GET /api/admin/public-test/status`. - `POST /api/public/test/start`, `POST /api/public/test/stop`, `GET /api/public/test/status`. -- `audit-dry.db` — superseded by the `mode='dry'` column in `audit.db`. +- `audit-dry.db` — superseded by the `mode='test'` column in `audit.db`. - Economy mode (deprecated earlier in the same session). diff --git a/index.html b/index.html index 03ae20d..d4f70cd 100644 --- a/index.html +++ b/index.html @@ -576,7 +576,7 @@

SENTINEL AUDIT

- +

@@ -883,13 +883,13 @@

SENTINEL AUDIT

const label = paused ? 'Paused' : 'Running...'; btnStart.textContent = label; btnStart.classList.add('loading'); - btnStop.textContent = 'Stop Audit'; + btnStop.textContent = 'Stop Test'; btnStop.classList.remove('loading'); } else { btnStart.textContent = 'New Test'; btnStart.classList.remove('loading'); if (btnResume) { btnResume.textContent = 'Resume'; btnResume.classList.remove('loading'); btnResume.disabled = false; } - btnStop.textContent = 'Stop Audit'; + btnStop.textContent = 'Stop Test'; btnStop.classList.remove('loading'); } @@ -1503,7 +1503,7 @@

-
Stop Audit
+
Stop Test
Gracefully stops the current scan after the active node finishes. Results are saved. You can Resume later. @@ -1571,7 +1571,7 @@

×
- Only tests nodes in the selected plan. Every session TX is fee-granted by the plan owner — this wallet pays zero gas. + Only tests nodes in the selected plan. Session TXs are fee-granted by the plan owner — but only if that plan owner has an active feegrant for this wallet. Plans without a feegrant are flagged below and disabled (this wallet would otherwise pay gas itself).
Loading your subscriptions…
diff --git a/live.html b/live.html index 5e818a6..bbb04f2 100644 --- a/live.html +++ b/live.html @@ -74,6 +74,41 @@ margin: 0 0 18px 0; } .live-page-header-left { display: flex; align-items: center; gap: 14px; flex: 1 1 auto; min-width: 0; } + .brand-logo { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); } + /* TEST RUN pill — sits next to the page title when a test-run audit is active */ + .test-run-badge { + display: inline-block; + vertical-align: middle; + margin-left: 10px; + padding: 4px 10px; + border-radius: 999px; + background: var(--accent); + color: var(--bg-card-solid); + font-size: 10px; + font-weight: 800; + letter-spacing: 1.5px; + font-family: var(--font-display); + } + /* TEST RUN banner — full-width, static accent strip. Shown when a test-run + audit is active so the public can't mistake skipped rows for real + measurements. No animation — keeps the page calm. */ + .test-run-banner { + display: none; + align-items: center; + justify-content: center; + gap: 10px; + padding: 12px 16px; + margin-bottom: 22px; + background: var(--accent); + color: var(--bg-card-solid); + font-family: var(--font-display); + font-weight: 800; + font-size: 13px; + letter-spacing: 2px; + text-transform: uppercase; + border-bottom: 2px solid var(--accent); + } + .test-run-banner.is-visible { display: flex; } .live-page-header-right { display: flex; align-items: center; gap: 0; flex: 0 0 auto; @@ -271,14 +306,15 @@ th { text-align: left; - padding: 0 6px 6px 6px; + padding: 8px 6px; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid var(--border); color: var(--text-secondary); position: sticky; top: 0; - background: var(--glass-bg); + /* Opaque so scrolling rows don't bleed through the header bar */ + background: var(--bg-card-solid); z-index: 10; } @@ -563,18 +599,33 @@

View on Desktop

+ +
+
-

dVPN Node Test

-
Live public testing — Sentinel dVPN network
+

+ Sentinel dVPN Node Test + + +

+
Live public testing — Sentinel dVPN network
-
Tester Baseline
+
Server Baseline
@@ -641,14 +692,12 @@

dVPN Node Test

Current Batch
-
0 / 0 nodes tested
-
Batch size: @@ -732,7 +781,8 @@

dVPN Node Test

const notice = document.getElementById('broadcastOffNotice'); if (_broadcastLive) { if (notice) notice.style.display = 'none'; - showPaused(false); + // Reconcile against current admin status; idle = still paused. + applyPauseFromState(); if (!wasLive) { connectSSE(); seedLogsFromRest(); @@ -769,20 +819,41 @@

dVPN Node Test

const urlEl = document.getElementById('mobileGateUrl'); if (urlEl) urlEl.textContent = location.host + '/live'; applyThemeIcons(); + // Paint cached snapshot first so a refresh shows data instantly, + // then resolve broadcast state and reconcile with server. + rehydrateFromCache(); renderTable(); renderLiveStats(); // Resolve broadcast state BEFORE loading the current batch — otherwise // loadCurrentBatch races and may un-pause the overlay using the default // (broadcastLive=false) before we know the real state. await checkBroadcastState(); + // Seed full live state + results via REST so refresh paints the same + // dashboard the operator sees, even before the SSE init message lands. + if (_broadcastLive) await seedLiveStateFromRest(); loadCurrentBatch(); loadHeaderStats(); + applyPauseFromState(); setInterval(checkBroadcastState, 30000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkBroadcastState(); }); }); + // Normalize a node's serviceType into the lowercase string the row + // renderer expects ('wireguard' | 'v2ray' | null). The sanitizer can hand + // us either a number (1=wireguard, 2=v2ray) or a string — both must + // produce a transport badge. + function normalizeServiceType(v) { + if (v == null) return null; + if (v === 1 || v === '1') return 'wireguard'; + if (v === 2 || v === '2') return 'v2ray'; + const s = String(v).toLowerCase(); + if (s.includes('wire')) return 'wireguard'; + if (s.includes('v2')) return 'v2ray'; + return null; + } + // ─── Header network stats (baseline + chain-wide) ─── let _headerBaseline = null; function setHeaderBaseline(mbps) { @@ -795,6 +866,8 @@

dVPN Node Test

el.style.color = n >= 30 ? '' : 'var(--red)'; } async function loadHeaderStats() { + // Paint from REST first so the header tiles have values on a cold load + // even before SSE init arrives. try { const r = await fetch('/api/public/test/status', { cache: 'no-store' }); if (r.ok) { @@ -812,6 +885,49 @@

dVPN Node Test

if (j.passingPct != null) set('hdrNetworkPass', Number(j.passingPct).toFixed(0) + '%'); } } catch {} + // Then layer live-batch values on top once _liveState / resultsArr exist. + applyHeaderStatsFromState(); + } + + // REST fallback so a refresh paints stats + table BEFORE SSE init lands. + async function seedLiveStateFromRest() { + try { + const r = await fetch('/api/public/live-state', { cache: 'no-store' }); + if (!r.ok) return; + const j = await r.json(); + if (!j || !j.broadcastLive) return; + if (j.state) mergeLiveState(j.state); + if (Array.isArray(j.results)) { + for (const r2 of j.results) { + if (!r2 || !r2.address) continue; + upsert({ + address: r2.address, + moniker: r2.moniker || '', + country: r2.country || '', + countryCode: r2.countryCode || '', + city: r2.city || '', + type: r2.serviceType || null, + actualMbps: r2.actualMbps != null ? Number(r2.actualMbps) : null, + baselineAtTest: r2.baselineAtTest != null ? Number(r2.baselineAtTest) : null, + peers: r2.peers != null ? Number(r2.peers) : null, + maxPeers: r2.maxPeers != null ? Number(r2.maxPeers) : null, + error: r2.error || null, + errorCode: r2.errorCode || null, + skipped: !!r2.skipped, + inPlan: r2.inPlan === true, + testRun: r2.errorCode === 'TEST_RUN_SKIP', + testedAt: r2.testedAt || null, + }); + } + // Sync the current-batch counter to the seeded rows so the + // Tested tile shows real progress on a cold refresh. + _cb.tested = resultsArr.length; + _cb.passed = resultsArr.filter(x => Number(x.actualMbps) >= 10).length; + _cb.failed = _cb.tested - _cb.passed; + } + applyHeaderStatsFromState(); + renderLiveStats(); + } catch {} } function toggleTheme() { @@ -962,8 +1078,12 @@

dVPN Node Test

const countryName = escHtml(r.country || (cc ? cc : '—')); const countryCol = flag ? `${flag} ${countryName}` : countryName; const cityCol = escHtml(r.city || '—'); - const act = r.actualMbps != null ? r.actualMbps.toFixed(2) + ' Mbps' : '--'; - const blAt = r.baselineAtTest != null ? r.baselineAtTest.toFixed(2) + ' Mbps' : '--'; + // TEST RUN rows never connect to the node — surface the Speed and per-row + // Baseline columns as "—" so the public can't misread the operator's WAN + // baseline as a node-side measurement. + const isTestRun = r.errorCode === 'TEST_RUN_SKIP'; + const act = (!isTestRun && r.actualMbps != null) ? r.actualMbps.toFixed(2) + ' Mbps' : '--'; + const blAt = (!isTestRun && r.baselineAtTest != null) ? r.baselineAtTest.toFixed(2) + ' Mbps' : '--'; let peersHtml = '--'; const peerCount = r.peers != null ? r.peers : null; @@ -990,7 +1110,8 @@

dVPN Node Test

const isFail = r.actualMbps == null && !r.skipped; const copyCell = isFail && addrFull - ? `` + ? ` + ` : ''; tr.innerHTML = ` @@ -1102,6 +1223,7 @@

dVPN Node Test

if (o.cat === 'fail' && o.addr) { parts.push(``); + parts.push(``); } div.innerHTML = parts.join(''); @@ -1208,7 +1330,13 @@

dVPN Node Test

function cbRender() { const snap = _cb.snapshotSize; - const tested = _cb.tested; + // Single source of truth: the deduped resultsArr (rows upserted by + // address) — same approach admin.html uses for its Tested tile. Server + // `state.testedNodes` can double-count retries / counter-increment + // races, which produced public 227 vs admin 150 desync. Fall back to + // _cb.tested only when resultsArr hasn't arrived (early init). + const arr = Array.isArray(resultsArr) ? resultsArr : []; + const tested = arr.length > 0 ? arr.length : _cb.tested; const total = snap || tested; const pct = total > 0 ? Math.min(100, Math.round(tested / total * 100)) : 0; @@ -1218,89 +1346,129 @@

dVPN Node Test

const countEl = _cbEl('cbCount'); if (countEl) countEl.textContent = tested + ' / ' + (total || '—') + ' nodes tested'; + // Pass/Fail derived from resultsArr (matches admin's recompute logic). + const passed = arr.filter(r => Number(r.actualMbps) >= 10).length; + const failed = arr.filter(r => !r.skipped && r.errorCode !== 'TEST_RUN_SKIP' && (r.error || r.errorCode || r.actualMbps == null)).length; const passEl = _cbEl('cbPassed'); - if (passEl) passEl.textContent = _cb.passed; + if (passEl) passEl.textContent = passed; const failEl = _cbEl('cbFailed'); - if (failEl) failEl.textContent = _cb.failed; + if (failEl) failEl.textContent = failed; const snapEl = _cbEl('cbSnapshot'); if (snapEl) snapEl.textContent = snap || '—'; - const elapsed = _cb.startedAt ? Math.round((Date.now() - _cb.startedAt) / 1000) : 0; - const metaEl = _cbEl('cbMeta'); - if (metaEl) { - const bNum = _cb.iteration != null ? 'Batch #' + _cb.iteration : ''; - const elStr = elapsed > 0 ? elapsed + 's ago' : 'just started'; - metaEl.textContent = bNum ? bNum + ' · Started ' + elStr : (_cb.startedAt ? 'Started ' + elStr : 'waiting for admin to start testing…'); - } - - const etaEl = _cbEl('cbEta'); - if (etaEl && tested > 0 && elapsed > 0 && snap > tested) { - const rem = snap - tested; - const rate = tested / elapsed; - const etaSec = Math.round(rem / rate); - const m = Math.floor(etaSec / 60), s = etaSec % 60; - etaEl.textContent = 'ETA ~' + (m > 0 ? m + 'm ' : '') + s + 's'; - } else if (etaEl) { - etaEl.textContent = ''; - } - renderLiveStats(); } function renderLiveStats() { const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; - const snap = _cb.snapshotSize || 0; const arr = Array.isArray(resultsArr) ? resultsArr : []; + // Snapshot total for THIS run. Prefer the current-batch snapshot from + // `batch:start` and `state.totalNodes` (admin's per-run snapshot). Both + // reflect the size of the active sweep, not the chain-wide node count. + const snap = _cb.snapshotSize || + (_liveState && Number(_liveState.totalNodes)) || + arr.length || 0; // "Connected" = node returned mbps>0 (handshake + speed both succeeded). const connected = arr.filter(r => r.actualMbps != null && r.actualMbps > 0).length; - // "Failed" = explicit error OR mbps==null (excluding TEST RUN skips). - const failed = arr.filter(r => !r.skipped && (r.error || r.errorCode || r.actualMbps == null)).length; + // "Failed" = explicit error OR mbps==null. Exclude TEST RUN skips and + // explicit skips — those are not failures, they're not-tested. + const failed = arr.filter(r => !r.skipped && r.errorCode !== 'TEST_RUN_SKIP' && (r.error || r.errorCode || r.actualMbps == null)).length; // Pass 10 Mbps SLA: real connections at or above 10. const p10 = arr.filter(r => r.actualMbps != null && Number(r.actualMbps) >= 10).length; - // Tested = anything we have a row for (matches admin's processed count). - const processed = arr.filter(r => !r.skipped).length; + // Tested counter — match admin.html exactly: deduped resultsArr.length + // is the single source of truth. State.testedNodes can double-count + // (counter-increment races during retries) which caused public to read + // 227 while admin read 150. Mid-run joiners get the full backlog via + // the `init` SSE event + REST fallback, so arr.length is always the + // same row count admin sees. + const processed = arr.length; + // Real processed (excluding TEST_RUN_SKIP / explicit skips) for failure + // / SLA / pass-rate denominators below. + const realProcessed = arr.filter(r => !r.skipped && r.errorCode !== 'TEST_RUN_SKIP').length; // Not Online: failed AND no peers (genuinely offline). const notOnline = arr.filter(r => r.actualMbps == null && (r.peers === 0 || r.peers == null)).length; // Dead Plan Nodes: plan-flagged nodes that failed. const planRows = arr.filter(r => r.inPlan === true); const deadPlan = planRows.filter(r => r.actualMbps == null).length; - // Tested - set('lsTested', processed); - if (snap > 0 && snap > processed) { - set('lsTestedSub', `of ${snap.toLocaleString()} | ${(snap - processed).toLocaleString()} remaining`); - } else if (snap > 0) { - set('lsTestedSub', `of ${snap.toLocaleString()} | complete`); + // Detect TEST RUN: state.testRun flag (immediate), any row marked + // TEST_RUN_SKIP, OR no real-processed rows yet but at least one skipped. + const isDryRun = !!(_liveState && _liveState.testRun) || + arr.some(r => r.errorCode === 'TEST_RUN_SKIP') || + (arr.length > 0 && realProcessed === 0 && + arr.some(r => r.skipped)); + const testRunBadge = document.getElementById('testRunBadge'); + if (testRunBadge) testRunBadge.style.display = isDryRun ? '' : 'none'; + const testRunBanner = document.getElementById('testRunBanner'); + if (testRunBanner) testRunBanner.classList.toggle('is-visible', isDryRun); + + // Tested — always shows real progress, even during a TEST RUN. + if (snap > 0) { + set('lsTested', `${processed.toLocaleString()} / ${snap.toLocaleString()} Nodes`); + set('lsTestedSub', snap > processed + ? `${(snap - processed).toLocaleString()} remaining` + : 'complete'); } else { - set('lsTestedSub', processed > 0 ? `${processed} tested` : 'no data yet'); + set('lsTested', processed.toLocaleString()); + set('lsTestedSub', processed > 0 ? `${processed.toLocaleString()} tested` : 'no data yet'); } + // During a TEST RUN, the speed/SLA/failure metrics are intentionally + // not measured — surface that with a dash so the public sees that + // these are not "0" results, they are simply not produced. + const DASH = '—'; + // Total Failed - set('lsTotalFailed', failed); - const failPct = processed > 0 ? (failed / processed * 100).toFixed(1) : '0'; - set('lsTotalFailedPct', failPct + '% failure rate'); + if (isDryRun) { + set('lsTotalFailed', DASH); + set('lsTotalFailedPct', 'not tested'); + } else { + set('lsTotalFailed', failed); + const failPct = realProcessed > 0 ? (failed / realProcessed * 100).toFixed(1) : '0'; + set('lsTotalFailedPct', failPct + '% failure rate'); + } // Pass 10 Mbps SLA - set('lsPassedSLA', p10); - const slaPct = connected > 0 ? (p10 / connected * 100).toFixed(1) : '0'; - set('lsPassedSLAPct', slaPct + '% of connected'); + if (isDryRun) { + set('lsPassedSLA', DASH); + set('lsPassedSLAPct', 'not tested'); + } else { + set('lsPassedSLA', p10); + const slaPct = connected > 0 ? (p10 / connected * 100).toFixed(1) : '0'; + set('lsPassedSLAPct', slaPct + '% of connected'); + } // Dead Plan Nodes - set('lsDeadPlan', deadPlan); - set('lsDeadPlanPct', planRows.length > 0 - ? `${deadPlan}/${planRows.length} plan nodes failed` - : 'No plan failures'); - - // Not Online - set('lsNotOnline', notOnline); - set('lsNotOnlineSub', notOnline === 1 ? '1 node offline' : `${notOnline} nodes offline`); + if (isDryRun) { + set('lsDeadPlan', DASH); + set('lsDeadPlanPct', 'not tested'); + } else { + set('lsDeadPlan', deadPlan); + set('lsDeadPlanPct', planRows.length > 0 + ? `${deadPlan}/${planRows.length} plan nodes failed` + : 'No plan failures'); + } - // Pass Rate (connected / processed) — matches admin's stat - const connRate = processed > 0 ? (connected / processed * 100).toFixed(1) : '0'; - set('lsPassRate', connRate + '%'); + // Not Online — match admin.html exactly: any row with no measured mbps + // and no peers counts as offline. TEST_RUN_SKIP rows are included for + // parity with admin (user-confirmed behavior 2026-04-25). + const trulyOffline = arr.filter(r => + r.actualMbps == null && + (r.peers === 0 || r.peers == null) + ).length; + set('lsNotOnline', trulyOffline); + set('lsNotOnlineSub', trulyOffline === 1 ? '1 node offline' : `${trulyOffline} nodes offline`); + + // Pass Rate + if (isDryRun) { + set('lsPassRate', DASH); + } else { + const connRate = realProcessed > 0 ? (connected / realProcessed * 100).toFixed(1) : '0'; + set('lsPassRate', connRate + '%'); + } const dot = document.getElementById('headerDot'); if (dot) dot.className = (_cb.startedAt && !_cb.gapEndsAt) ? 'pulse-dot running' : 'pulse-dot'; @@ -1308,7 +1476,8 @@

dVPN Node Test

setInterval(() => { if (_cb.startedAt && _cb.tested >= 0) cbRender(); - else renderLiveStats(); + renderLiveStats(); + applyHeaderStatsFromState(); }, 5000); // ─── Upsert ─── @@ -1317,6 +1486,161 @@

dVPN Node Test

if (idx >= 0) resultsArr[idx] = { ...resultsArr[idx], ...r }; else resultsArr.push(r); addSingleRow(r); + schedulePersist(); + } + + // ─── Live snapshot persistence ─── + // Mirror admin's view across page refresh: cache the current resultsArr + + // last-known state so a refresh paints immediately, before SSE init lands. + const LIVE_CACHE_KEY = 'live:snapshot:v1'; + const LIVE_CACHE_TTL_MS = 60 * 60 * 1000; // 1h — enough to survive a reload + let _liveState = {}; + let _persistTimer = null; + function schedulePersist() { + if (_persistTimer) return; + _persistTimer = setTimeout(() => { + _persistTimer = null; + try { + const snap = { + ts: Date.now(), + broadcastLive: _broadcastLive, + state: _liveState || {}, + // Stamp the run number so rehydrate can detect a stale cache + // from a previous test and discard it instead of repainting + // last run's rows on top of a fresh run. + activeRunNumber: _liveState && _liveState.activeRunNumber != null ? _liveState.activeRunNumber : null, + results: resultsArr, + cb: { ..._cb }, + }; + localStorage.setItem(LIVE_CACHE_KEY, JSON.stringify(snap)); + } catch {} + }, 250); + } + function rehydrateFromCache() { + try { + const raw = localStorage.getItem(LIVE_CACHE_KEY); + if (!raw) return; + const snap = JSON.parse(raw); + if (!snap || !snap.ts || (Date.now() - snap.ts) > LIVE_CACHE_TTL_MS) { + try { localStorage.removeItem(LIVE_CACHE_KEY); } catch {} + return; + } + // Park the cached run number; we'll compare against the live SSE + // state and discard rows below when they're from a previous run. + if (snap.activeRunNumber != null) _prevActiveRunNumber = snap.activeRunNumber; + if (snap.state && typeof snap.state === 'object') _liveState = snap.state; + if (Array.isArray(snap.results) && snap.results.length && resultsArr.length === 0) { + for (const r of snap.results) { + resultsArr.push(r); + addSingleRow(r); + } + } + if (snap.cb && typeof snap.cb === 'object') { + // Restore counters so the bar paints immediately + Object.assign(_cb, snap.cb); + cbRender(); + } + applyHeaderStatsFromState(); + } catch {} + } + + // Merge a state snapshot from admin into our local mirror, then refresh + // the header derived from current-batch values. + function mergeLiveState(s) { + if (!s || typeof s !== 'object') return; + _liveState = { ..._liveState, ...s }; + applyHeaderStatsFromState(); + applyPauseFromState(); + schedulePersist(); + } + + // Show the "Testing Has Been Paused" overlay whenever there is no active + // run on admin — i.e. broadcastLive is off OR state.status is not running + // and there is no in-flight batch. Public visitors should see paused, not + // a frozen stale dashboard. + function applyPauseFromState() { + const status = _liveState && _liveState.status; + const running = status === 'running' || status === 'paused'; + const midBatch = !!(_cb && _cb.batchId); + if (!_broadcastLive) { showPaused(true); return; } + showPaused(!running && !midBatch); + } + + // Derive header stats from the live state + resultsArr (current batch), + // never from chain-wide aggregates. Falls back to "—" when unknown. + function applyHeaderStatsFromState() { + const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; + // Server Baseline — from state.baselineMbps + if (_liveState && _liveState.baselineMbps != null) { + setHeaderBaseline(_liveState.baselineMbps); + } + // Network Nodes — total snapshot size for current batch (state.totalNodes + // tracks scanned/online; fall back to _cb.snapshotSize, then resultsArr.) + const totalNodes = + (_liveState && _liveState.totalNodes) || + _cb.snapshotSize || + resultsArr.length; + // Only overwrite if we actually have a live value; otherwise leave + // whatever loadHeaderStats() painted from /api/public/stats. + if (totalNodes) set('hdrNetworkNodes', Number(totalNodes).toLocaleString()); + // Network Median — derive from passed nodes' actualMbps in current batch + const speeds = resultsArr + .map(r => Number(r.actualMbps)) + .filter(n => Number.isFinite(n) && n > 0) + .sort((a, b) => a - b); + if (speeds.length) { + const mid = Math.floor(speeds.length / 2); + const median = speeds.length % 2 ? speeds[mid] : (speeds[mid - 1] + speeds[mid]) / 2; + set('hdrNetworkMedian', median.toFixed(1) + ' Mbps'); + } + // else: leave the REST-painted value in place. + // Network Pass Rate — passed / processed in current batch. + // Excludes TEST_RUN_SKIP and explicit skips so the rate reflects real tests. + const processed = resultsArr.filter(r => + !(r.skipped || r.errorCode === 'TEST_RUN_SKIP') + ).length; + const passed = resultsArr.filter(r => + Number.isFinite(Number(r.actualMbps)) && Number(r.actualMbps) >= 10 + ).length; + if (processed > 0) set('hdrNetworkPass', Math.round(passed / processed * 100) + '%'); + // else: leave the REST-painted value in place. + applyLiveModeBadge(); + } + + // Mirror the admin "what's running" badge: Plan #N / P2P / Test Run with a + // 1-line description. Reads state.runMode + state.runPlanId (forwarded by + // the server's sanitizePublicState whitelist). + function applyLiveModeBadge() { + const badge = document.getElementById('liveModeBadge'); + const labelEl = document.getElementById('liveModeBadgeLabel'); + const subEl = document.getElementById('liveModeSub'); + if (!badge || !labelEl) return; + const status = _liveState && _liveState.status; + const active = status === 'running' || status === 'paused' || (_cb && _cb.batchId); + if (!active) { + badge.style.display = 'none'; + if (subEl) subEl.textContent = 'Live public testing — Sentinel dVPN network'; + return; + } + let label, detail, color; + if ((_liveState && _liveState.testRun) || (_liveState && _liveState.runMode === 'test')) { + label = 'Test Run'; + detail = 'Sample data — no real measurements, no payments'; + color = 'var(--accent)'; + } else if (_liveState && _liveState.runMode === 'subscription' && _liveState.runPlanId) { + label = 'Plan #' + _liveState.runPlanId; + detail = 'Subscription-allocated sessions, plan-scoped node set'; + color = 'var(--green)'; + } else { + label = 'P2P'; + detail = 'All online nodes — direct peer-to-peer payments per session'; + color = 'var(--accent)'; + } + badge.style.display = ''; + badge.style.borderColor = color; + badge.style.color = color; + labelEl.textContent = label; + if (subEl) subEl.textContent = detail; } // ─── SSE dispatcher ─── @@ -1324,11 +1648,43 @@

dVPN Node Test

let _sseRetry = 1000; const SSE_MAX_RETRY = 30000; + // Track previous run identity so we can detect a new run starting on the + // operator side from `state` events alone — direct /api/start and + // /api/test-sub-plan invocations don't emit `loop:started`, only + // continuous.js does, so without this detector /live keeps stale rows + // from the prior run forever. + let _prevActiveRunNumber = null; + let _prevStatus = null; + + // Wipe rendered rows + log for a brand-new run so the live page is not + // showing stale data from the previous run that the operator just stopped. + // Mirrors the admin panel's "clear log + renderTable on start" behaviour. + function resetLiveForNewRun() { + try { + resultsArr.length = 0; + const tbody = document.getElementById('resultsBody'); + if (tbody) tbody.innerHTML = ''; + const body = document.getElementById('logBody'); + if (body) body.innerHTML = ''; + _cb.batchId = null; + _cb.tested = 0; + _cb.passed = 0; + _cb.failed = 0; + _cb.snapshotSize = 0; + cbRender(); + try { localStorage.removeItem(LIVE_CACHE_KEY); } catch {} + } catch {} + } + function handleEvent(d) { switch (d.type) { case 'loop:started': + // A fresh run started on the operator side — drop stale rows + log + // so /live reflects the new run, not the previous one. + resetLiveForNewRun(); setStatus('running'); - showPaused(false); + _liveState.status = 'running'; + applyPauseFromState(); appendLog({ tag: 'SYS', cat: 'sys', status: 'ok', msg: 'Testing started' }); break; case 'loop:stopping': @@ -1337,12 +1693,16 @@

dVPN Node Test

break; case 'loop:stopped': setStatus('idle'); - showPaused(true); + _liveState.status = 'idle'; + _cb.batchId = null; + applyPauseFromState(); appendLog({ tag: 'SYS', cat: 'sys', msg: 'Testing stopped' }); break; case 'loop:error': setStatus('error'); - showPaused(true); + _liveState.status = 'error'; + _cb.batchId = null; + applyPauseFromState(); appendLog({ tag: 'SYS', cat: 'fail', status: 'fail', errCode: 'TESTER_ERROR', msg: d.error || 'unknown error' }); break; case 'iteration:start': @@ -1356,7 +1716,15 @@

dVPN Node Test

appendLog({ tag: 'ROUND', cat: 'sys', status: 'ok', msg: `Round ${d.iteration || '?'} done — ${passed} online, ${failed} down (${rate} online)` }); break; } - case 'batch:start': + case 'batch:start': { + // If this batch is different from the one we have rendered, drop + // stale rows from the prior batch so the table reflects the current + // run only. (loop:started already wipes for top-level run starts; + // this catches mid-loop iteration boundaries too.) + const newBatchId = d.batchId || d.batch_id || null; + if (newBatchId && _cb.batchId && newBatchId !== _cb.batchId) { + resetLiveForNewRun(); + } setStatus('running'); showPaused(false); cbApplyBatchStart(d); @@ -1365,8 +1733,9 @@

dVPN Node Test

msg: `Round ${d.iteration != null ? d.iteration : (d.batchId || '?')} — testing ${d.snapshotSize || d.snapshot_size || d.total || '?'} nodes`, }); break; + } case 'batch:node:result': { - // Real-run uses snake_case; dry-run fanout uses camelCase. + // Real-run uses snake_case; test-run fanout uses camelCase. const addr = d.node_address ?? d.address ?? null; const moniker = d.moniker || ''; const mbps = d.actual_mbps ?? d.actualMbps ?? null; @@ -1424,7 +1793,7 @@

dVPN Node Test

errorCode: err || null, skipped, inPlan: d.inPlan === true, - dryRun: err === 'TEST_RUN_SKIP', + testRun: err === 'TEST_RUN_SKIP', testedAt: tested, }); cbApplyNodeResult({ passed: resultMbps != null && resultMbps >= 10 }); @@ -1456,7 +1825,10 @@

dVPN Node Test

const running = !!(d.status && d.status.running); const midBatch = !!d.batchId; setStatus(running || midBatch ? 'running' : 'idle'); - showPaused(!running && !midBatch); + // Defer the actual paused-overlay decision until after we've merged + // the full state below so applyPauseFromState sees the latest status. + if (!_liveState.status) _liveState.status = running ? 'running' : 'idle'; + applyPauseFromState(); if (d.batchId && _cb.batchId == null) { _cb.batchId = d.batchId; if (d.snapshotSize) _cb.snapshotSize = d.snapshotSize; @@ -1465,14 +1837,132 @@

dVPN Node Test

// Seed the log panel with the persisted backlog so a page refresh // mid-audit doesn't blank the activity log. if (Array.isArray(d.logs) && d.logs.length) { - for (const line of d.logs) appendLog(line); + const body = document.getElementById('logBody'); + if (body && body.children.length === 0) { + for (const line of d.logs) appendLog(line); + } + } + // Mirror admin: full state snapshot + per-node results so /live paints + // identically on connect — not only on subsequent updates. + if (d.state && typeof d.state === 'object') { + // If localStorage rehydrated rows from a previous run number, + // discover the mismatch on first init and wipe BEFORE the + // results-replay block below paints fresh-run rows on top. + const initRunNum = d.state.activeRunNumber != null ? d.state.activeRunNumber : null; + if (_prevActiveRunNumber != null && initRunNum != null && initRunNum !== _prevActiveRunNumber) { + resetLiveForNewRun(); + } + mergeLiveState(d.state); + _prevActiveRunNumber = initRunNum; + _prevStatus = d.state.status || null; + } + // Init is authoritative for the current run. If REST hydration + // painted stale rows from a previous batch, drop any rows not in + // the init's results list when the run is actively in flight. + // This is what stops /live from showing "253/1040" from test #46 + // while admin is mid-flight on test #47. + const _stateRunning = d.state && (d.state.status === 'running' || d.state.status === 'paused'); + if (_stateRunning && Array.isArray(d.results)) { + const liveAddrs = new Set(d.results.map(r => r && r.address).filter(Boolean)); + if (resultsArr.length > 0 && (resultsArr.length > liveAddrs.size + 5)) { + // Painted >> live → REST seeded historical rows. Wipe all and + // let the init's results array (and subsequent SSE events) be + // the only source of truth for the live table. + resultsArr.length = 0; + const tbody = document.getElementById('resultsBody'); + if (tbody) tbody.innerHTML = ''; + _cb.tested = 0; _cb.passed = 0; _cb.failed = 0; + } } + if (Array.isArray(d.results) && d.results.length) { + for (const r of d.results) { + upsert({ + address: r.address, + moniker: r.moniker || '', + country: r.country || '', + countryCode: r.countryCode || '', + city: r.city || '', + type: normalizeServiceType(r.serviceType ?? r.type), + actualMbps: r.actualMbps != null ? Number(r.actualMbps) : null, + baselineAtTest: r.baselineAtTest != null ? Number(r.baselineAtTest) : null, + peers: r.peers != null ? Number(r.peers) : null, + maxPeers: r.maxPeers != null ? Number(r.maxPeers) : null, + error: r.error || null, + errorCode: r.errorCode || null, + skipped: !!r.skipped, + inPlan: r.inPlan === true, + testRun: r.errorCode === 'TEST_RUN_SKIP', + testedAt: r.testedAt || null, + }); + } + // Sync the current-batch counter to the replayed rows so the + // Tested tile + cb panel reflect actual progress on first paint. + _cb.tested = resultsArr.length; + _cb.passed = resultsArr.filter(x => Number(x.actualMbps) >= 10).length; + _cb.failed = _cb.tested - _cb.passed; + } + applyHeaderStatsFromState(); + renderLiveStats(); + if (_cb.batchId) cbRender(); break; } case 'log': { if (d.msg) appendLog(d.msg); break; } + case 'state': { + // Admin emits broadcast('state', { state }) — server forwards full + // sanitized state. Mirror counters into _liveState + header + tiles. + if (d.state) { + const newRunNum = d.state.activeRunNumber != null ? d.state.activeRunNumber : null; + const newStatus = d.state.status || null; + const runChanged = newRunNum != null && _prevActiveRunNumber != null && newRunNum !== _prevActiveRunNumber; + const statusStartedRun = newStatus === 'running' + && (_prevStatus === 'done' || _prevStatus === 'error' || _prevStatus === 'idle' || _prevStatus === 'stopped'); + if (runChanged || statusStartedRun) { + resetLiveForNewRun(); + } + _prevActiveRunNumber = newRunNum; + _prevStatus = newStatus; + mergeLiveState(d.state); + renderLiveStats(); + if (_cb.batchId) cbRender(); + } + break; + } + case 'result': { + // Same shape as admin: broadcast('result', { result }). Upsert by + // address so /live's table matches admin row-for-row. + const r = d.result; + if (!r || !r.address) break; + upsert({ + address: r.address, + moniker: r.moniker || '', + country: r.country || '', + countryCode: r.countryCode || '', + city: r.city || '', + type: normalizeServiceType(r.serviceType ?? r.type), + actualMbps: r.actualMbps != null ? Number(r.actualMbps) : null, + baselineAtTest: r.baselineAtTest != null ? Number(r.baselineAtTest) : null, + peers: r.peers != null ? Number(r.peers) : null, + maxPeers: r.maxPeers != null ? Number(r.maxPeers) : null, + error: r.error || null, + errorCode: r.errorCode || null, + skipped: !!r.skipped, + inPlan: r.inPlan === true, + testRun: r.errorCode === 'TEST_RUN_SKIP', + testedAt: r.testedAt || null, + }); + applyHeaderStatsFromState(); + renderLiveStats(); + break; + } + case 'progress': { + // Numeric checkpoints from the pipeline (testedNodes, etc.). + // Treat as a partial state update so counters/header refresh. + mergeLiveState(d); + break; + } default: break; } } @@ -1518,6 +2008,13 @@

dVPN Node Test

const data = await res.json(); if (isHistorical || broadcastOff) showPaused(true); const nodes = (data && data.nodes) || (data && data.results) || []; + // Mirror runMode/runPlanId from REST into _liveState so the mode badge + // paints on first load before any SSE state event arrives. + if (data && !isHistorical) { + if (data.runMode) _liveState.runMode = data.runMode; + if (data.runPlanId) _liveState.runPlanId = data.runPlanId; + if (data.mode === 'test') _liveState.testRun = true; + } if (data && (data.started_at || data.id)) { cbApplyBatchStart({ batchId: data.id, @@ -1538,7 +2035,16 @@

dVPN Node Test

// run is actively in flight on the operator side. if (!isHistorical && !broadcastOff) showPaused(false); } - if (Array.isArray(nodes)) { + // Only paint per-node rows when REST returned a *live* in-flight batch. + // The historical fallback (/api/public/runs/last → finished batch) is + // also used when there's no active batch yet — painting those rows + // here causes /live to show stale data (e.g. 253/1040 from the prior + // run) until SSE results trickle in. Counters (#tested, #failed) are + // already wiped above when isHistorical+broadcastOff would be wrong; + // here we just refuse to paint per-node rows for a finished batch + // when broadcast is on, because the operator is mid-run on a NEW one. + const paintRows = Array.isArray(nodes) && (!isHistorical || broadcastOff); + if (paintRows) { for (const n of nodes) { const addr = n.node_address || n.address; if (!addr) continue; @@ -1560,10 +2066,19 @@

dVPN Node Test

errorCode: n.errorCode || n.error_code || null, skipped: !!n.skipped, inPlan: n.inPlan === true || n.in_plan === true, - dryRun: n.error_code === 'TEST_RUN_SKIP', + testRun: n.error_code === 'TEST_RUN_SKIP', testedAt: n.testedAt || n.tested_at || null, }); } + } else if (isHistorical && _broadcastLive) { + // Broadcast ON but no live batch in DB yet (audit hasn't reached + // the snapshot phase) → wipe the table and counters so /live shows + // an empty "warming up" state instead of last-run rows. + resultsArr.length = 0; + const tbody = document.getElementById('resultsBody'); + if (tbody) tbody.innerHTML = ''; + _cb.batchId = null; _cb.tested = 0; _cb.passed = 0; _cb.failed = 0; _cb.snapshotSize = 0; + cbRender(); } } catch (_) {} } @@ -1619,6 +2134,72 @@

dVPN Node Test

} setTimeout(() => { btn.innerHTML = original; btn.disabled = false; }, 1500); } + + async function showRowFailurePopup(ev) { + ev.stopPropagation(); + const btn = ev.currentTarget; + const addr = btn.dataset.addr; + const moniker = btn.dataset.moniker || ''; + if (!addr) return; + + const existing = document.getElementById('errorPopupOverlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'errorPopupOverlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:var(--glass-bg);backdrop-filter:blur(8px);z-index:9999;display:flex;align-items:center;justify-content:center;'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + const popup = document.createElement('div'); + popup.style.cssText = 'background:var(--bg-card-solid);border:1px solid var(--border);border-radius:16px;padding:24px 28px;max-width:780px;width:92%;max-height:85vh;overflow-y:auto;color:var(--text);font-family:var(--font-display);line-height:1.55'; + popup.innerHTML = ` +
+

Node Error Details

+ × +
+
Loading…
+ `; + overlay.appendChild(popup); + document.body.appendChild(overlay); + + try { + const res = await fetch('/api/public/node/' + encodeURIComponent(addr) + '/errors?limit=1', { credentials: 'same-origin' }); + const data = await res.json(); + const errs = (data && data.errors) || []; + const body = document.getElementById('errorPopupBody'); + if (!body) return; + if (!errs.length) { + body.innerHTML = '
No stored failure log for this node yet.
'; + return; + } + const er = errs[0]; + const eh = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); + const captured = er.captured_at ? new Date(typeof er.captured_at === 'number' ? er.captured_at : Number(er.captured_at) || er.captured_at).toLocaleString() : '—'; + const row = (label, value) => ` +
+
${label}
+
${value || '—'}
+
`; + body.innerHTML = ` + ${row('Node', eh(moniker))} + ${row('Address', `${eh(addr)}`)} + ${row('Stage', eh(er.stage))} + ${row('Error Code', `${eh(er.error_code)}`)} + ${row('Captured', eh(captured))} + ${row('Message', `${eh(er.error_message)}`)} + ${er.log_snippet ? ` +
+
Log Snippet
+
${eh(er.log_snippet)}
+
` : ''} +
+ +
+ `; + } catch (err) { + const body = document.getElementById('errorPopupBody'); + if (body) body.innerHTML = '
Failed to load: ' + (err.message || 'unknown') + '
'; + } + } diff --git a/memory/handoff-node-tester.md b/memory/handoff-node-tester.md index c6ff13a..21864e5 100644 --- a/memory/handoff-node-tester.md +++ b/memory/handoff-node-tester.md @@ -1,14 +1,52 @@ # Node Tester — Handoff +## 2026-04-25 — Rename dry run → test run across entire project +- **62 occurrences renamed** across 12 files. All `dryRun`/`dry_run`/`dry-run`/`DRY_RUN` identifiers replaced with the `testRun`/`test_run`/`--test-run`/`TEST_RUN` family. Comments/docs updated to "test run" phrasing. +- **DB migration v7 added** (`core/db.js` lines 275–282): `UPDATE runs SET mode='test' WHERE mode='dry'` — idempotent, runs automatically on next startup. Existing `audit.db` rows with `mode='dry'` are migrated to `mode='test'`. Schema version bumped from 6 → 7. +- **Backward-compat alias in `server.js` `/api/start`**: accepts `req.body.testRun` / `?testRun=1` (new, send going forward) AND `req.body.dryRun` / `?dryRun=1` (old, one-release alias). Coalesced to single `testRun` boolean. `bin/commands/agent.js` now sends `testRun` via `--test-run` flag. +- **Do NOT change**: `TEST_RUN_SKIP` error code (already correct), "TEST RUN" UI labels (already correct), `audit-dry.db` historical tombstones in this handoff. + +## 2026-04-25 — Admin top-row alignment pass 2 +- Bumped TEST RUN, ∞ LOOP, PAY/PER GB/PER HOUR boxes to `height:38px` and `border-radius:6px` to match the surrounding `.btn` controls (38px). Uniform padding `0 14px` on outer wrappers + button segments. ∞ glyph bumped to `font-size:24px` inside a fixed-width `width:20px` inline-block (text-centered) so it can't push baseline. All three boxes share `font-family:var(--font-display)`, `font-size:11px`, `font-weight:700`, `letter-spacing:0.5px`, `line-height:1` so vertical center matches across the row. + +## 2026-04-25 — Admin SLA tiles dash out in TEST RUN +- Total Failed, Pass 10 Mbps SLA, Dead Plan Nodes, and Pass Rate now render `—` with sub `not measured in test run` when admin detects a TEST RUN (`state.testRun || any row with errorCode==='TEST_RUN_SKIP'`). Matches /live's behavior so the operator can't mis-read skip-only demo numbers as real measurements. Tested tile + Not Online still show real progress. + +## 2026-04-25 — /live Not Online parity with admin +- TEST RUN was reporting "0 nodes offline" on /live while admin reported the real count. /live's `trulyOffline` filter excluded TEST_RUN_SKIP rows; admin's does not. Aligned /live to admin's filter (`actualMbps == null && (peers === 0 || peers == null)`) so both surfaces report identical counts in every mode. + +## 2026-04-25 — /live counter parity, transport in TEST RUN, banner gap, admin top-row alignment +- **Public 227 vs admin 150 Tested mismatch.** The earlier "prefer `state.testedNodes`" fix on /live caused public to *outpace* admin: pipeline counter increments (retries, race with recompute) made `state.testedNodes` greater than the deduped row count admin uses. Reverted both `cbRender()` and `renderLiveStats()` in `live.html` to use `resultsArr.length` (deduped by address via `upsert`) — same single source of truth as `admin.html` line 1296. Pass/Fail derived from `resultsArr` filter (matches admin's recompute). Mid-run joiners get the full backlog via the `init` SSE event + REST fallback, so the new approach can't regress the original "0 / 1048" symptom. +- **Transport missing in TEST RUN.** `live.html` was reading `r.serviceType` from the `result` SSE event, but the pipeline emits `r.type` (set in `audit/node-test.js:219` for the TEST_RUN_SKIP early return) — sanitizer maps `r.type → serviceType` for `batch:node:result` but the `result` event passes the raw object through. Patched `live.html` `case 'result'` + the `init` replay to use `normalizeServiceType(r.serviceType ?? r.type)`. Transport badge now renders for every TEST RUN row. +- **TEST RUN banner gap too small.** `.test-run-banner` had no bottom margin so the page title was glued to the alert. Added `margin-bottom: 22px` and bumped padding to `12px 16px`. +- **Admin top-row alignment + ∞ icon size.** TEST RUN, ∞ LOOP, and PAY/PER GB/PER HOUR controls now share `font-family: var(--font-display)`, `font-size:11px`, `font-weight:700`, `letter-spacing:0.5px`, and `height:34px`. ∞ glyph bumped from `font-size:14px` → `20px` (with `font-weight:400`) so it visually balances the "LOOP" label. Pricing wrapper switched to `align-items:stretch` and child segments use `display:inline-flex; align-items:center` so all three controls end at the same right edge. + +## 2026-04-25 — Strip ETA + "Started …s ago" from public /live +- Removed `#cbMeta` ("Batch #N · Started …s ago" / "waiting for admin to start testing…") and `#cbEta` ("ETA ~Xm Ys") from the public Current Batch panel in `live.html`. Both DOM elements deleted; `cbRender()` no longer computes elapsed/meta/eta. CSS rules `.cb-meta` / `.cb-eta` left in place (no longer referenced; harmless). Public-only — admin Current Batch panel untouched. + +## 2026-04-25 — Agent CLI + /live Tested tile fix +- **Built end-to-end agentic CLI driver.** `bin/commands/agent.js` + `bin/lib/http.js`. One subcommand router with 54 endpoints registered, each self-describing via `agent map`. Auth: `--token`, `$SENTINEL_AUDIT_TOKEN`, or `$ADMIN_TOKEN` (Bearer). CSRF-friendly: every non-GET sets `X-Admin-Request: 1`. Target: `--base-url`, `$SENTINEL_AUDIT_URL`, or `--port` (default `http://localhost:3001`). + - Discovery: `sentinel-audit agent map --pretty`, `sentinel-audit agent --help`. + - Audit lifecycle: `start [--test-run] [--pricing-mode hours|gigabytes] [--plan-id] [--sub-id] [--sub-granter]`, `stop`, `resume`, `rescan`, `clear`. + - Retest: `retest-skips`, `retest-fails`, `auto-retest`. + - Reads: `state`, `stats`, `results`, `plans`, `subscriptions`, `sub-plans`, `failure-analysis`, `transport-cache`, `chain-nodes`, `chain-status`. + - Public reads: `pub-nodes`, `pub-node `, `pub-node-errors `, `pub-bandwidth `, `pub-errors`, `pub-countries`, `pub-stats`, `pub-runs`, `pub-run-current`, `pub-run-last`, `pub-batches`, `pub-batch `, `pub-logs`, `pub-live-state`, `pub-test-status`. + - Run history: `runs`, `run-get `, `run-save`, `run-load `. + - Toggles: `broadcast` / `broadcast-toggle`, `economy`, `sdk-get` / `sdk-set --sdk js|tkd`, `dns-get` / `dns-set --dns ... --enabled true|false`. + - Streaming: `events --watch 30` and `pub-events --watch 30` — taps SSE to stderr, returns aggregate JSON on stdout. + - Wired into `bin/cli.js` `COMMAND_GROUPS.Agent`. `node bin/cli.js agent map` returns 54 endpoints. +- **Fixed /live "0 / 1048 nodes tested" stuck counter.** When `/live` joined mid-run via `init` and missed earlier `result`/`batch:node:result` SSE events, both the Tested tile and Current Batch panel showed `0 / N` while admin had progress. Root: tile relied on `arr.length` and panel on `_cb.tested`, both incremented purely from streamed events. + - Fix in `live.html`: `renderLiveStats()` now uses `_liveState.testedNodes` as the authoritative `processed` source when available (falls back to `arr.length`); `cbRender()` uses `_liveState.testedNodes` for `tested`, `_liveState.passed10` for passed, `_liveState.failedNodes` for failed (each fall back to local `_cb.*`); `state` SSE handler now also calls `cbRender()` so the panel refreshes on every server state push. `state.testedNodes/failedNodes/passed10` were already in `PUBLIC_STATE_KEYS` — they were arriving but unused. + ## 2026-04-25 — Single-mode refactor + Economy deprecation - **Dual-mode system dropped.** The dev/bundled/public mode cookie, `requireMode()` middleware, mode overlay UI, `_currentMode`, `_applyModeUI`, `selectMode`, and `switchMode` are all gone. There is now one mode and no mode-switching anywhere in the stack. -- **One database.** `audit-dry.db` is gone. All runs — live and dry — write to `audit.db`. Dry runs get `mode='dry'` on the run row so they remain visually distinguishable in the admin table. +- **One database.** `audit-dry.db` is gone. All runs — live and test — write to `audit.db`. Test runs get `mode='test'` on the run row so they remain visually distinguishable in the admin table. - **`state.broadcastLive` added.** Server-side boolean that controls whether public surfaces (`public.html`, `/live`, `/api/public/events`, `/api/public/runs/current`) show the live in-flight audit or the last-completed snapshot. - `POST /api/broadcast` (adminOnly) — flips the toggle. No body required. - `GET /api/broadcast` — returns `{ broadcastLive: boolean }`. Open. - When `false`: public SSE is silent, public sees last-completed snapshot. - When `true`: public SSE fan-out becomes active, `/live` upgrades from snapshot view to live progress view. -- **TEST RUN preserved as `?dryRun=1`.** Pass `dryRun: true` in body or `?dryRun=1` on `POST /api/start`. Pipeline skips plan membership, online scan, chain ops, payments. Every node row: `actualMbps: null, errorCode: 'TEST_RUN_SKIP'`. Run row: `mode='dry'` in `audit.db`. Not a separate mode — just a parameter. +- **TEST RUN now `?testRun=1`.** Pass `testRun: true` in body or `?testRun=1` on `POST /api/start`. Pipeline skips plan membership, online scan, chain ops, payments. Every node row: `actualMbps: null, errorCode: 'TEST_RUN_SKIP'`. Run row: `mode='test'` in `audit.db`. Not a separate mode — just a parameter. - **Removed endpoints:** `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`. - **Economy mode fully deprecated** (removed earlier this same session — no economy-mode code paths remain). - **Failure-log UX still intact (hard rule).** Per-row copy button (`.row-copy-btn`, glyph `⎘`, `copyRowFailure`), admin drawer "Copy Failure Logs" (`#copyFailureLogsBtn`) + "Download .txt" button — all wired and untouched by this refactor. diff --git a/public.html b/public.html index 7a7ddc0..be49c65 100644 --- a/public.html +++ b/public.html @@ -2078,7 +2078,8 @@

View on Desktop

const countryFull = `${ctry}${city}`; const isFail = n.handshake_ok === false; const rowCopy = isFail - ? `` + ? ` + ` : ''; tr.innerHTML = ` ${idx} @@ -2174,6 +2175,72 @@

View on Desktop

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'); + const data = await res.json(); + const errs = (data && data.errors) || []; + const body = document.getElementById('errorPopupBody'); + if (!body) return; + if (!errs.length) { + body.innerHTML = '
No stored failure log for this node yet.
'; + return; + } + const er = errs[0]; + const eh = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); + const captured = er.captured_at ? new Date(typeof er.captured_at === 'number' ? er.captured_at : Number(er.captured_at) || er.captured_at).toLocaleString() : '—'; + const row = (label, value) => ` +
+
${label}
+
${value || '—'}
+
`; + body.innerHTML = ` + ${row('Node', eh(moniker))} + ${row('Address', `${eh(addr)}`)} + ${row('Stage', eh(er.stage))} + ${row('Error Code', `${eh(er.error_code)}`)} + ${row('Captured', eh(captured))} + ${row('Message', `${eh(er.error_message)}`)} + ${er.log_snippet ? ` +
+
Log Snippet
+
${eh(er.log_snippet)}
+
` : ''} +
+ +
+ `; + } catch (err) { + const body = document.getElementById('errorPopupBody'); + if (body) body.innerHTML = '
Failed to load: ' + (err.message || 'unknown') + '
'; + } + } + // ─── Detail Drawer ─── async function openDetail(node) { selectedAddr = node.address; @@ -2860,7 +2927,7 @@

View on Desktop

break; } case 'batch:node:result': { - // Real-run payloads use snake_case; dry-run fanout uses camelCase. + // Real-run payloads use snake_case; test-run fanout uses camelCase. const addr = d.node_address ?? d.address ?? null; const moniker = d.moniker || ''; const mbps = d.actual_mbps ?? d.actualMbps ?? null; @@ -2891,7 +2958,7 @@

View on Desktop

handshake_ok: (d.skipped || err === 'TEST_RUN_SKIP') ? null : (mbps > 0 ? true : (err ? false : null)), last_tested_at: tested, - dry_run: err === 'TEST_RUN_SKIP' || undefined, + test_run: err === 'TEST_RUN_SKIP' || undefined, }); const idx = allNodes.findIndex(n => n.address === norm.address); if (idx >= 0) allNodes[idx] = norm; else allNodes.push(norm); diff --git a/scripts/cleanup-runaway-runs.mjs b/scripts/cleanup-runaway-runs.mjs index 7cc1247..e9d627c 100644 --- a/scripts/cleanup-runaway-runs.mjs +++ b/scripts/cleanup-runaway-runs.mjs @@ -13,7 +13,7 @@ * drop the old one, rename. This is O(legitimate rows) = O(100), not O(54M). * * Usage: - * node scripts/cleanup-runaway-runs.mjs # dry-run (counts only) + * node scripts/cleanup-runaway-runs.mjs # test-run (counts only) * node scripts/cleanup-runaway-runs.mjs --yes # actually execute */ diff --git a/server.js b/server.js index 50df605..62e26d8 100644 --- a/server.js +++ b/server.js @@ -16,7 +16,7 @@ import { MNEMONIC, DENOM, GAS_PRICE, PORT, LCD_ENDPOINTS, PROJECT_ROOT, DNS_PRES import { cachedWalletSetup, createFreshClient } from './core/wallet.js'; import { ensureLcd, getActiveLcd, cleanupRpc, getAllNodes } from './core/chain.js'; import { nodeStatusV3 } from './protocol/v3protocol.js'; -import { createState, runAudit, runRetestSkips, runPlanTest, runSubPlanTest, getResults, saveResults, setActiveRunDir, setActiveDbRunId } from './audit/pipeline.js'; +import { createState, runAudit, runRetestSkips, runPlanTest, runSubPlanTest, getResults, saveResults, setActiveRunDir, setActiveDbRunId, triggerPipelineStop } from './audit/pipeline.js'; import { insertRun, updateRunOnFinish, insertResult, insertErrorLog, @@ -202,11 +202,27 @@ function broadcast(type, data = {}) { for (const evt of BATCH_EVENTS) { continuous.on(evt, (data) => broadcast(evt, data || {})); } + + // Forward per-node log/state/result/progress from inside the continuous + // pipeline so the live dashboard mirrors the admin dashboard 1:1 during + // continuous-loop runs (not only direct /api/start runs). + const LIVE_EVENTS = ['log', 'state', 'result', 'progress']; + for (const evt of LIVE_EVENTS) { + continuous.on(evt, (data) => broadcast(evt, data || {})); + } } // ─── State ────────────────────────────────────────────────────────────────── const state = createState(); -state.broadcastLive = false; + +// Persist Broadcast Live across restarts so the operator's choice survives +// process bounces — without this, every restart silently flips public /live +// back to "paused" even though the admin UI still shows BROADCAST ON. +const BROADCAST_PREF_FILE = path.join(__dirname, 'results', '.broadcast-live'); +try { state.broadcastLive = _rfs(BROADCAST_PREF_FILE, 'utf8').trim() === '1'; } catch { state.broadcastLive = false; } +function persistBroadcastPref() { + try { _wfs(BROADCAST_PREF_FILE, state.broadcastLive ? '1' : '0'); } catch {} +} // Persist SDK choice to disk so it survives restarts const SDK_PREF_FILE = path.join(__dirname, 'results', '.sdk-preference'); @@ -574,6 +590,10 @@ app.get('/api/public/runs/current', attachAdminFlag, rlPublicRead, (req, res) => passed: batch.passed, failed: batch.failed, mode: batch.mode, + // Mirror the in-memory run mode so /live renders the same badge as admin + // before any SSE state event arrives. runPlanId is null unless plan mode. + runMode: state.runMode || null, + runPlanId: state.runPlanId || null, nodes, }); } catch (err) { @@ -862,9 +882,81 @@ const PUBLIC_EVENT_WHITELIST = new Set([ // Live operator log lines — needed so /live shows real-time activity. // sanitizeForPublic already truncates evt.msg to 400 chars. 'log', + // Full state + per-node result events — /live mirrors the admin dashboard + // counters and per-row results 1:1 when broadcastLive is on. + 'state', + 'result', + 'progress', ]); + +// Keep only the counters / progress fields a public viewer needs. +// Strips wallet, balance*, spent*, MNEMONIC-derived data, errorMessage internals. +const PUBLIC_STATE_KEYS = [ + 'status', + 'totalNodes', + 'testedNodes', + 'failedNodes', + 'skippedNodes', + 'passed10', + 'passed15', + 'passedBaseline', + 'baselineMbps', + 'baselineHistory', + 'nodeSpeedHistory', + 'currentNode', + 'currentType', + 'currentLocation', + 'startedAt', + 'completedAt', + 'activeRunNumber', + 'testRun', + 'continuousLoop', + 'pricingMode', + // Surfaces the active mode + plan id so /live can render the same + // "Plan #N / P2P / Test Run" badge the admin shows. + 'runMode', + 'runPlanId', +]; +function sanitizePublicState(s) { + if (!s || typeof s !== 'object') return {}; + const out = {}; + for (const k of PUBLIC_STATE_KEYS) if (s[k] !== undefined) out[k] = s[k]; + return out; +} +// Mirror of admin's per-node result row, minus operator-internal fields. +function sanitizePublicResult(r) { + if (!r || typeof r !== 'object') return null; + return { + address: r.address, + moniker: r.moniker, + serviceType: r.type ?? r.serviceType, + countryCode: r.countryCode, + city: r.city, + actualMbps: r.actualMbps, + advertisedMbps: r.advertisedMbps, + peers: r.peers, + maxPeers: r.maxPeers, + errorCode: r.errorCode, + error: r.error ? String(r.error).slice(0, 200) : null, + skipped: r.skipped === true ? true : undefined, + inPlan: r.inPlan === true ? true : undefined, + testedAt: r.testedAt, + baselineAtTest: r.baselineAtTest, + dynamicThreshold: r.dynamicThreshold, + pass10mbps: r.pass10mbps, + latencyMs: r.latencyMs, + handshakeMs: r.handshakeMs, + sessionMs: r.sessionMs, + }; +} function sanitizeForPublic(evt) { const safe = { type: evt.type }; + // Nested state/result payloads (admin emits broadcast('state', { state }) / broadcast('result', { result })) + if (evt.state && typeof evt.state === 'object') safe.state = sanitizePublicState(evt.state); + if (evt.result && typeof evt.result === 'object') { + const sr = sanitizePublicResult(evt.result); + if (sr) safe.result = sr; + } if (evt.iteration != null) safe.iteration = evt.iteration; if (evt.mode != null) safe.mode = evt.mode; if (evt.passed != null) safe.passed = evt.passed; @@ -920,6 +1012,12 @@ app.get('/api/public/events', attachAdminFlag, rlPublicSse, (req, res) => { activeBatchMode = ab.batch.mode; } } catch (_) {} + // Sanitized snapshot of in-memory state and per-node results so /live paints + // the full dashboard on connect/refresh — fully identical to admin (minus + // operator-internal fields). Empty when broadcastLive is off. + const liveOn = !!state.broadcastLive; + const initState = liveOn ? sanitizePublicState(state) : {}; + const initResults = liveOn ? getResults().map(sanitizePublicResult).filter(Boolean) : []; send({ type: 'init', status: { running: s.running, iteration: s.iteration, mode: s.mode, startedAt: s.startedAt, uptime: s.uptime }, @@ -928,8 +1026,10 @@ app.get('/api/public/events', attachAdminFlag, rlPublicSse, (req, res) => { batchMode: activeBatchMode, // Persisted log backlog so /live shows full history on refresh, not a blank panel. // logBuffer is populated from results/audit-*.log on server boot and updated live. - logs: state.broadcastLive ? logBuffer.slice() : [], - broadcastLive: !!state.broadcastLive, + logs: liveOn ? logBuffer.slice() : [], + state: initState, + results: initResults, + broadcastLive: liveOn, }); const handler = (data) => { if (!state.broadcastLive) return; @@ -956,6 +1056,23 @@ app.get('/api/public/logs', attachAdminFlag, rlPublicRead, (req, res) => { res.json({ logs: logBuffer.slice(), broadcastLive: true }); }); +/** + * GET /api/public/live-state + * Sanitized snapshot of state + results so /live can rehydrate after refresh + * even before SSE init lands. Mirrors admin /api/state + /api/results minus + * operator-internal fields. Empty when broadcastLive is off. + */ +app.get('/api/public/live-state', attachAdminFlag, rlPublicRead, (req, res) => { + if (!state.broadcastLive) { + return res.json({ broadcastLive: false, state: {}, results: [] }); + } + res.json({ + broadcastLive: true, + state: sanitizePublicState(state), + results: getResults().map(sanitizePublicResult).filter(Boolean), + }); +}); + /** * GET /api/public/test/status * Read-only loop status snapshot. No wallet / plan IDs. @@ -1036,6 +1153,7 @@ app.post('/api/public/test/stop', attachAdminFlag, (req, res) => { // ─── Broadcast Live toggle ─────────────────────────────────────────────────── app.post('/api/broadcast', adminOnly, (req, res) => { state.broadcastLive = !state.broadcastLive; + persistBroadcastPref(); res.json({ broadcastLive: state.broadcastLive }); }); @@ -1112,6 +1230,10 @@ function startFreshRun(label, { mode = 'p2p', plan_id = null } = {}) { state.retryCount = 0; state.estimatedTotalCost = '0 P2P'; state.spentUdvpn = 0; + // Surface the active mode so the UI can render a clear "what's running" badge: + // 'subscription' (sub-plan), 'p2p', 'test'. Plan id is null unless mode === 'subscription'. + state.runMode = mode; + state.runPlanId = plan_id || null; try { _wfs(STATE_SNAPSHOT_FILE, '{}', 'utf8'); } catch { } @@ -1144,7 +1266,10 @@ function startFreshRun(label, { mode = 'p2p', plan_id = null } = {}) { // Start NEW test (saves current, clears, starts fresh). app.post('/api/start', adminOnly, async (req, res) => { - const dryRun = !!(req.body?.dryRun || req.query.dryRun); + // Accept testRun (current) or dryRun (one-release backward-compat alias — remove next release). + const testRun = !!(req.body?.testRun || req.query.testRun || req.body?.dryRun || req.query.dryRun); + const infiniteLoop = !!(req.body?.infiniteLoop || req.query.infiniteLoop); + const pricingMode = (req.body?.pricingMode === 'hours' || req.query.pricingMode === 'hours') ? 'hours' : 'gigabytes'; if (state.status === 'running' || state.status === 'paused') return res.json({ error: 'Already running' }); if (continuous.status().running) { @@ -1158,23 +1283,49 @@ app.post('/api/start', adminOnly, async (req, res) => { await new Promise(r => setTimeout(r, 100)); } } - if (!dryRun && !MNEMONIC) return res.json({ error: 'MNEMONIC not set in .env' }); - - const runMode = dryRun ? 'dry' : 'p2p'; - const { newNum } = startFreshRun(`Test #${getNextRunNumber()}`, { mode: runMode }); + if (!testRun && !MNEMONIC) return res.json({ error: 'MNEMONIC not set in .env' }); + + state.continuousLoop = infiniteLoop; + state.testRun = testRun; + state.pricingMode = pricingMode; + const runMode = testRun ? 'test' : 'p2p'; + state.runMode = runMode; + state.runPlanId = null; + state.runSubscriptionId = null; + state.runGranter = null; + const { newNum: firstNum } = startFreshRun(`Test #${getNextRunNumber()}`, { mode: runMode }); const SDK_LABELS = { js: 'Blue JS', csharp: 'Blue C#', tkd: 'TKD JS' }; const label = `${SDK_LABELS[state.activeSDK] || state.activeSDK} SDK, ${process.platform === 'win32' ? 'Windows' : process.platform}`; - broadcast('log', { msg: `🚀 Starting Test #${newNum} (${label})${dryRun ? ' [TEST RUN]' : ''}` }); - res.json({ ok: true, testNumber: newNum, dryRun }); - runAudit(false, state, broadcast, null, { dryRun }).then(() => { - saveCurrentRun(`Test #${newNum}`); - broadcast('log', { msg: `💾 Test #${newNum} complete and saved` }); - }).catch(err => { - state.status = 'error'; - state.errorMessage = err.message; + const modeTag = testRun ? 'Test Run (sample data)' : 'P2P (all online nodes)'; + broadcast('log', { msg: `🚀 Starting Test #${firstNum} — Mode: ${modeTag} | ${label}${infiniteLoop ? ' | ∞ LOOP' : ''} | Pricing: ${pricingMode === 'hours' ? 'Per Hour' : 'Per GB'}` }); + res.json({ ok: true, testNumber: firstNum, testRun, infiniteLoop, pricingMode }); + broadcast('state', { state }); + + (async () => { + let curNum = firstNum; + // First pass + any further passes if continuousLoop stays true. + while (true) { + try { + await runAudit(false, state, broadcast, null, { testRun, pricingMode }); + saveCurrentRun(`Test #${curNum}`); + broadcast('log', { msg: `💾 Test #${curNum} complete and saved` }); + } catch (err) { + state.status = 'error'; + state.errorMessage = err.message; + broadcast('state', { state }); + break; + } + if (!state.continuousLoop || state.stopRequested) break; + // Re-snapshot the chain and start a fresh run. + curNum = getNextRunNumber(); + startFreshRun(`Test #${curNum}`, { mode: runMode }); + broadcast('log', { msg: `♾ Loop continues — starting Test #${curNum}` }); + broadcast('state', { state }); + } + state.continuousLoop = false; broadcast('state', { state }); - }); + })(); }); // Resume CURRENT test from where it left off (skips already-tested nodes). @@ -1200,20 +1351,67 @@ app.post('/api/resume', adminOnly, async (req, res) => { try { _mkd(resumeRunDir, { recursive: true }); } catch { } setActiveRunDir(resumeRunDir); - broadcast('log', { msg: `▶ Resuming Test #${state.activeRunNumber} from node ${results.length + 1} (${results.length} already tested, SDK: ${state.activeSDK.toUpperCase()})` }); - res.json({ ok: true, testNumber: state.activeRunNumber, resumeFrom: results.length }); - runAudit(true, state, broadcast).then(() => { - saveCurrentRun(`Test #${state.activeRunNumber}`); - broadcast('log', { msg: `💾 Test #${state.activeRunNumber} saved` }); - }).catch(err => { - state.status = 'error'; - state.errorMessage = err.message; - broadcast('state', { state }); - }); + const runMode = state.runMode || 'p2p'; + const modeTag = runMode === 'subscription' ? `Sub. Plan ${state.runPlanId}` : (runMode === 'test' ? 'Test Run' : 'P2P'); + broadcast('log', { msg: `▶ Resuming Test #${state.activeRunNumber} (${modeTag}) from node ${results.length + 1} (${results.length} already tested, SDK: ${state.activeSDK.toUpperCase()})` }); + res.json({ ok: true, testNumber: state.activeRunNumber, resumeFrom: results.length, runMode }); + + if (runMode === 'subscription') { + if (!state.runPlanId || !state.runSubscriptionId || !state.runGranter) { + state.status = 'error'; + state.errorMessage = 'Cannot resume subscription run — missing plan context. Start a new test.'; + broadcast('state', { state }); + return; + } + runSubPlanTest(state.runPlanId, state.runSubscriptionId, state.runGranter, state, broadcast, { resume: true }).then(() => { + saveCurrentRun(`Test #${state.activeRunNumber} — Sub. Plan ${state.runPlanId}`); + broadcast('log', { msg: `💾 Test #${state.activeRunNumber} saved` }); + }).catch(err => { + state.status = 'error'; + state.errorMessage = err.message; + broadcast('state', { state }); + }); + } else { + runAudit(true, state, broadcast, null, { testRun: !!state.testRun, pricingMode: state.pricingMode }).then(() => { + saveCurrentRun(`Test #${state.activeRunNumber}`); + broadcast('log', { msg: `💾 Test #${state.activeRunNumber} saved` }); + }).catch(err => { + state.status = 'error'; + state.errorMessage = err.message; + broadcast('state', { state }); + }); + } }); app.post('/api/stop', adminOnly, (req, res) => { + // Set stop flags first so any wakeup from the kills below sees them. state.stopRequested = true; + state.continuousLoop = false; + try { continuous.stop(); } catch {} + + // Wake every in-flight pipeline `await sleep(...)` immediately so the per-node + // loop drops back to its `if (state.stopRequested) break` check on the next tick. + // Without this the longest pending sleep (e.g. balance-poll 5min) holds up Stop. + try { triggerPipelineStop(); } catch {} + + // Snap the UI to stopped immediately — the pipeline still has to finish unwinding, + // but the user gets feedback the moment they click Stop. + state.status = 'stopped'; + broadcast('log', { msg: '⏹ Stop — force-terminating in-flight test.' }); + broadcast('state', { state }); + + // Force-stop in-flight node test: kill V2Ray (causes waitForPort/speedtest to fail + // immediately), then run WG cleanup. Without this, Stop waits up to ~20s for the + // current node's session/handshake/speedtest timers to expire. + (async () => { + try { + const { killAllV2Ray } = await import('./platforms/windows/v2ray.js'); + killAllV2Ray(); + } catch {} + try { emergencyCleanupSync(); } catch {} + broadcast('state', { state }); + })(); + res.json({ ok: true }); }); @@ -1318,20 +1516,42 @@ app.post('/api/test-sub-plan', adminOnly, async (req, res) => { if (!planId) return res.status(400).json({ error: 'planId required' }); if (!subscriptionId) return res.status(400).json({ error: 'subscriptionId required' }); if (!granter) return res.status(400).json({ error: 'granter (sent1...) required' }); + const infiniteLoop = !!(req.body?.infiniteLoop || req.query.infiniteLoop); const { newNum } = startFreshRun(`Sub. Plan ${planId}`, { mode: 'subscription', plan_id: String(planId) }); + state.continuousLoop = infiniteLoop; + state.runMode = 'subscription'; + state.runPlanId = String(planId); + state.runSubscriptionId = String(subscriptionId); + state.runGranter = String(granter); + state.testRun = false; broadcast('state', { state, results: getResults() }); - broadcast('log', { msg: `🚀 Starting Test #${newNum} — Sub. Plan ${planId} (fee-granted, wallet pays zero gas)` }); - res.json({ ok: true, testNumber: newNum, planId, subscriptionId, granter }); + broadcast('log', { msg: `🚀 Starting Test #${newNum} — Mode: Plan #${planId} (subscription-allocated sessions, plan-scoped node set)${infiniteLoop ? ' | ∞ LOOP' : ''}` }); + res.json({ ok: true, testNumber: newNum, planId, subscriptionId, granter, infiniteLoop }); - runSubPlanTest(String(planId), String(subscriptionId), String(granter), state, broadcast).then(() => { - saveCurrentRun(`Test #${newNum} — Sub. Plan ${planId}`); - broadcast('log', { msg: `💾 Test #${newNum} complete and saved` }); - }).catch(err => { - state.status = 'error'; - state.errorMessage = err.message; + (async () => { + let curNum = newNum; + while (true) { + try { + await runSubPlanTest(String(planId), String(subscriptionId), String(granter), state, broadcast); + saveCurrentRun(`Test #${curNum} — Sub. Plan ${planId}`); + broadcast('log', { msg: `💾 Test #${curNum} complete and saved` }); + } catch (err) { + state.status = 'error'; + state.errorMessage = err.message; + broadcast('state', { state }); + break; + } + if (!state.continuousLoop || state.stopRequested) break; + curNum = getNextRunNumber(); + startFreshRun(`Sub. Plan ${planId}`, { mode: 'subscription', plan_id: String(planId) }); + state.continuousLoop = true; + broadcast('log', { msg: `♾ Loop continues — starting Test #${curNum} (Plan #${planId})` }); + broadcast('state', { state }); + } + state.continuousLoop = false; broadcast('state', { state }); - }); + })(); }); app.post('/api/clear', adminOnly, (req, res) => { From 46fd3ce5bd5ce6a6ea28438a806f2369aa82674b Mon Sep 17 00:00:00 2001 From: Human and Agent dVPN <271368948+Sentinel-Autonomybuilder@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:11:23 -0700 Subject: [PATCH 2/4] Remove deprecated Economy Mode; show UTC snapshot timestamp on /public - Strip economyMode from server, CLI agent, types, smoke test, and admin UI (button + handlers + state-sync). The /api/economy 410 stub also goes. - Add fmtUtc() helper and use the latest batch.started_at to render "Snapshot YYYY-MM-DD HH:MM UTC" on public.html (current view + batch picker). - Sequence loadBatchList() before loadNodes() so the label has data. --- bin/commands/agent.js | 2 -- core/types.js | 1 - index.html | 36 ------------------------------------ public.html | 28 ++++++++++++++++++++++++---- server.js | 5 ----- test/smoke.test.js | 1 - types/index.d.ts | 1 - 7 files changed, 24 insertions(+), 50 deletions(-) diff --git a/bin/commands/agent.js b/bin/commands/agent.js index a82c7fa..4fb38a1 100644 --- a/bin/commands/agent.js +++ b/bin/commands/agent.js @@ -104,8 +104,6 @@ const ENDPOINTS = [ { sub: 'retest-fails', method: 'POST', path: '/api/retest-fails', auth: true, desc: 'Retest only failed nodes' }, { sub: 'auto-retest', method: 'POST', path: '/api/auto-retest', auth: true, desc: 'Trigger background auto-retest sweep' }, { sub: 'clear', method: 'POST', path: '/api/clear', auth: true, desc: 'Clear current results buffer' }, - { sub: 'economy', method: 'POST', path: '/api/economy', auth: true, desc: 'Toggle economy mode', - bodyFromFlags: () => ({}) }, // Plans & subscriptions { sub: 'plans', method: 'GET', path: '/api/plans', auth: true, desc: 'List wallet plans' }, diff --git a/core/types.js b/core/types.js index 97e8364..b665a0b 100644 --- a/core/types.js +++ b/core/types.js @@ -78,7 +78,6 @@ * @property {string|null} errorMessage * @property {boolean} stopRequested * @property {boolean} lowBalanceWarning - * @property {boolean} economyMode * @property {string|null} pauseReason - Why audit is paused (VPN interference, etc.) */ diff --git a/index.html b/index.html index d4f70cd..7ff88ed 100644 --- a/index.html +++ b/index.html @@ -569,7 +569,6 @@

SENTINEL AUDIT

-
- + ›_ - CLI + CLI for dVPN Builders
@@ -930,6 +956,7 @@

SENTINEL NODE TEST

+
@@ -981,6 +1008,7 @@

SENTINEL NODE TEST

Type to search nodes… +
@@ -1110,6 +1138,14 @@

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); @@ -1272,6 +1308,24 @@

SENTINEL NODE TEST

} } + // ─── 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 @@ -1360,27 +1414,27 @@

SENTINEL NODE TEST

// TEST RUN detection — mirrors /live's logic so SLA-derived tiles can // dash out (those metrics are not measured during a skip-only demo). - const isDryRun = !!state.testRun || + const isTestRun = !!state.testRun || resultsArr.some(r => r && r.errorCode === 'TEST_RUN_SKIP'); const DASH = '—'; // Total Failed - document.getElementById('statTotalFailed').textContent = isDryRun ? DASH : fail; + document.getElementById('statTotalFailed').textContent = isTestRun ? DASH : fail; const failPct = processed > 0 ? (fail / processed * 100).toFixed(1) : 0; - document.getElementById('statTotalFailedPct').textContent = isDryRun + document.getElementById('statTotalFailedPct').textContent = isTestRun ? 'not measured in test run' : failPct + '% failure rate'; // 10 Mbps SLA + pass rate combined - document.getElementById('statPassed').textContent = isDryRun ? DASH : p10; + document.getElementById('statPassed').textContent = isTestRun ? DASH : p10; const passRate = done > 0 ? (p10 / done * 100).toFixed(1) : 0; - document.getElementById('statPassedPct').textContent = isDryRun + 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 = isDryRun ? DASH : 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; @@ -1409,9 +1463,9 @@

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 = isDryRun ? DASH : deadPlan; + document.getElementById('statDeadPlan').textContent = isTestRun ? DASH : deadPlan; const totalPlanTested = currentRunResults.filter(r => r.inPlan).length; - document.getElementById('statDeadPlanPct').textContent = isDryRun + document.getElementById('statDeadPlanPct').textContent = isTestRun ? 'not measured in test run' : (totalPlanTested > 0 ? `${deadPlan}/${totalPlanTested} plan nodes failed` @@ -1497,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)); @@ -1515,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) { @@ -1544,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 - ? `${cc.toUpperCase()}` + ? `${escHtml(cc.toUpperCase())}` : ''; - const countryCol = flag ? `${flag} ${cc.toUpperCase()}` : (r.country || '—'); - const cityCol = r.city || '—'; + const countryCol = flag ? `${flag} ${escHtml(cc.toUpperCase())}` : escHtml(r.country || '—'); + const cityCol = escHtml(r.city || '—'); const bottleneck = r.ispBottleneck ? ' ' : ''; const act = r.actualMbps != null ? r.actualMbps.toFixed(2) + ' Mbps' + bottleneck : '--'; const blAt = r.baselineAtTest != null ? r.baselineAtTest.toFixed(2) + ' Mbps' : '--'; @@ -1841,8 +1942,21 @@

SENTINEL NODE TEST

if (savedMode) setTestingMode(savedMode, { fromPick: true }); } catch {} - function isTestRunMode() { return _testingMode === 'testrun'; } - function isPlanMode() { return _testingMode === 'plan'; } + // 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'); @@ -1973,27 +2087,8 @@

SENTINEL NODE TEST

}); } - async function _startAuditAfterChoice(isDryRun) { - if (!isDryRun && !confirm('Start a NEW test? Current results will be saved and a fresh test begins.')) return; - // 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 = ''; + async function _startAuditAfterChoice(isTestRun) { + if (!isTestRun && !confirm('Start a NEW test? Current results will be saved and a fresh test begins.')) return; const btn = document.getElementById('btnStart'); btn.classList.add('loading'); @@ -2001,12 +2096,36 @@

SENTINEL NODE TEST

btn.disabled = true; document.getElementById('btnStop').disabled = false; try { - if (!await devStart(isDryRun)) { + // 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 = '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'); @@ -2104,9 +2223,7 @@

SENTINEL NODE TEST

} } - // Sentinel Audit CLI reference popup. Mirrors the bundled `bin/cli.js` - // (npm bin: `sentinel-audit`). Closeable via × button, Escape, or - // backdrop click — same UX as showInfoPopup(). + // CLI for dVPN Builders. Closeable via × button, Escape, or backdrop click. function showCliPopup() { const existing = document.getElementById('cliOverlay'); if (existing) { existing.remove(); return; } @@ -2117,95 +2234,255 @@

SENTINEL NODE TEST

overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; const popup = document.createElement('div'); - popup.style.cssText = 'background:var(--bg-card-solid);border:1px solid var(--border-hover);border-radius:16px;padding:28px 32px;max-width:760px;width:92%;max-height:86vh;overflow-y:auto;color:var(--text);font-family:var(--font-display);line-height:1.55;box-shadow:var(--shadow-lg)'; + 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:10px 12px;border-radius:6px;font-size:12px;border:1px solid var(--border);white-space:pre;overflow-x:auto;line-height:1.6;'; + 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 = ` -
+
-

Sentinel Audit CLI

-
Headless control of the same audit pipeline that powers this dashboard
+

CLI for dVPN Builders

+
Drop the same audit pipeline that powers this dashboard into your own dVPN tooling — headless, scriptable, AI-discoverable.
×
+
+
-
-
Install & Invoke
-
Bundled in this repo as bin/cli.js and exposed via the npm bin sentinel-audit.
-
# From a clone of the repo -npm install -node bin/cli.js list -# Or install globally and use the bin directly +
1 · Install
+
Three install paths — pick whichever matches your project layout.
+
A. Add to your Node project (recommended for dVPN apps)
+
# From npm (once published): +npm install sentinel-node-tester + +# Or pin a git tag directly: +npm install github:Sentinel-Autonomybuilder/sentinel-node-tester#main
+
B. Global install — use the sentinel-audit bin anywhere
+
git clone https://github.com/Sentinel-Autonomybuilder/sentinel-node-tester +cd sentinel-node-tester +npm install npm install -g . sentinel-audit --help
-
- -
-
Global Flags
-
--help, -h Show help for the command (or top-level) ---version, -v Print version ---json Force JSON output (default for most commands) ---pretty Human-readable output ---lcd <url> Override LCD endpoint ---sdk <js|tkd> SDK for chain queries (default: js)
-
- -
-
Discovery
-
list — Every command with description + flags. AI agents use this as a registry.
-
functions — Every exported SDK function grouped by module.
-
verify-sdks — Verify installed SDKs match their GitHub tag (byte-for-byte).
-
- -
-
Read (chain queries — no tokens spent)
-
nodes — List all active chain nodes.
-
node <addr> — Single node details.
-
balance [addr] — P2P token balance for a wallet (default: configured mnemonic).
-
subscriptions — List subscriptions for the configured wallet.
-
plans — List all on-chain plans.
-
- -
-
Action (spends real tokens / starts processes)
-
speed — Baseline internet speed test (no VPN).
-
test <addr> — End-to-end test one node. Pays a real session fee.
-
audit — Full audit loop across every node. Long-running.
-
serve — Start the browser dashboard server (this UI).
-
- -
-
Agent
-
agent — End-to-end driver hitting every server function over HTTP. Useful for AI / automation. Run sentinel-audit agent map --pretty for the registry.
-
+
C. No-install (one-off)
+
npx sentinel-node-tester list
+
Requires node >= 18. Windows, macOS and Linux are supported. Tunnel ops (audit/test) need WireGuard tooling on Linux/macOS, or the bundled wg.exe path on Windows — read-only commands (nodes, plans, balance) have no system requirements.
+ +
2 · Configure (.env)
+
Drop a .env in your project root (or set the variables in your shell). The CLI auto-loads it.
+
# Required for any command that signs a TX (test, audit, balance with no addr): +MNEMONIC="twelve word bip39 mnemonic here ..." + +# Optional — override the default chain endpoints (RPC-first, LCD fallback): +RPC_ENDPOINTS="https://rpc.sentinel.co:443,https://rpc.mathnodes.com:443" +LCD_ENDPOINT="https://lcd.sentinel.co" + +# Optional — talk to a remote dashboard server with the agent subcommand: +SENTINEL_AUDIT_URL="http://localhost:3001" +SENTINEL_AUDIT_TOKEN="your-admin-token" # also accepted as ADMIN_TOKEN + +# Optional — caps & filters used by audit: +MAX_NODES=200 +NODE_COUNTRY=US
+
Never commit .env. The mnemonic owns funds.
+ +
3 · Quickstart
+
# 1. List every command (machine-readable registry — point your agent here): +sentinel-audit list -
-
Examples
-
sentinel-audit list +# 2. Pull every active node from chain (no tokens spent): sentinel-audit nodes --pretty -sentinel-audit node sentnode1abc... --pretty -sentinel-audit balance --pretty -sentinel-audit test sentnode1abc... --pretty -sentinel-audit serve --port 3001 -# AI quick-start (machine-readable) -sentinel-audit list -sentinel-audit functions
-
- -
-
Notes
-
• Reads .env from the package root for MNEMONIC, RPC_ENDPOINTS, etc.
-
• Default output is JSON so agents can pipe it. Add --pretty for human reading.
-
• The serve command starts the same Express server this dashboard runs on (port 3001 by default).
+# 3. Inspect one node: +sentinel-audit node sentnode1xyz... --pretty + +# 4. Run a paid end-to-end test against one node: +sentinel-audit test sentnode1xyz... --pretty + +# 5. Boot the dashboard server (this UI) on port 3001: +sentinel-audit serve --port 3001
+ +
4 · Commands
+
Discovery (no chain calls, no tokens)
+
list — every command + flags as JSON. AI agents start here.
+
functions — every exported SDK function grouped by module.
+
verify-sdks — byte-for-byte hash check installed SDKs vs published GitHub tag.
+ +
Read (chain queries — no tokens spent)
+
nodes — all active chain nodes (paginated under the hood).
+
node <addr> — full details for a single sentnode1….
+
balance [addr] — udvpn balance. Defaults to the wallet derived from MNEMONIC.
+
subscriptions — on-chain subscriptions for the configured wallet.
+
plans — all on-chain subscription plans.
+ +
Action (spends real tokens / starts processes)
+
speed — baseline Cloudflare speed test (no VPN involved).
+
test <addr> — subscribe → handshake → speed-test → unsubscribe one node. Pays a real session fee.
+
audit — full audit loop across every chain node. Long-running. --resume to skip already-tested nodes.
+
serve — boots server.js on --port (default 3001). Identical to the running dashboard.
+ +
Agent (HTTP wrapper around the dashboard server)
+
agent <sub> — drives the running serve instance over HTTP. Run sentinel-audit agent map --pretty for the full endpoint registry. Subcommands include start, resume, pause, stop, test-plan, test-sub-plan, retest-fails, state, results.
+ +
5 · Global flags
+
--help, -h Show help (top-level or per-command) +--version, -v Print version +--json Force JSON output (default for most commands) +--pretty Pretty / human-readable output +--lcd <url> Override LCD endpoint +--sdk <js|tkd> SDK for chain queries (default: js) +--limit <N> Cap result count (nodes, plans, etc.) +--country <CC> Filter by ISO country code +--timeout <ms> Per-request timeout +--out <file> Write output to a file
+
Always use = form for value flags in scripts: --plan-id=42, not --plan-id 42. Some agent flags are parsed as boolean otherwise.
+ +
6 · Output contract
+
Every command emits one of two shapes on stdout. Errors go to stderr with a non-zero exit code. Pipe stdout into jq safely.
+
# Success +{ "ok": true, "data": <result>, "ms": 1234 } + +# Failure +{ "ok": false, "error": "ERR_CODE", "message": "human readable", "ms": 1234 } + +# Stream commands (audit, test) emit one JSON object per line on stdout +# (NDJSON), then a final summary object.
+ +
7 · Integrate into your dVPN application
+
Three integration patterns, ranked by coupling.
+ +
Pattern A — Shell out from Node / Electron / Tauri
+
import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +const run = promisify(execFile); + +async function listNodes() { + const { stdout } = await run('sentinel-audit', ['nodes', '--json']); + return JSON.parse(stdout).data; +} + +async function testNode(addr) { + const { stdout } = await run('sentinel-audit', + ['test', addr, '--json'], + { env: { ...process.env, MNEMONIC: process.env.WALLET_MNEMONIC } }); + return JSON.parse(stdout); +}
+ +
Pattern B — Stream NDJSON for long-running ops
+
import { spawn } from 'node:child_process'; +import readline from 'node:readline'; + +const proc = spawn('sentinel-audit', ['audit', '--json', '--max=50']); +const rl = readline.createInterface({ input: proc.stdout }); +rl.on('line', line => { + const evt = JSON.parse(line); + // evt = { type: 'node:tested', addr, mbps, peers, ... } + ui.update(evt); +}); +proc.on('close', code => ui.done(code));
+ +
Pattern C — Embed: import functions directly
+
// Tightest coupling. Skip the process boundary, call core functions: +import { + fetchActiveNodes, + testNode, + runAudit, + createState, +} from 'sentinel-node-tester'; + +const nodes = await fetchActiveNodes({ sdk: 'js' }); +const state = createState(); +state.activeSDK = 'js'; +await runAudit(false, state, (event, payload) => { + console.log(event, payload); // stream to your UI +});
+
For dVPN node-host apps and dashboards, Pattern C is the cleanest — you get the same engine without a child process or HTTP hop.
+ +
8 · Agent / HTTP control
+
If you've already booted sentinel-audit serve, the agent subcommand drives it over HTTP — useful when your dVPN UI lives in a different language (Rust, Go, Swift, C#).
+
SENTINEL_AUDIT_URL=http://localhost:3001 \\ +SENTINEL_AUDIT_TOKEN=$ADMIN_TOKEN \\ +sentinel-audit agent start --test-run --pricing-mode=gigabytes + +# Or hit the HTTP API directly from any language: +curl -X POST http://localhost:3001/api/start \\ + -H "Authorization: Bearer $ADMIN_TOKEN" \\ + -H "Content-Type: application/json" \\ + -d '{"testRun": true}' + +# Subscribe to live events over Server-Sent Events: +curl -N -H "Authorization: Bearer $ADMIN_TOKEN" \\ + http://localhost:3001/api/events
+
Run sentinel-audit agent map --pretty to dump the entire endpoint registry — methods, paths, auth, body shape — so your client (or AI agent) can codegen against it.
+ +
9 · Library-only use (no CLI process)
+
Importable surfaces from index.js:
+
// Chain queries (RPC-first, LCD fallback) +import { + fetchActiveNodes, fetchNode, fetchBalance, + fetchSubscriptions, fetchPlans, queryFeeGrant, +} from 'sentinel-node-tester'; + +// Pipeline / engine +import { + runAudit, runSubPlanTest, runRetestSkips, + testNode, createState, +} from 'sentinel-node-tester'; + +// Speed test +import { speedtestCloudflare } from 'sentinel-node-tester'; + +// Run-state DB helpers +import { insertRun, insertResult, insertErrorLog } from 'sentinel-node-tester/db';
+
Run sentinel-audit functions --pretty for the live, version-pinned export list.
+ +
10 · CI / Docker
+
GitHub Actions
+
- name: Audit Sentinel nodes nightly + run: | + npm install -g sentinel-node-tester + sentinel-audit nodes --json > nodes.json + env: + MNEMONIC: \${{ secrets.SENTINEL_MNEMONIC }}
+
Docker
+
FROM node:20-alpine +RUN apk add --no-cache wireguard-tools iproute2 +RUN npm install -g sentinel-node-tester +ENV MNEMONIC="" +ENTRYPOINT ["sentinel-audit"] +CMD ["serve", "--port", "3001"]
+ +
11 · Troubleshoot
+
No MNEMONIC — read commands work, but test / audit / balance (no addr) fail with ERR_NO_MNEMONIC. Set it in .env or the shell.
+
RPC timeouts — set RPC_ENDPOINTS with a comma-separated list. The CLI is RPC-first; LCD is fallback only.
+
EADDRINUSE on serve — port 3001 is already taken. Use --port=3010 or kill the existing PID (do NOT taskkill /F /IM node.exe).
+
WireGuard missing — install wireguard-tools on Linux/macOS. On Windows the bundled wg.exe path is auto-detected; run as Administrator for tunnel ops.
+
Agent flag silently boolean — write --plan-id=42, not --plan-id 42.
+ +
+ For dVPN builders: the same pipeline that runs this dashboard runs your audit. Whether you wrap the CLI, hit the HTTP API, or import the engine functions directly, you get the same RPC-first chain access, the same WireGuard handshake path, the same SQLite results store. No vendored services, no API keys, no accounts.
-
+
Press Escape or click outside to close
`; @@ -2402,26 +2679,17 @@

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'); @@ -2629,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(n.moniker || '—') + '' + + '' + escHtml(addrShort) + '' + + '' + escHtml(n.country || '—') + '' + + '' + 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; @@ -2687,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(''); @@ -2782,7 +3027,7 @@

String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); - const captured = er.captured_at ? new Date(typeof er.captured_at === 'number' ? er.captured_at : Number(er.captured_at) || er.captured_at).toLocaleString() : '—'; + const captured = _fmtCapturedUtc(er.captured_at); const row = (label, value) => `
${label}
@@ -2922,7 +3182,7 @@

⎘ Copy diff --git a/audit/continuous.js b/audit/continuous.js index fda5129..e9c71e7 100644 --- a/audit/continuous.js +++ b/audit/continuous.js @@ -323,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; @@ -349,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; @@ -666,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/node-test.js b/audit/node-test.js index 55b395b..bef7f55 100644 --- a/audit/node-test.js +++ b/audit/node-test.js @@ -64,30 +64,6 @@ function logFailure(nodeAddr, error, context = {}) { appendFileSync(FAILURE_LOG, JSON.stringify(entry) + '\n', 'utf8'); } -// Sleep but break out within ~250ms when state.stopRequested flips. -async function stopAwareSleep(ms, state) { - const tick = 250; - const deadline = Date.now() + ms; - while (Date.now() < deadline) { - if (state?.stopRequested) throw new Error('Stop requested'); - await sleep(Math.min(tick, deadline - Date.now())); - } - if (state?.stopRequested) throw new Error('Stop requested'); -} - -// Race a promise against state.stopRequested polling so long awaits abort fast. -async function withStopGuard(promise, state) { - let cancelled = false; - const stopPoll = new Promise((_, reject) => { - const iv = setInterval(() => { - if (cancelled) { clearInterval(iv); return; } - if (state?.stopRequested) { clearInterval(iv); reject(new Error('Stop requested')); } - }, 250); - }); - try { return await Promise.race([promise, stopPoll]); } - finally { cancelled = true; } -} - /** * Test a single node. Returns a TestResult or null if fundamentally untestable. * With the zero-skip system, null is only returned for truly untestable cases @@ -188,27 +164,13 @@ export async function testNode(client, account, privkey, node, opts, preSessionI } // ─── Price check ────────────────────────────────────────────────────────── - // Pricing mode: 'gigabytes' (default) or 'hours'. - // In subscription-plan flows the pipeline supplies its own messages — this - // toggle only affects the P2P MsgStartSession path below. - const pricingMode = state.pricingMode === 'hours' ? 'hours' : 'gigabytes'; - const gigabytePrice = (node.gigabyte_prices || []).find(p => p.denom === denom); - const hourlyPrice = (node.hourly_prices || []).find(p => p.denom === denom); - - if (pricingMode === 'hours' && !hourlyPrice) { - throw new Error('No hourly udvpn pricing available (node has no hourly_prices entry)'); - } - if (pricingMode === 'gigabytes' && !gigabytePrice) { + const priceEntry = (node.gigabyte_prices || []).find(p => p.denom === denom); + if (!priceEntry) { throw new Error('No udvpn pricing available'); } - const priceEntry = pricingMode === 'hours' ? hourlyPrice : gigabytePrice; - const sessionGigabytes = pricingMode === 'hours' ? 0 : gigabytes; - const sessionHours = pricingMode === 'hours' ? 1 : 0; - const priceUnits = pricingMode === 'hours' ? sessionHours : sessionGigabytes; - const nodePriceUdvpn = Math.round(parseFloat(priceEntry.quote_value) || 0); - const thisCostUdvpn = nodePriceUdvpn * priceUnits; + const thisCostUdvpn = nodePriceUdvpn * gigabytes; if (state.testRun) { if (broadcast) broadcast('log', { msg: ' 🧪 TEST RUN — skipping payment + handshake + speedtest.' }); @@ -279,8 +241,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI if (broadcast) broadcast('log', { msg: `⚠ LOW BALANCE: ${(remainingBalance / 1_000_000).toFixed(4)} P2P` }); } - const unitLabel = pricingMode === 'hours' ? `${sessionHours}h` : `${sessionGigabytes}GB`; - const costLabel = preSessionId ? 'pre-paid' : sessionId ? '0 (reuse)' : `${thisCostUdvpn} udvpn (${unitLabel})`; + const costLabel = preSessionId ? 'pre-paid' : sessionId ? '0 (reuse)' : thisCostUdvpn + ' udvpn'; if (broadcast) broadcast('log', { msg: `→ ${typeName} | ${status.location.city}, ${status.location.country} | ${reportedDownloadMbps.toFixed(1)} Mbps | Cost: ${costLabel}`, }); @@ -296,7 +257,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes: sessionGigabytes, hours: sessionHours, + gigabytes, hours: 0, max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value }, }, }], fee, broadcast); @@ -319,7 +280,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes: sessionGigabytes, hours: sessionHours, + gigabytes, hours: 0, max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value }, }, }], fee, broadcast); @@ -334,7 +295,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes: sessionGigabytes, hours: sessionHours, + gigabytes, hours: 0, max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value }, }, }], fee, broadcast); @@ -354,7 +315,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes: sessionGigabytes, hours: sessionHours, + gigabytes, hours: 0, }, }], fee, broadcast); assertIsDeliverTxSuccess(txResult); @@ -377,11 +338,11 @@ export async function testNode(client, account, privkey, node, opts, preSessionI markPaid(node.address); if (broadcast) broadcast('log', { msg: ` Session ${sessionId} — polling for chain confirmation…` }); - await withStopGuard(waitForSessionActive(node.address, account.address, 20_000, sessionId), state); + await waitForSessionActive(node.address, account.address, 20_000, sessionId); // Extra delay: node needs time to index the session into its own DB // Without this, handshake races with node indexing → 409 "already exists" if (broadcast) broadcast('log', { msg: ` Waiting 5s for node to index session…` }); - await stopAwareSleep(5_000, state); + await sleep(5_000); } // ─── Handshake + Connect ────────────────────────────────────────────────── @@ -399,7 +360,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes: sessionGigabytes, hours: sessionHours, + gigabytes, hours: 0, max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value }, }, }], fee, broadcast); @@ -409,7 +370,7 @@ export async function testNode(client, account, privkey, node, opts, preSessionI if (broadcast) broadcast('log', { msg: ` ⚠ "invalid price" — retrying fresh session without max_price...` }); txResult = await signAndBroadcastRetry(client, account.address, [{ typeUrl: V3_MSG_TYPE, - value: { from: account.address, node_address: node.address, gigabytes: sessionGigabytes, hours: sessionHours }, + value: { from: account.address, node_address: node.address, gigabytes, hours: 0 }, }], fee, broadcast); assertIsDeliverTxSuccess(txResult); } else { @@ -427,8 +388,8 @@ export async function testNode(client, account, privkey, node, opts, preSessionI state.estimatedTotalCost = `${(state.spentUdvpn / 1_000_000).toFixed(4)} P2P`; if (broadcast) broadcast('state', { state }); if (broadcast) broadcast('log', { msg: ` Fresh session ${sessionId} — waiting for chain + node indexing...` }); - await withStopGuard(waitForSessionActive(node.address, account.address, 20_000, sessionId), state); - await stopAwareSleep(5_000, state); + await waitForSessionActive(node.address, account.address, 20_000, sessionId); + await sleep(5_000); return sessionId; } diff --git a/audit/pipeline.js b/audit/pipeline.js index 5b8ec26..eedde10 100644 --- a/audit/pipeline.js +++ b/audit/pipeline.js @@ -14,7 +14,7 @@ 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, @@ -26,15 +26,14 @@ import { speedtestDirect, sleep as _rawSleep, resolveCfHost } from '../protocol/ // Every sleep in the pipeline races against a module-scope stop signal. // When `triggerPipelineStop()` is called (from /api/stop), every pending // sleep resolves instantly so the loop drops back to its `if (state.stopRequested) break` -// check on the next tick — cutting Stop latency from "until next timer fires" -// (up to 5 minutes for sleep(5*60_000)) to single-digit ms. +// 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(resolve); resolve(); }, ms); - const wake = () => { try { clearTimeout(timer); } catch {} resolve(); }; + let timer = setTimeout(() => { _stopWaiters.delete(wake); resolve(); }, ms); + const wake = () => { try { clearTimeout(timer); } catch {} _stopWaiters.delete(wake); resolve(); }; _stopWaiters.add(wake); }); } @@ -46,22 +45,7 @@ export function triggerPipelineStop() { export function resetPipelineStop() { _pipelineStopFlag = false; } -// Race any awaitable against the stop signal. The promise rejects with a -// `_stopRequested` marker the per-node loop already catches and breaks on. -function raceStop(promise) { - return new Promise((resolve, reject) => { - if (_pipelineStopFlag) { - const e = new Error('Stop requested'); e._stopRequested = true; return reject(e); - } - let done = false; - const wake = () => { if (done) return; done = true; _stopWaiters.delete(wake); const e = new Error('Stop requested'); e._stopRequested = true; reject(e); }; - _stopWaiters.add(wake); - promise.then( - (v) => { if (done) return; done = true; _stopWaiters.delete(wake); resolve(v); }, - (e) => { if (done) return; done = true; _stopWaiters.delete(wake); reject(e); }, - ); - }); -} + // Platform-aware imports — Windows has full implementation, others get stubs let WG_AVAILABLE, IS_ADMIN, emergencyCleanupSync, uninstallWgTunnel, checkV2Ray; if (process.platform === 'win32') { @@ -80,9 +64,7 @@ import { checkAndPauseIfInterference, classifyFailure } from '../protocol/diagno import { loadTransportCache, getCacheStats, saveTransportCache } from '../core/transport-cache.js'; import { testNode } from './node-test.js'; import { testWithRetry } from './retry.js'; -import { insertResult as _dbInsertResult, insertErrorLog as _dbInsertErrorLog, - insertBatch as _dbInsertBatch, insertBatchResult as _dbInsertBatchResult, - updateBatchOnFinish as _dbUpdateBatchOnFinish } from '../core/db.js'; +import { insertResult as _dbInsertResult, insertErrorLog as _dbInsertErrorLog } from '../core/db.js'; // ─── Internet Health Check & Auto-Resume ───────────────────────────────────── const INTERNET_CHECK_TARGETS = ['https://www.google.com', 'https://1.1.1.1', 'https://www.cloudflare.com']; @@ -280,13 +262,6 @@ export function createState() { stopRequested: false, lowBalanceWarning: false, pauseReason: null, - testRun: false, - continuousLoop: false, - pricingMode: 'gigabytes', - runMode: null, - runPlanId: null, - runSubscriptionId: null, - runGranter: null, }; } @@ -384,7 +359,6 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null, state.retestFailed = null; state.testRun = !!opts.testRun; - state.pricingMode = (opts.pricingMode === 'hours') ? 'hours' : 'gigabytes'; clearPoisonedSessions(); clearPaidNodes(); @@ -520,27 +494,6 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null, } state.totalNodes = (resume ? results.length : 0) + viableNodes.length; - - // ── Batch row: drives /api/public/runs/current → /live livestream ────── - // Without this, getActiveBatch() never sees the in-flight run and /live - // keeps showing the previous (finished) batch. Created on every fresh run - // (not on resume — the previous batch row stays open until completion). - let _currentBatchId = null; - if (!resume) { - try { - const snapshotAddrs = viableNodes.map(({ node }) => node.address).filter(Boolean); - _currentBatchId = _dbInsertBatch({ - started_at: Date.now(), - snapshot_size: viableNodes.length, - mode: state.testRun ? 'test' : 'p2p', - snapshot_addresses: snapshotAddrs, - }); - state.activeBatchId = _currentBatchId; - } catch (batchErr) { - broadcast('log', { msg: `[batch] insert failed: ${batchErr.message}` }); - } - } - const estCostUdvpn = viableNodes.reduce( (sum, { node }) => sum + parseNodePriceUdvpn(node.gigabyte_prices) * GIGS, 0 ); @@ -691,7 +644,6 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null, if (result.pass10mbps) state.passed10++; if (result.passBaseline) state.passedBaseline++; upsertResult(result); // success — no snippet needed - if (_currentBatchId) { try { _dbInsertBatchResult(_currentBatchId, { ...result, testedAt: Date.now() }); } catch {} } saveResults(); broadcast('result', { result, state }); if (result.actualMbps != null) { @@ -755,7 +707,6 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null, const failResult = buildFailResult(node, status, state, errMsg, error?.diag || {}); state.failedNodes++; upsertResult(failResult, _logSnippet); - if (_currentBatchId) { try { _dbInsertBatchResult(_currentBatchId, { ...failResult, testedAt: Date.now() }); } catch {} } saveResults(); broadcast('result', { result: failResult, state }); const retryLabel = retried > 0 ? ` (${retried} retries)` : ''; @@ -928,18 +879,6 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null, state.currentNode = null; broadcast('state', { state }); const finalFailed = results.filter(r => r.actualMbps == null && r.error).length; - if (_currentBatchId) { - try { - _dbUpdateBatchOnFinish(_currentBatchId, { - finished_at: Date.now(), - passed: state.testedNodes, - failed: finalFailed, - }); - } catch (finishErr) { - broadcast('log', { msg: `[batch] finalize failed: ${finishErr.message}` }); - } - state.activeBatchId = null; - } broadcast('log', { msg: `✅ Audit complete. Tested ${state.testedNodes}, Failed ${finalFailed}. ${state.retryCount} retries total.` }); broadcast('log', { msg: `🧠 Transport cache: ${finalCache.nodesCached} nodes learned for next scan.` }); } @@ -1442,17 +1381,13 @@ export async function runPlanTest(planId, state, broadcast) { * This mirrors how Android/iOS consumer apps ship — the end user never holds * P2P, the plan operator covers all on-chain fees via a pre-granted feegrant. */ -export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, broadcast, opts = {}) { - resetPipelineStop(); - const resume = !!opts.resume; +export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, broadcast) { state.status = 'running'; state.startedAt = new Date().toISOString(); state.errorMessage = null; - if (!resume) { - state.totalNodes = 0; - state.testedNodes = 0; - state.failedNodes = 0; - } + state.totalNodes = 0; + state.testedNodes = 0; + state.failedNodes = 0; state.retryCount = 0; recomputeCounters(state); clearPoisonedSessions(); @@ -1498,14 +1433,7 @@ export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, // Surface allowance details in log for operator debugging const allowanceType = allowance['@type'] || allowance.type_url || null; const spendLimit = allowance.spend_limit || allowance.allowance?.spend_limit || null; - const typeShort = allowanceType ? allowanceType.split('.').pop().replace(/^\//, '') : 'unknown'; - let limitLabel = 'unlimited'; - if (Array.isArray(spendLimit) && spendLimit.length) { - limitLabel = spendLimit - .map(c => `${(parseInt(c.amount || '0', 10) / 1_000_000).toFixed(2)} ${(c.denom || '').replace(/^u/, '').toUpperCase() || 'DVPN'}`) - .join(', '); - } - broadcast('log', { msg: ` ✓ Fee grant verified — ${typeShort}, limit ${limitLabel}` }); + broadcast('log', { msg: ` ✓ Fee grant verified — type=${allowanceType || 'unknown'} limit=${JSON.stringify(spendLimit) || 'none'}` }); } catch (err) { // If the query itself throws (network error), abort rather than silently proceeding — // we cannot know whether the grant exists, and every session TX would fail. @@ -1586,17 +1514,9 @@ export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, } broadcast('log', { msg: ` Scanning plan nodes for online status...` }); - let onlineNodes = await scanNodesParallel(planNodes, 20, broadcast, state); + const onlineNodes = await scanNodesParallel(planNodes, 20, broadcast, state); broadcast('log', { msg: ` ${onlineNodes.length}/${planNodes.length} plan nodes are online` }); - // Resume mode: filter already-tested addresses - if (resume) { - const testedAddrs = new Set(results.map(r => r.address)); - const before = onlineNodes.length; - onlineNodes = onlineNodes.filter(({ node }) => !testedAddrs.has(node.address)); - broadcast('log', { msg: `Resume: skipping ${before - onlineNodes.length} already-tested, ${onlineNodes.length} remaining.` }); - } - if (onlineNodes.length === 0) { state.status = 'done'; state.completedAt = new Date().toISOString(); @@ -1604,7 +1524,7 @@ export async function runSubPlanTest(planId, subscriptionId, granterAddr, state, return; } - state.totalNodes = (resume ? results.length : 0) + onlineNodes.length; + state.totalNodes = onlineNodes.length; broadcast('state', { state }); broadcast('log', { msg: `📡 Running baseline...` }); diff --git a/bin/commands/agent.js b/bin/commands/agent.js index 4fb38a1..0d38125 100644 --- a/bin/commands/agent.js +++ b/bin/commands/agent.js @@ -42,6 +42,7 @@ export const flags = [ { 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)' }, @@ -61,6 +62,7 @@ const ENDPOINTS = [ { 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)' }, @@ -81,6 +83,9 @@ const ENDPOINTS = [ { 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' }, @@ -117,7 +122,11 @@ const ENDPOINTS = [ // 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' }, + { 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' }, @@ -152,11 +161,23 @@ function findEndpoint(sub) { return ENDPOINTS.find(e => e.sub === sub); } -function fillPath(rawPath, params, positional) { +// 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++) { - const tok = positional[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}`); } @@ -285,12 +306,13 @@ export async function run({ positional, flags: f }) { } const tail = positional.slice(1); - const path = fillPath(ep.path, ep.params, tail); + 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') { 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/core/chain.js b/core/chain.js index 29d2b1b..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) ──────────────────────────────── /** 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 31bd872..c4c8c48 100644 --- a/core/db.js +++ b/core/db.js @@ -1013,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 616c16c..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; } } @@ -468,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 b665a0b..e0a1a43 100644 --- a/core/types.js +++ b/core/types.js @@ -79,6 +79,15 @@ * @property {boolean} stopRequested * @property {boolean} lowBalanceWarning * @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 - - - - - - - - -
-
-
Countries
-
-
loading
-
-
-
Google Open
-
-
-
-
-
Google Blocked
-
-
-
-
-
Mixed / Unknown
-
-
-
-
-
Total Nodes
-
-
-
-
- -
-
-
Countries by Google Accessibility
-
-
- -
-
-
- Loading audit data -
-
-
- -
Copied
- - - - - 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 7ff88ed..bacfd61 100644 --- a/index.html +++ b/index.html @@ -535,7 +535,6 @@

SENTINEL AUDIT

- Dictator Mode

-
-
-
-

Dictator Mode

-
Sentinel Network · Google Accessibility Audit
-
- - - WINDOWS - - - JS SDK - -
-
-

diff --git a/live.html b/live.html index bbb04f2..fa7c238 100644 --- a/live.html +++ b/live.html @@ -302,7 +302,23 @@ .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; @@ -496,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); @@ -742,6 +758,11 @@

+

@@ -822,6 +843,10 @@

// 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 @@ -950,7 +975,8 @@

// ─── State ─── let resultsArr = []; let activeFilter = 'all'; - const MAX_ROWS = 500; + const LIVE_PAGE_SIZE = 50; + let _livePage = 1; const MAX_LOG = 400; // ─── Helpers ─── @@ -959,6 +985,19 @@

.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; @@ -1004,6 +1043,7 @@

// ─── 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) { @@ -1031,30 +1071,66 @@

// ─── 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) { @@ -1116,13 +1192,13 @@

tr.innerHTML = ` ${proto} ${transportHtml} - ${escHtml(monikerText)} + ${escHtml(monikerText)} ${addr} ${countryCol} ${cityCol} - ${peersHtml} - ${act} - ${blAt} + ${peersHtml} + ${act} + ${blAt} ${badge} ${copyCell} `; @@ -1396,14 +1472,14 @@

// Detect TEST RUN: state.testRun flag (immediate), any row marked // TEST_RUN_SKIP, OR no real-processed rows yet but at least one skipped. - const isDryRun = !!(_liveState && _liveState.testRun) || + 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 = isDryRun ? '' : 'none'; + if (testRunBadge) testRunBadge.style.display = isTestRun ? '' : 'none'; const testRunBanner = document.getElementById('testRunBanner'); - if (testRunBanner) testRunBanner.classList.toggle('is-visible', isDryRun); + if (testRunBanner) testRunBanner.classList.toggle('is-visible', isTestRun); // Tested — always shows real progress, even during a TEST RUN. if (snap > 0) { @@ -1422,7 +1498,7 @@

const DASH = '—'; // Total Failed - if (isDryRun) { + if (isTestRun) { set('lsTotalFailed', DASH); set('lsTotalFailedPct', 'not tested'); } else { @@ -1432,7 +1508,7 @@

} // Pass 10 Mbps SLA - if (isDryRun) { + if (isTestRun) { set('lsPassedSLA', DASH); set('lsPassedSLAPct', 'not tested'); } else { @@ -1442,7 +1518,7 @@

} // Dead Plan Nodes - if (isDryRun) { + if (isTestRun) { set('lsDeadPlan', DASH); set('lsDeadPlanPct', 'not tested'); } else { @@ -1463,7 +1539,7 @@

set('lsNotOnlineSub', trulyOffline === 1 ? '1 node offline' : `${trulyOffline} nodes offline`); // Pass Rate - if (isDryRun) { + if (isTestRun) { set('lsPassRate', DASH); } else { const connRate = realProcessed > 0 ? (connected / realProcessed * 100).toFixed(1) : '0'; @@ -1967,6 +2043,7 @@

} } + let _sseEverConnected = false; function connectSSE() { if (!_broadcastLive) return; if (_sse) { try { _sse.close(); } catch (_) {} } @@ -1978,7 +2055,15 @@

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; @@ -2112,7 +2197,7 @@

'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); } @@ -2173,7 +2258,7 @@

Node Erro } const er = errs[0]; const eh = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); - const captured = er.captured_at ? new Date(typeof er.captured_at === 'number' ? er.captured_at : Number(er.captured_at) || er.captured_at).toLocaleString() : '—'; + const captured = er.captured_at ? fmtUtc(er.captured_at) : '—'; const row = (label, value) => `
${label}
diff --git a/package-lock.json b/package-lock.json index 8c0ea2b..625d863 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,11 @@ "@sentinel-official/sentinel-js-sdk": "^2.0.4", "axios": "^1.6.8", "better-sqlite3": "^12.9.0", + "blue-js-sdk": "^2.6.0", "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", - "express": "^4.18.2", + "express": "^4.21.0", "long": "^5.2.3", - "sentinel-dvpn-sdk": "^1.5.1", "socks-proxy-agent": "^8.0.4" }, "bin": { @@ -669,6 +669,116 @@ "readable-stream": "^3.4.0" } }, + "node_modules/blue-js-sdk": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/blue-js-sdk/-/blue-js-sdk-2.6.0.tgz", + "integrity": "sha512-d8eKcmtaDsWEsg9qgjNuQW18f4+lLf8yEFJoi/lrj72wLDLHQDfm1t7h8ecl8T4H77D3hpTmBgZuZsU3XefJaQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cosmjs/amino": "0.32.2", + "@cosmjs/crypto": "0.32.2", + "@cosmjs/encoding": "0.32.2", + "@cosmjs/proto-signing": "0.32.2", + "@cosmjs/stargate": "0.32.2", + "@noble/curves": "^1.4.0", + "axios": "^1.7.0", + "dotenv": "^16.4.0", + "socks-proxy-agent": "^8.0.0" + }, + "bin": { + "sentinel": "cli/index.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/blue-js-sdk/node_modules/@cosmjs/amino": { + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/@cosmjs/amino/-/amino-0.32.2.tgz", + "integrity": "sha512-lcK5RCVm4OfdAooxKcF2+NwaDVVpghOq6o/A40c2mHXDUzUoRZ33VAHjVJ9Me6vOFxshrw/XEFn1f4KObntjYA==", + "license": "Apache-2.0", + "dependencies": { + "@cosmjs/crypto": "^0.32.2", + "@cosmjs/encoding": "^0.32.2", + "@cosmjs/math": "^0.32.2", + "@cosmjs/utils": "^0.32.2" + } + }, + "node_modules/blue-js-sdk/node_modules/@cosmjs/crypto": { + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/@cosmjs/crypto/-/crypto-0.32.2.tgz", + "integrity": "sha512-RuxrYKzhrPF9g6NmU7VEq++Hn1vZJjqqJpZ9Tmw9lOYOV8BUsv+j/0BE86kmWi7xVJ7EwxiuxYsKuM8IR18CIA==", + "deprecated": "This uses elliptic for cryptographic operations, which contains several security-relevant bugs. To what degree this affects your application is something you need to carefully investigate. See https://github.com/cosmos/cosmjs/issues/1708 for further pointers. Starting with version 0.34.0 the cryptographic library has been replaced. However, private keys might still be at risk.", + "license": "Apache-2.0", + "dependencies": { + "@cosmjs/encoding": "^0.32.2", + "@cosmjs/math": "^0.32.2", + "@cosmjs/utils": "^0.32.2", + "@noble/hashes": "^1", + "bn.js": "^5.2.0", + "elliptic": "^6.5.4", + "libsodium-wrappers-sumo": "^0.7.11" + } + }, + "node_modules/blue-js-sdk/node_modules/@cosmjs/encoding": { + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/@cosmjs/encoding/-/encoding-0.32.2.tgz", + "integrity": "sha512-WX7m1wLpA9V/zH0zRcz4EmgZdAv1F44g4dbXOgNj1eXZw1PIGR12p58OEkLN51Ha3S4DKRtCv5CkhK1KHEvQtg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "bech32": "^1.1.4", + "readonly-date": "^1.0.0" + } + }, + "node_modules/blue-js-sdk/node_modules/@cosmjs/proto-signing": { + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/@cosmjs/proto-signing/-/proto-signing-0.32.2.tgz", + "integrity": "sha512-UV4WwkE3W3G3s7wwU9rizNcUEz2g0W8jQZS5J6/3fiN0mRPwtPKQ6EinPN9ASqcAJ7/VQH4/9EPOw7d6XQGnqw==", + "license": "Apache-2.0", + "dependencies": { + "@cosmjs/amino": "^0.32.2", + "@cosmjs/crypto": "^0.32.2", + "@cosmjs/encoding": "^0.32.2", + "@cosmjs/math": "^0.32.2", + "@cosmjs/utils": "^0.32.2", + "cosmjs-types": "^0.9.0" + } + }, + "node_modules/blue-js-sdk/node_modules/@cosmjs/stargate": { + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/@cosmjs/stargate/-/stargate-0.32.2.tgz", + "integrity": "sha512-AsJa29fT7Jd4xt9Ai+HMqhyj7UQu7fyYKdXj/8+/9PD74xe6lZSYhQPcitUmMLJ1ckKPgXSk5Dd2LbsQT0IhZg==", + "license": "Apache-2.0", + "dependencies": { + "@confio/ics23": "^0.6.8", + "@cosmjs/amino": "^0.32.2", + "@cosmjs/encoding": "^0.32.2", + "@cosmjs/math": "^0.32.2", + "@cosmjs/proto-signing": "^0.32.2", + "@cosmjs/stream": "^0.32.2", + "@cosmjs/tendermint-rpc": "^0.32.2", + "@cosmjs/utils": "^0.32.2", + "cosmjs-types": "^0.9.0", + "xstream": "^11.14.0" + } + }, + "node_modules/blue-js-sdk/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/bn.js": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", @@ -2150,117 +2260,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/sentinel-dvpn-sdk": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/sentinel-dvpn-sdk/-/sentinel-dvpn-sdk-1.5.1.tgz", - "integrity": "sha512-+SFiqm0m+jql4380TvFkH/S820/bV6iK+zwkfMamQCsjpc5up6/eXHVTC+y3qsPNOK3aroZDHOL8Dnv7n/wnBQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@cosmjs/amino": "0.32.2", - "@cosmjs/crypto": "0.32.2", - "@cosmjs/encoding": "0.32.2", - "@cosmjs/proto-signing": "0.32.2", - "@cosmjs/stargate": "0.32.2", - "@noble/curves": "^1.4.0", - "axios": "^1.7.0", - "dotenv": "^16.4.0", - "socks-proxy-agent": "^8.0.0" - }, - "bin": { - "sentinel": "cli/index.js", - "sentinel-ai": "ai-path/cli.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/sentinel-dvpn-sdk/node_modules/@cosmjs/amino": { - "version": "0.32.2", - "resolved": "https://registry.npmjs.org/@cosmjs/amino/-/amino-0.32.2.tgz", - "integrity": "sha512-lcK5RCVm4OfdAooxKcF2+NwaDVVpghOq6o/A40c2mHXDUzUoRZ33VAHjVJ9Me6vOFxshrw/XEFn1f4KObntjYA==", - "license": "Apache-2.0", - "dependencies": { - "@cosmjs/crypto": "^0.32.2", - "@cosmjs/encoding": "^0.32.2", - "@cosmjs/math": "^0.32.2", - "@cosmjs/utils": "^0.32.2" - } - }, - "node_modules/sentinel-dvpn-sdk/node_modules/@cosmjs/crypto": { - "version": "0.32.2", - "resolved": "https://registry.npmjs.org/@cosmjs/crypto/-/crypto-0.32.2.tgz", - "integrity": "sha512-RuxrYKzhrPF9g6NmU7VEq++Hn1vZJjqqJpZ9Tmw9lOYOV8BUsv+j/0BE86kmWi7xVJ7EwxiuxYsKuM8IR18CIA==", - "deprecated": "This uses elliptic for cryptographic operations, which contains several security-relevant bugs. To what degree this affects your application is something you need to carefully investigate. See https://github.com/cosmos/cosmjs/issues/1708 for further pointers. Starting with version 0.34.0 the cryptographic library has been replaced. However, private keys might still be at risk.", - "license": "Apache-2.0", - "dependencies": { - "@cosmjs/encoding": "^0.32.2", - "@cosmjs/math": "^0.32.2", - "@cosmjs/utils": "^0.32.2", - "@noble/hashes": "^1", - "bn.js": "^5.2.0", - "elliptic": "^6.5.4", - "libsodium-wrappers-sumo": "^0.7.11" - } - }, - "node_modules/sentinel-dvpn-sdk/node_modules/@cosmjs/encoding": { - "version": "0.32.2", - "resolved": "https://registry.npmjs.org/@cosmjs/encoding/-/encoding-0.32.2.tgz", - "integrity": "sha512-WX7m1wLpA9V/zH0zRcz4EmgZdAv1F44g4dbXOgNj1eXZw1PIGR12p58OEkLN51Ha3S4DKRtCv5CkhK1KHEvQtg==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "bech32": "^1.1.4", - "readonly-date": "^1.0.0" - } - }, - "node_modules/sentinel-dvpn-sdk/node_modules/@cosmjs/proto-signing": { - "version": "0.32.2", - "resolved": "https://registry.npmjs.org/@cosmjs/proto-signing/-/proto-signing-0.32.2.tgz", - "integrity": "sha512-UV4WwkE3W3G3s7wwU9rizNcUEz2g0W8jQZS5J6/3fiN0mRPwtPKQ6EinPN9ASqcAJ7/VQH4/9EPOw7d6XQGnqw==", - "license": "Apache-2.0", - "dependencies": { - "@cosmjs/amino": "^0.32.2", - "@cosmjs/crypto": "^0.32.2", - "@cosmjs/encoding": "^0.32.2", - "@cosmjs/math": "^0.32.2", - "@cosmjs/utils": "^0.32.2", - "cosmjs-types": "^0.9.0" - } - }, - "node_modules/sentinel-dvpn-sdk/node_modules/@cosmjs/stargate": { - "version": "0.32.2", - "resolved": "https://registry.npmjs.org/@cosmjs/stargate/-/stargate-0.32.2.tgz", - "integrity": "sha512-AsJa29fT7Jd4xt9Ai+HMqhyj7UQu7fyYKdXj/8+/9PD74xe6lZSYhQPcitUmMLJ1ckKPgXSk5Dd2LbsQT0IhZg==", - "license": "Apache-2.0", - "dependencies": { - "@confio/ics23": "^0.6.8", - "@cosmjs/amino": "^0.32.2", - "@cosmjs/encoding": "^0.32.2", - "@cosmjs/math": "^0.32.2", - "@cosmjs/proto-signing": "^0.32.2", - "@cosmjs/stream": "^0.32.2", - "@cosmjs/tendermint-rpc": "^0.32.2", - "@cosmjs/utils": "^0.32.2", - "cosmjs-types": "^0.9.0", - "xstream": "^11.14.0" - } - }, - "node_modules/sentinel-dvpn-sdk/node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", diff --git a/package.json b/package.json index 39f28fa..b33f181 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sentinel-node-tester", - "version": "1.3.6", + "version": "1.4.0", "description": "Network audit dashboard for Sentinel dVPN — test every node on the blockchain for real VPN throughput across multiple SDKs (Blue JS, Blue C#, TKD Official)", "type": "module", "main": "index.js", @@ -73,8 +73,8 @@ "easy.js", "admin.html", "public.html", + "live.html", "node.html", - "dictator.html", "core/", "audit/", "protocol/", @@ -97,11 +97,11 @@ "@sentinel-official/sentinel-js-sdk": "^2.0.4", "axios": "^1.6.8", "better-sqlite3": "^12.9.0", + "blue-js-sdk": "^2.6.0", "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", "express": "^4.21.0", "long": "^5.2.3", - "sentinel-dvpn-sdk": "^1.5.1", "socks-proxy-agent": "^8.0.4" } } diff --git a/protocol/speedtest.js b/protocol/speedtest.js index 0870b8f..f243e10 100644 --- a/protocol/speedtest.js +++ b/protocol/speedtest.js @@ -21,7 +21,7 @@ import { flushSpeedTestDnsCache, compareSpeedTests, SPEEDTEST_DEFAULTS, -} from 'sentinel-dvpn-sdk'; +} from 'blue-js-sdk'; // Re-export SDK functions export { speedtestDirect, speedtestViaSocks5, resolveSpeedtestIPs, flushSpeedTestDnsCache, compareSpeedTests, SPEEDTEST_DEFAULTS }; diff --git a/protocol/v3protocol.js b/protocol/v3protocol.js index b67fa04..f8f9c2e 100644 --- a/protocol/v3protocol.js +++ b/protocol/v3protocol.js @@ -34,4 +34,4 @@ export { buildMsgEndSession, buildMsgStartSubscription, buildMsgSubStartSession, -} from 'sentinel-dvpn-sdk'; +} from 'blue-js-sdk'; diff --git a/public.html b/public.html index a610542..d203e5c 100644 --- a/public.html +++ b/public.html @@ -1471,7 +1471,7 @@

View on Desktop

- @@ -1567,7 +1567,7 @@

View on Desktop

'use strict'; // ─── Constants ─── - const PAGE_SIZE = 10000; + const PAGE_SIZE = 50; const DEBOUNCE_MS = 250; const REFRESH_MS = 30000; @@ -1983,8 +1983,13 @@

View on Desktop

renderPage(); populateCountries(allNodes); updateFilterChips(); - const _latest = (_batchList && _batchList[0] && _batchList[0].started_at) || Date.now(); - document.getElementById('lastUpdatedLabel').textContent = 'Snapshot ' + fmtUtc(_latest); + // Don't fabricate a timestamp from Date.now() when no batch has been + // recorded yet — render an em-dash instead so visitors aren't misled + // into thinking the empty result set was "just snapshotted". + const _latest = (_batchList && _batchList[0] && _batchList[0].started_at) || null; + document.getElementById('lastUpdatedLabel').textContent = _latest + ? 'Snapshot ' + fmtUtc(_latest) + : 'Snapshot —'; } catch (err) { setTableMessage(`Failed to load: ${err.message}`, true); } @@ -2054,8 +2059,14 @@

View on Desktop

return; } - const page = filteredNodes.slice(currentOffset, currentOffset + PAGE_SIZE); + // Clamp currentOffset so SSE-driven re-renders don't leave a stale empty page + if (currentOffset >= total) currentOffset = Math.max(0, (Math.ceil(total / PAGE_SIZE) - 1) * PAGE_SIZE); + const tbody = document.getElementById('nodesBody'); + const tableWrap = tbody.closest('.table-container, .results-container, div'); + const prevScroll = tableWrap ? tableWrap.scrollTop : 0; + + const page = filteredNodes.slice(currentOffset, currentOffset + PAGE_SIZE); tbody.innerHTML = ''; const frag = document.createDocumentFragment(); @@ -2127,6 +2138,9 @@

View on Desktop

} else { paginationEl.style.display = 'none'; } + + // Restore scroll position so SSE-driven re-renders don't yank the user back to top + if (tableWrap) tableWrap.scrollTop = prevScroll; } // ─── Sort ─── @@ -2167,7 +2181,7 @@

View on Desktop

'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) { @@ -2228,7 +2242,7 @@

Node Erro } const er = errs[0]; const eh = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); - const captured = er.captured_at ? new Date(typeof er.captured_at === 'number' ? er.captured_at : Number(er.captured_at) || er.captured_at).toLocaleString() : '—'; + const captured = er.captured_at ? fmtUtc(er.captured_at) : '—'; const row = (label, value) => `
${label}
@@ -2439,7 +2453,7 @@

Node Erro

When - ${ts ? new Date(ts).toLocaleString() : '—'} + ${ts ? fmtUtc(ts) : '—'}
${snippet ? `
${escHtml(snippet)}
` @@ -2459,7 +2473,7 @@

Node Erro // Copy button item.querySelector('.error-copy-btn').addEventListener('click', (ev) => { ev.stopPropagation(); - const compact = `${errCode} | ${ts ? new Date(ts).toISOString() : '?'} | ${errMsg}`; + const compact = `${errCode} | ${ts ? fmtUtc(ts) : '?'} | ${errMsg}`; const btn = ev.currentTarget; navigator.clipboard.writeText(compact).then(() => { btn.textContent = '✓ Copied'; @@ -3008,6 +3022,7 @@

Node Erro } } + let _sseEverConnected = false; function connectSSE() { if (_sse) { try { _sse.close(); } catch (_) {} } try { @@ -3021,7 +3036,18 @@

Node Erro try { handlePublicEvent(d); } catch (_) {} }; - _sse.onopen = () => { _sseRetry = 1000; }; + _sse.onopen = () => { + _sseRetry = 1000; + // On reconnect (not initial connect), re-seed via REST so we don't + // show stale batch list / node data while waiting for the next event. + if (_sseEverConnected) { + (async () => { + try { await loadBatchList(); } catch (_) {} + try { loadNodes(); } catch (_) {} + })(); + } + _sseEverConnected = true; + }; _sse.onerror = () => { try { _sse.close(); } catch (_) {} _sse = null; diff --git a/server.js b/server.js index 57f39f7..12718bd 100644 --- a/server.js +++ b/server.js @@ -171,6 +171,16 @@ function saveStateSnapshot() { baselineMbps: state.baselineMbps, totalNodes: state.totalNodes, status: state.status, + // Run-mode context — without this, /api/resume after a process bounce + // silently demotes a subscription run to P2P (the C-1 family of bugs). + runMode: state.runMode, + testRun: state.testRun, + runPlanId: state.runPlanId, + runSubscriptionId: state.runSubscriptionId, + runGranter: state.runGranter, + pricingMode: state.pricingMode, + activeSDK: state.activeSDK, + continuousLoop: state.continuousLoop, }), 'utf8'); } catch { } } @@ -370,7 +380,17 @@ function rehydrateState(results) { if (snap.startedAt) state.startedAt = snap.startedAt; if (snap.baselineMbps) state.baselineMbps = snap.baselineMbps; if (snap.totalNodes) state.totalNodes = snap.totalNodes; - console.log(`State snapshot restored: baseline=${snap.baselineHistory?.length || 0} readings, speeds=${snap.nodeSpeedHistory?.length || 0} nodes, total=${state.totalNodes}`); + // Restore run-mode context so /api/resume after a process bounce can + // route to the correct pipeline (P2P vs subscription vs test). + if (snap.runMode) state.runMode = snap.runMode; + if (snap.testRun != null) state.testRun = snap.testRun; + if (snap.runPlanId) state.runPlanId = snap.runPlanId; + if (snap.runSubscriptionId) state.runSubscriptionId = snap.runSubscriptionId; + if (snap.runGranter) state.runGranter = snap.runGranter; + if (snap.pricingMode) state.pricingMode = snap.pricingMode; + if (snap.activeSDK) state.activeSDK = snap.activeSDK; + if (snap.continuousLoop != null) state.continuousLoop = snap.continuousLoop; + console.log(`State snapshot restored: baseline=${snap.baselineHistory?.length || 0} readings, speeds=${snap.nodeSpeedHistory?.length || 0} nodes, total=${state.totalNodes}, runMode=${state.runMode || 'none'}`); } catch { } // Resume the active test — DON'T create a new one on restart @@ -1170,11 +1190,20 @@ app.get('/api/events', adminOnly, rlAdminSse, (req, res) => { res.flushHeaders(); const results = getResults(); const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`); - const { walletAddress, balance, balanceUdvpn, spentUdvpn, ...stateForSse } = state; + // Strip wallet + balance internals AND runGranter (subscription granter + // address — operator-internal, never needs to leave the server). + const { walletAddress, balance, balanceUdvpn, spentUdvpn, runGranter, ...stateForSse } = state; send({ type: 'init', state: stateForSse, results, logs: logBuffer.slice() }); const ADMIN_BLOCK = /^(loop:|iteration:|batch:)/; const handler = (data) => { if (data && typeof data.type === 'string' && ADMIN_BLOCK.test(data.type)) return; + // Strip runGranter from any state payload before sending — even the admin + // browser doesn't need the granter address; it's purely server-internal. + if (data && data.type === 'state' && data.state && typeof data.state === 'object') { + const { runGranter, ...safeState } = data.state; + send({ ...data, state: safeState }); + return; + } send(data); }; emitter.on('update', handler); @@ -1266,8 +1295,7 @@ function startFreshRun(label, { mode = 'p2p', plan_id = null } = {}) { // Start NEW test (saves current, clears, starts fresh). app.post('/api/start', adminOnly, async (req, res) => { - // Accept testRun (current) or dryRun (one-release backward-compat alias — remove next release). - const testRun = !!(req.body?.testRun || req.query.testRun || req.body?.dryRun || req.query.dryRun); + const testRun = !!(req.body?.testRun || req.query.testRun); const infiniteLoop = !!(req.body?.infiniteLoop || req.query.infiniteLoop); const pricingMode = (req.body?.pricingMode === 'hours' || req.query.pricingMode === 'hours') ? 'hours' : 'gigabytes'; @@ -1351,7 +1379,17 @@ app.post('/api/resume', adminOnly, async (req, res) => { try { _mkd(resumeRunDir, { recursive: true }); } catch { } setActiveRunDir(resumeRunDir); - const runMode = state.runMode || 'p2p'; + // Refuse to silently demote an unknown-mode resume to P2P. If the snapshot + // didn't preserve runMode (older runs pre-snapshot-v2) the operator must + // start a fresh test so we don't pay-per-node on a subscription chain or + // accidentally TEST_RUN_SKIP a real audit. + if (!state.runMode) { + return res.status(409).json({ + error: 'NO_RUN_MODE', + message: 'Cannot resume — run mode is unknown (snapshot did not preserve mode). Start a new test instead.', + }); + } + const runMode = state.runMode; const modeTag = runMode === 'subscription' ? `Sub. Plan ${state.runPlanId}` : (runMode === 'test' ? 'Test Run' : 'P2P'); broadcast('log', { msg: `▶ Resuming Test #${state.activeRunNumber} (${modeTag}) from node ${results.length + 1} (${results.length} already tested, SDK: ${state.activeSDK.toUpperCase()})` }); res.json({ ok: true, testNumber: state.activeRunNumber, resumeFrom: results.length, runMode }); @@ -1787,18 +1825,27 @@ app.post('/api/runs/load/:num', adminOnly, (req, res) => { app.post('/api/sdk', adminOnly, (req, res) => { const { sdk } = req.body; const SDK_LABELS = { js: 'Blue JS', csharp: 'Blue C#', tkd: 'TKD JS (Official)' }; - if (SDK_LABELS[sdk]) { - const changed = state.activeSDK !== sdk; - state.activeSDK = sdk; - try { _wfs(SDK_PREF_FILE, sdk, 'utf8'); } catch {} - if (changed) { - broadcast('state', { state }); - broadcast('log', { msg: `SDK switched to ${SDK_LABELS[sdk]}` }); - } - res.json({ ok: true, sdk }); - } else { - res.status(400).json({ error: 'Invalid SDK. Use "js", "csharp", or "tkd"' }); + if (!SDK_LABELS[sdk]) { + return res.status(400).json({ error: 'Invalid SDK. Use "js", "csharp", or "tkd"' }); + } + // Refuse SDK swaps mid-run — the pipeline reads state.activeSDK on every + // node, so a switch would silently split a single audit across two SDK + // implementations and contaminate the result-row sdk tag. Stop the run + // first, then switch. + if (state.status === 'running' || state.status === 'paused' || continuous.status().running) { + return res.status(409).json({ + error: 'RUN_ACTIVE', + message: 'Cannot switch SDK while an audit is running or paused. Stop first.', + }); + } + const changed = state.activeSDK !== sdk; + state.activeSDK = sdk; + try { _wfs(SDK_PREF_FILE, sdk, 'utf8'); } catch {} + if (changed) { + broadcast('state', { state }); + broadcast('log', { msg: `SDK switched to ${SDK_LABELS[sdk]}` }); } + res.json({ ok: true, sdk }); }); app.get('/api/sdk', adminOnly, (req, res) => { @@ -1887,69 +1934,6 @@ app.post('/api/dns', adminOnly, (req, res) => { res.status(400).json({ error: 'Provide preset (default|hns|cloudflare|google) or servers array' }); }); -// ─── Dictator Mode ────────────────────────────────────────────────────────── -app.get('/dictator', adminOnly, (req, res) => res.sendFile(path.join(__dirname, 'dictator.html'))); - -app.get('/api/dictator', adminOnly, (req, res) => { - const results = getResults(); - const countryMap = {}; - for (const r of results) { - const country = r.country || 'Unknown'; - if (!countryMap[country]) { - countryMap[country] = { - country, - total: 0, - tested: 0, - googleYes: 0, - googleNo: 0, - googleUnknown: 0, - googleLatencySum: 0, - googleLatencyCount: 0, - nodes: [], - }; - } - const c = countryMap[country]; - c.total++; - if (r.actualMbps != null) c.tested++; - if (r.googleAccessible === true) { - c.googleYes++; - if (r.googleLatencyMs != null) { - c.googleLatencySum += r.googleLatencyMs; - c.googleLatencyCount++; - } - } else if (r.googleAccessible === false) { - c.googleNo++; - } else { - c.googleUnknown++; - } - c.nodes.push({ - address: r.address, - moniker: r.moniker, - city: r.city, - googleAccessible: r.googleAccessible, - googleLatencyMs: r.googleLatencyMs, - actualMbps: r.actualMbps, - type: r.type, - error: r.error || null, - }); - } - const countries = Object.values(countryMap) - .map(c => ({ - country: c.country, - total: c.total, - tested: c.tested, - googleYes: c.googleYes, - googleNo: c.googleNo, - googleUnknown: c.googleUnknown, - avgGoogleLatencyMs: c.googleLatencyCount > 0 - ? Math.round(c.googleLatencySum / c.googleLatencyCount) - : null, - nodes: c.nodes, - })) - .sort((a, b) => a.country.localeCompare(b.country)); - res.json({ sdk: state.activeSDK, countries, generatedAt: new Date().toISOString() }); -}); - // ─── Health ───────────────────────────────────────────────────────────────── app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime() }); @@ -1957,8 +1941,7 @@ app.get('/health', (req, res) => { // ─── Server Startup ───────────────────────────────────────────────────────── app.listen(PORT, async () => { - console.log(`\nSentinel Node Audit Dashboard → http://localhost:${PORT}`); - console.log(`Dictator Mode → http://localhost:${PORT}/dictator\n`); + console.log(`\nSentinel Node Audit Dashboard → http://localhost:${PORT}\n`); if (!IS_ADMIN) { console.warn('⚠ NOT running as Administrator — WireGuard tests will be skipped.'); } else { diff --git a/types/index.d.ts b/types/index.d.ts index 75c547b..60bc59c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -116,6 +116,22 @@ export interface AuditState { retestPassed?: number; retestFailed?: number; activeSDK?: string; + /** Active run mode (persisted across restart). */ + runMode?: 'p2p' | 'subscription' | 'test' | null; + /** Skip-only TEST RUN demo flag (persisted). */ + testRun?: boolean; + /** Plan ID when runMode='p2p'. */ + runPlanId?: string | number | null; + /** Subscription ID when runMode='subscription'. */ + runSubscriptionId?: string | number | null; + /** Fee-grant granter address. Admin-only — never broadcast over the public SSE channel. */ + runGranter?: string | null; + /** Active pricing strategy. */ + pricingMode?: string | null; + /** True when a continuous loop run is active. */ + continuousLoop?: boolean; + /** When true, public SSE forwards live events; when false, public sees last-completed snapshot only. */ + broadcastLive?: boolean; } /** Session map entry for a node. */ @@ -269,9 +285,9 @@ export class SpeedTestError extends AuditError { constructor(message: string, diag?: DiagnosticData); } -// Re-exported from sentinel-dvpn-sdk -export { SentinelError, ValidationError, NodeError, SecurityError } from 'sentinel-dvpn-sdk'; -export { ErrorCodes, ERROR_SEVERITY, isRetryable, userMessage } from 'sentinel-dvpn-sdk'; +// Re-exported from blue-js-sdk +export { SentinelError, ValidationError, NodeError, SecurityError } from 'blue-js-sdk'; +export { ErrorCodes, ERROR_SEVERITY, isRetryable, userMessage } from 'blue-js-sdk'; // ─── Audit Pipeline ───────────────────────────────────────────────────────── From 61f419ad89c670cb7f533b5e75865182c5bb19d3 Mon Sep 17 00:00:00 2001 From: Human and Agent dVPN <271368948+Sentinel-Autonomybuilder@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:09:07 -0700 Subject: [PATCH 4/4] fix(test): replace fragile node -e shell template-literal with spawnSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PUBLIC_MODE startup test used execSync with a multi-line node -e template literal. Embedded newlines and shell quoting made it unreliable across Windows/macOS/Linux runners — CI got exit code 0 instead of the expected 1, failing all six platform jobs. Switch to spawnSync with the script passed as a single arg (no shell parsing) and an explicit env so the child cannot inherit a non-empty ADMIN_TOKEN from the runner. --- test/security.test.js | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/test/security.test.js b/test/security.test.js index fb18622..a7caa56 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -10,7 +10,7 @@ import { createServer } from 'http'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import path from 'path'; -import { execSync } from 'child_process'; +import { execSync, spawnSync } from 'child_process'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, '..'); @@ -225,21 +225,15 @@ async function runHttpTests(port) { async function runPublicModeStartupTest() { console.log('\n3. PUBLIC_MODE=true + ADMIN_TOKEN empty → process.exit(1)...'); - try { - execSync(`node -e " - process.env.PUBLIC_MODE = 'true'; - process.env.ADMIN_TOKEN = ''; - // Simulate the startup check inline (mirrors server.js logic) - if (process.env.PUBLIC_MODE === 'true' && !process.env.ADMIN_TOKEN) { - process.exit(1); - } - process.exit(0); - "`, { stdio: 'pipe' }); - assert(false, 'PUBLIC_MODE=true + ADMIN_TOKEN empty → should have exited 1'); - } catch (err) { - // execSync throws when exit code !== 0 - assert(err.status === 1, 'PUBLIC_MODE=true + ADMIN_TOKEN empty → exits with code 1'); - } + // Use spawnSync with the script as a single argument (no shell parsing). + // Pass env explicitly so the child does not inherit a non-empty ADMIN_TOKEN + // from the parent process or CI runner. + const script = "if (process.env.PUBLIC_MODE === 'true' && !process.env.ADMIN_TOKEN) { process.exit(1); } process.exit(0);"; + const result = spawnSync(process.execPath, ['-e', script], { + env: { ...process.env, PUBLIC_MODE: 'true', ADMIN_TOKEN: '' }, + stdio: 'pipe', + }); + assert(result.status === 1, 'PUBLIC_MODE=true + ADMIN_TOKEN empty → exits with code 1'); } // ─── Main ────────────────────────────────────────────────────────────────────