diff --git a/.gitignore b/.gitignore index 4624335..40e4470 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ SentinelAudit.exe # Backup files *.bak *.backup +*.local-backup +agent-map.json # Editor & OS .vscode/ diff --git a/CLAUDE.md b/CLAUDE.md index ac06cb0..449feeb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ TEST RUN is an optional skip-only demo — it is NOT a separate mode and it does - 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='test'` so it is visually distinguishable in the admin table. -- No second database. No `audit-test.db`. One file on disk. +- No second database. No `audit-dry.db`. One file on disk. ## Theme (Dark/Light) - Toggle exists on BOTH `admin.html` and `public.html` (and `/live` when built). @@ -85,8 +85,9 @@ 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-test.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, `TEST_RUN_SKIP`, `test-run:log`, `#testRunLoop`. Broadcast Live toggle moved to top action cluster. +- DONE 2026-04-25: Consolidated the legacy `audit-test.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, `TEST_RUN_SKIP` legacy code path, `test-run:log`, `#testRunLoop`. Broadcast Live toggle moved to top action cluster. +- DONE 2026-04-26: Renamed every `dry`/`dryRun`/`dry-run`/`DRY_RUN` identifier in the project to the `test`/`testRun`/`test-run`/`TEST_RUN` family. There is no `dry` vocabulary anywhere in the tester. The flag is `state.testRun`, the API param is `testRun` / `?testRun=1`, the UI vars are `isTestRun`. The GitHub canonical version of `audit/pipeline.js` and `audit/node-test.js` is updated to match. - DONE: Theme toggle on `public.html` (#22) — `#btnTheme` + `toggleTheme()` already wired. ## Don't @@ -95,11 +96,11 @@ TEST RUN is an optional skip-only demo — it is NOT a separate mode and it does - Don't commit `.env` or `MNEMONIC=...`. - Don't `taskkill /F /IM node.exe` — kills Claude Code's own runtime. - Don't hide or remove the per-row failure copy button — the failure-log UX is a MUST, not a polish item. -- **DON'T FUCK WITH TEST RUN.** Never modify TEST RUN code paths. This file is the source of truth. This includes: +- **DON'T FUCK WITH TEST RUN.** Never modify TEST RUN code paths. The canonical implementation lives on GitHub at `Sentinel-Autonomybuilder/sentinel-node-tester` — that is the source of truth. This includes: - The `if (state.testRun)` short-circuit in `audit/node-test.js` (the block that returns early with `errorCode: 'TEST_RUN_SKIP'` after price discovery). - The TEST RUN branching in `audit/pipeline.js` (anything gated on `state.testRun`, including the batch-payment skip and `state.testRun = ...` assignment). - The `testRun` flag plumbing through `POST /api/start` (body `testRun: true` and query `?testRun=1`) in `server.js`. - The `mode='test'` row write in `core/db.js`. - Any helper that exists solely to support TEST RUN (skip flags, `TEST_RUN_SKIP` error code, test-run UI badges, etc.). - The vocabulary is `test`/`testRun`/`test-run`/`TEST_RUN` ONLY — never reintroduce `dry`/`dryRun`/`dry-run`/`DRY_RUN` anywhere in the project. - If a parity refactor, SDK upgrade, or "cleanup" seems to require touching TEST RUN — STOP. Ask the user first. Do not refactor, rename, "consolidate", "simplify", or otherwise modify these paths under any pretext. Treat TEST RUN as immutable. + If a parity refactor, SDK upgrade, or "cleanup" seems to require touching TEST RUN — STOP. Ask the user first. Do not refactor, rename, "consolidate", "simplify", or otherwise modify these paths under any pretext. If you find local divergence from the GitHub canonical version, the local version is wrong; restore from GitHub. Treat TEST RUN as immutable. diff --git a/SETUP.md b/SETUP.md index e4f6e63..4ccc4c0 100644 --- a/SETUP.md +++ b/SETUP.md @@ -130,9 +130,8 @@ This will: ## 9. Verify 1. Open http://localhost:3001 -- you should see the dashboard with your wallet address and P2P balance -2. Open http://localhost:3001/dictator -- Dictator Mode loads (empty until first audit) -3. Click **Start Audit** -- the live log should show node discovery, then batch payments, then individual node tests -4. Check `results/results.json` after a few nodes complete -- should contain test result objects +2. Click **Start Audit** -- the live log should show node discovery, then batch payments, then individual node tests +3. Check `results/results.json` after a few nodes complete -- should contain test result objects ### Health check diff --git a/admin.html b/admin.html index 5781c40..88b7452 100644 --- a/admin.html +++ b/admin.html @@ -541,7 +541,7 @@ .table-wrap { overflow-y: auto; flex: 1; 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; } th { text-align: left; @@ -563,12 +563,38 @@ white-space: nowrap; vertical-align: middle; background: var(--bg-card); + overflow: hidden; + text-overflow: ellipsis; } + /* Wide columns that benefit from truncation + tooltip */ + td.td-moniker { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + td.td-city { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + td.td-error { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + tr td:first-child { border-top-left-radius: 6px; border-bottom-left-radius: 6px; } tr td:last-child { border-top-right-radius: 6px; border-bottom-right-radius: 6px; } tr:hover td { background: var(--bg-card-solid); } + /* Numeric cells: tabular figures so digits align column-wise without overflow jitter */ + td[style*="text-align:right"] { font-variant-numeric: tabular-nums; } + + /* Pagination controls */ + .pager { font-family: var(--font-mono); } + .btn-paginate { + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 12px; + font-size: 11px; + font-family: var(--font-mono); + cursor: pointer; + transition: background 0.15s; + } + .btn-paginate:hover:not([disabled]) { background: var(--bg-card-solid); border-color: var(--border-hover); } + .btn-paginate[disabled] { opacity: 0.4; cursor: not-allowed; } + .badge { padding: 3px 8px; border-radius: 4px; @@ -787,24 +813,44 @@

SENTINEL NODE TEST

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

SENTINEL NODE TEST

+
@@ -961,6 +1008,7 @@

SENTINEL NODE TEST

Type to search nodes… +
@@ -1090,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); @@ -1199,7 +1255,7 @@

SENTINEL NODE TEST

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

SENTINEL NODE TEST

const active = running || paused; document.getElementById('headerDot').className = 'pulse-dot' + (running ? ' running' : paused ? ' paused' : ''); + // ─── Mode badge: clear "what's running right now" ────────────────── + // Three states the operator and viewer must distinguish at a glance: + // • Plan #N — testing only nodes inside a subscription plan + // • P2P — testing every online node, paying each session ourselves + // • Test Run — sample/skip data, no real measurements + const modeBadge = document.getElementById('modeBadge'); + if (modeBadge) { + if (!active) { + modeBadge.style.display = 'none'; + } else { + const lbl = document.getElementById('modeBadgeLabel'); + const det = document.getElementById('modeBadgeDetail'); + const dot = document.getElementById('modeBadgeDot'); + let label, detail, color; + if (state.testRun || state.runMode === 'test') { + label = 'Test Run'; + detail = 'sample data — no real measurements, no payments'; + color = 'var(--accent)'; + } else if (state.runMode === 'subscription' && state.runPlanId) { + label = 'Plan #' + state.runPlanId; + detail = 'subscription-allocated sessions, plan-scoped node set'; + color = 'var(--green)'; + } else { + label = 'P2P'; + detail = 'all online nodes, per-session payments from this wallet'; + color = 'var(--accent-bright, var(--accent))'; + } + modeBadge.style.display = ''; + modeBadge.style.borderColor = color; + modeBadge.style.color = color; + if (dot) dot.style.background = color; + if (lbl) lbl.textContent = label; + if (det) det.textContent = detail; + } + } + + // ─── Reconcile UI _testingMode with the server's authoritative runMode ─ + // The client mode flag (_testingMode) is only meaningful during the + // chooser flow. Once a run is active, the server's state.runMode is + // authoritative — drift between the two is what produced the C-1 family + // of bugs (Resume/Retest short-circuiting on stale UI state). When a + // server-sent state lands, force the UI flag to mirror it so any later + // resumeAudit / retestFails / testPlanFlow call branches correctly. + if (active && state.runMode) { + const want = state.testRun || state.runMode === 'test' + ? 'testrun' + : (state.runMode === 'subscription' ? 'plan' : 'p2p'); + if (_testingMode !== want) { + _testingMode = want; + try { localStorage.setItem('sntTestingMode', _testingMode); } catch {} + if (state.runMode === 'subscription' && state.runPlanId) _selectedPlanId = state.runPlanId; + } + } + + // Mode/pay/loop UI lives in the New Test chooser popup now — the toolbar + // stubs are display:none. Mode is locked by virtue of the chooser only + // opening when the operator clicks New Test, which is itself disabled + // while a run is active. + const btnStart = document.getElementById('btnStart'); const btnResume = document.getElementById('btnResume'); const btnRetest = document.getElementById('btnRetestFails'); @@ -1229,13 +1344,13 @@

SENTINEL NODE TEST

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

SENTINEL NODE TEST

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

SENTINEL NODE TEST

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

SENTINEL NODE TEST

let activeFilter = 'all'; function setFilter(f) { activeFilter = f; + _resultsPage = 1; // Update button styles ['all','pass','fail','slow','wg','v2'].forEach(id => { const el = document.getElementById('filter' + id.charAt(0).toUpperCase() + id.slice(1)); @@ -1434,24 +1570,70 @@

SENTINEL NODE TEST

return arr; } + const RESULTS_PAGE_SIZE = 50; + let _resultsPage = 1; + function renderTable() { const tbody = document.getElementById('resultsBody'); + const tableWrap = tbody.closest('.table-container, .results-container, div'); + const prevScroll = tableWrap ? tableWrap.scrollTop : 0; + + const filtered = applyFilter(resultsArr).slice().reverse(); + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / RESULTS_PAGE_SIZE)); + if (_resultsPage > totalPages) _resultsPage = totalPages; + const start = (_resultsPage - 1) * RESULTS_PAGE_SIZE; + const slice = filtered.slice(start, start + RESULTS_PAGE_SIZE); + tbody.innerHTML = ''; - const filtered = applyFilter(resultsArr); - filtered.slice(-maxRows).reverse().forEach(r => appendRowHtml(r, tbody)); - const label = activeFilter === 'all' ? filtered.length + ' Entries' : filtered.length + '/' + resultsArr.length + ' (' + activeFilter.toUpperCase() + ')'; + slice.forEach(r => appendRowHtml(r, tbody)); + + const label = activeFilter === 'all' ? total + ' Entries' : total + '/' + resultsArr.length + ' (' + activeFilter.toUpperCase() + ')'; document.getElementById('resultsCountLabel').textContent = label; + renderResultsPager(total, totalPages); + + if (tableWrap) tableWrap.scrollTop = prevScroll; + } + + function renderResultsPager(total, totalPages) { + const pager = document.getElementById('resultsPager'); + if (!pager) return; + if (totalPages <= 1) { pager.innerHTML = ''; return; } + const start = (_resultsPage - 1) * RESULTS_PAGE_SIZE + 1; + const end = Math.min(_resultsPage * RESULTS_PAGE_SIZE, total); + pager.innerHTML = + '' + + 'Page ' + _resultsPage + ' / ' + totalPages + ' · ' + start + '–' + end + ' of ' + total + '' + + ''; + } + + function resultsGoPage(n) { + const filtered = applyFilter(resultsArr); + const totalPages = Math.max(1, Math.ceil(filtered.length / RESULTS_PAGE_SIZE)); + _resultsPage = Math.min(Math.max(1, n), totalPages); + renderTable(); } + window.resultsGoPage = resultsGoPage; + // Live SSE row update — only patches when the row is on the visible page, + // otherwise relies on the next renderTable() to pick up the change. function addSingleRow(r) { - const tbody = document.getElementById('resultsBody'); - // Remove old row for same node (upsert replaced it in array, now fix DOM) - for (const row of tbody.querySelectorAll('tr')) { - if (row.dataset.addr === r.address) { row.remove(); break; } + const filtered = applyFilter(resultsArr).slice().reverse(); + const idx = filtered.findIndex(x => x.address === r.address); + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / RESULTS_PAGE_SIZE)); + const pageStart = (_resultsPage - 1) * RESULTS_PAGE_SIZE; + const pageEnd = pageStart + RESULTS_PAGE_SIZE; + + // If the new row would land on the current page, re-render the page so + // ordering stays correct. Otherwise just refresh the counter + pager. + if (idx >= pageStart && idx < pageEnd) { + renderTable(); + return; } - appendRowHtml(r, tbody, true); - document.getElementById('resultsCountLabel').textContent = resultsArr.length + ' Entries'; - if (tbody.children.length > maxRows) tbody.removeChild(tbody.lastChild); + const label = activeFilter === 'all' ? total + ' Entries' : total + '/' + resultsArr.length + ' (' + activeFilter.toUpperCase() + ')'; + document.getElementById('resultsCountLabel').textContent = label; + renderResultsPager(total, totalPages); } function appendRowHtml(r, tbody, prepend = false) { @@ -1463,13 +1645,13 @@

SENTINEL NODE TEST

const addrFull = r.address || ''; const monikerText = r.moniker || r.nodeName || r.name || '(unknown)'; const addrShort = addrFull ? `${addrFull.slice(0, 12)}…${addrFull.slice(-5)}` : '--'; - const addr = addrFull ? `${addrShort}` : '--'; + const addr = addrFull ? `${escHtml(addrShort)}` : '--'; const cc = r.countryCode || _countryToCode(r.country) || ''; const flag = cc.length === 2 - ? `${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' : '--'; @@ -1553,7 +1735,8 @@

SENTINEL NODE TEST

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

SENTINEL NODE TEST

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

SENTINEL NODE TEST

async function startAudit() { if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; } - const dryEl = document.getElementById('testRunDev'); - const isTestRun = !!(dryEl && dryEl.checked); + // Open the mode chooser — operator picks Subscription Plan / Peer to Peer / Test Run. + // Each tile commits the testing mode and then calls _startAuditAfterChoice(). + openModeChooser(); + } + + function openModeChooser() { + // Tear down any previous chooser + const existing = document.getElementById('modeChooserBackdrop'); + if (existing) existing.remove(); + + const backdrop = document.createElement('div'); + backdrop.id = 'modeChooserBackdrop'; + backdrop.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9998;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);'; + + const modal = document.createElement('div'); + modal.style.cssText = 'background:var(--bg-card-solid);border:1px solid var(--border-hover);border-radius:10px;padding:28px;max-width:920px;width:92%;max-height:90vh;overflow:auto;color:var(--text);font-family:var(--font-display);'; + + const close = () => backdrop.remove(); + backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); }); + const onKey = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } }; + document.addEventListener('keydown', onKey); + + modal.innerHTML = ` +
+
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(isTestRun) { if (!isTestRun && !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 = ''; const btn = document.getElementById('btnStart'); btn.classList.add('loading'); @@ -1726,27 +2096,52 @@

SENTINEL NODE TEST

btn.disabled = true; document.getElementById('btnStop').disabled = false; try { - if (!await devStart(isTestRun)) { + // Fire /api/start FIRST. Only wipe the dashboard if the server + // accepted the request — otherwise a 4xx (e.g. PUBLIC_RUN_ACTIVE, + // missing MNEMONIC, RUN_ACTIVE) used to leave the operator with an + // empty dashboard for a run that never started. + const ok = await devStart(isTestRun); + if (!ok) { btn.classList.remove('loading'); - btn.textContent = 'New Test'; + btn.textContent = 'Start Test'; btn.disabled = false; return; } + // Server accepted — safe to clear previous test data from dashboard. + resultsArr = []; + state.testedNodes = 0; + state.failedNodes = 0; + state.retryCount = 0; + state.totalNodes = 0; + state.passed15 = 0; + state.passed10 = 0; + state.passedBaseline = 0; + state.nodeSpeedHistory = []; + state.baselineHistory = []; + state.estimatedTotalCost = '0.0000 P2P'; + state.startedAt = null; + state.completedAt = null; + state.errorMessage = null; + state.pauseReason = null; + renderTable(); + applyState(); + document.getElementById('logBody').innerHTML = ''; loadRunsList(); } catch (e) { btn.classList.remove('loading'); - btn.textContent = 'New Test'; + btn.textContent = 'Start Test'; btn.disabled = false; } } async function resumeAudit() { - const dryEl = document.getElementById('testRunDev'); - if (dryEl && dryEl.checked) { + if (isTestRunMode()) { if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; } await devStart(true); return; } + // Plan mode: server-side resume is mode-aware and uses the persisted + // planId/subId/granter from the run that was paused — DO NOT re-prompt. const btn = document.getElementById('btnResume'); btn.classList.add('loading'); btn.textContent = 'Resuming...'; @@ -1773,8 +2168,7 @@

SENTINEL NODE TEST

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

SENTINEL NODE TEST

} } + // CLI for dVPN Builders. Closeable via × button, Escape, or backdrop click. + function showCliPopup() { + const existing = document.getElementById('cliOverlay'); + if (existing) { existing.remove(); return; } + + const overlay = document.createElement('div'); + overlay.id = 'cliOverlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:var(--glass-bg);backdrop-filter:blur(8px);z-index:9999;display:flex;align-items:center;justify-content:center;'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + const popup = document.createElement('div'); + popup.style.cssText = 'background:var(--bg-card-solid);border:1px solid var(--border-hover);border-radius:16px;padding:28px 32px;max-width:880px;width:94%;max-height:90vh;overflow-y:auto;color:var(--text);font-family:var(--font-display);line-height:1.55;box-shadow:var(--shadow-lg)'; + + const codeStyle = 'font-family:var(--font-mono,ui-monospace,Menlo,Consolas,monospace);background:var(--bg-input);color:var(--text);padding:2px 6px;border-radius:4px;font-size:12px;border:1px solid var(--border);'; + const blockStyle = 'font-family:var(--font-mono,ui-monospace,Menlo,Consolas,monospace);background:var(--bg-input);color:var(--text);padding:12px 14px;border-radius:6px;font-size:12px;border:1px solid var(--border);white-space:pre;overflow-x:auto;line-height:1.6;'; + const sectionTitle = 'color:var(--accent);font-weight:600;font-size:14px;margin:22px 0 8px;letter-spacing:0.4px;text-transform:uppercase'; + const subTitle = 'color:var(--text);font-weight:600;font-size:13px;margin:14px 0 6px'; + const tocStyle = 'display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 6px;font-size:11px'; + const chipStyle = 'background:var(--bg-input);border:1px solid var(--border);padding:3px 8px;border-radius:99px;color:var(--text-dim);text-decoration:none;letter-spacing:0.4px'; + + popup.innerHTML = ` +
+
+

CLI for dVPN Builders

+
Drop the same audit pipeline that powers this dashboard into your own dVPN tooling — headless, scriptable, AI-discoverable.
+
+ × +
+ +
+ 1 · Install + 2 · Configure + 3 · Quickstart + 4 · Commands + 5 · Flags + 6 · Output + 7 · Integrate + 8 · Agent / HTTP + 9 · Library use + 10 · CI / Docker + 11 · Troubleshoot +
+ +
+ +
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
+
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 + +# 2. Pull every active node from chain (no tokens spent): +sentinel-audit nodes --pretty + +# 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 +
+ `; + 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 +2532,7 @@

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

-
Subscription Plan
-

Test Sub. Plan

+

Test Subscription Plan

×
- Only tests nodes in the selected plan. Every session TX is fee-granted by the plan owner — this wallet pays zero gas. + Only tests nodes in the selected plan. Session TXs are fee-granted by the plan owner — but only if that plan owner has an active feegrant for this wallet. Plans without a feegrant are flagged below and disabled (this wallet would otherwise pay gas itself).
Loading your subscriptions…
@@ -1962,6 +2625,9 @@

FEE GRANT ACTIVE' : 'NO FEE GRANT'; + const allocBadge = p.viaAllocation + ? 'ALLOCATION' + : ''; const disabled = !p.feeGrantActive || p.nodeCount === 0 || !p.ownerSentAddr; const reason = !p.ownerSentAddr ? 'Plan owner unknown' : p.nodeCount === 0 ? 'No active nodes in this plan' @@ -1976,6 +2642,7 @@

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

Subscription #${p.subscriptionId} @@ -1995,8 +2662,7 @@

Select plan…']; - _subPlanCache.plans.forEach(p => { - const usable = p.feeGrantActive && p.nodeCount > 0 && p.ownerSentAddr; - const label = `Plan ${p.planId} — ${p.nodeCount} nodes${p.feeGrantActive ? '' : ' • no fee grant'}`; - opts.push(``); - }); - sel.innerHTML = opts.join(''); + // Server accepted — clean canvas for the new run. + const _logBody = document.getElementById('logBody'); + if (_logBody) _logBody.innerHTML = ''; + resultsArr = []; + state.testedNodes = 0; + state.failedNodes = 0; + state.totalNodes = 0; + state.passed10 = 0; + state.passed15 = 0; + state.passedBaseline = 0; + state.nodeSpeedHistory = []; + state.baselineHistory = []; + renderTable(); + applyState(); } catch (err) { - sel.innerHTML = ``; + alert('Sub. Plan request failed: ' + err.message); } } - function onSubPlanInlineChange() { /* selection stored via select.value; Start reads it */ } - - async function startCreateMode() { - const btn = document.getElementById('btnCreateStart'); - const dryEl = document.getElementById('testRunDev'); - if (dryEl && dryEl.checked) { - if (state.status === 'running' || state.status === 'paused') { stopAudit(); return; } - await devStart(true); - return; - } - if (_createMode === 'p2p') { - startAudit(); - return; - } - const sel = document.getElementById('subPlanInlineSelect'); - const planId = sel ? sel.value : ''; - if (!planId) { - alert('Pick a subscription plan first.'); - return; - } - const plan = _subPlanCache.plans.find(p => String(p.planId) === String(planId)); - if (!plan) { alert('Plan not found in cache. Reopen and try again.'); return; } - if (!plan.feeGrantActive || plan.nodeCount === 0 || !plan.ownerSentAddr) { - alert('This plan is not testable: ' + ( - !plan.ownerSentAddr ? 'plan owner unknown.' - : plan.nodeCount === 0 ? 'no active nodes.' - : 'plan owner has not granted gas to this wallet.' - )); - return; - } - if (btn) { btn.disabled = true; btn.classList.add('loading'); } - try { - await runSubPlanTest(plan.planId, plan.subscriptionId, plan.ownerSentAddr); - } finally { - if (btn) { btn.disabled = false; btn.classList.remove('loading'); } - } - } + // ─── Create-Mode shadow chooser removed ───────────────────────────────── + // The setCreateMode / startCreateMode / loadSubPlansInline / _createMode + // / _subPlanCache surface was an unreachable shadow of the live New-Test + // chooser flow. It had no DOM caller (the active flow goes through + // setTestingMode + startAudit / runSubPlanTest / devStart). Removed to + // eliminate drift between two parallel mode-picker code paths. async function stopAudit() { const btn = document.getElementById('btnStop'); @@ -2218,41 +2832,83 @@

Search failed: ' + e.message + ''; + 'Search failed: ' + escHtml(e.message || String(e)) + ''; + const pager = document.getElementById('nsPager'); + if (pager) pager.innerHTML = ''; } } - function nsRenderResults(nodes) { + let _nsAllNodes = []; + let _nsPage = 1; + const NS_PAGE_SIZE = 50; + + function nsRenderPage() { const tbody = document.getElementById('nsBody'); - document.getElementById('nsResultCount').textContent = nodes.length ? nodes.length + ' nodes' : ''; - if (!nodes.length) { + const total = _nsAllNodes.length; + document.getElementById('nsResultCount').textContent = total ? total + ' nodes' : ''; + if (!total) { tbody.innerHTML = 'No nodes found.'; + const pager = document.getElementById('nsPager'); + if (pager) pager.innerHTML = ''; return; } - tbody.innerHTML = nodes.map(function(n) { - const addrShort = n.addr ? n.addr.slice(0, 14) + '…' + n.addr.slice(-5) : '—'; + const totalPages = Math.max(1, Math.ceil(total / NS_PAGE_SIZE)); + if (_nsPage > totalPages) _nsPage = totalPages; + const start = (_nsPage - 1) * NS_PAGE_SIZE; + const slice = _nsAllNodes.slice(start, start + NS_PAGE_SIZE); + tbody.innerHTML = slice.map(function(n) { + const addrFull = n.addr || ''; + const addrShort = addrFull ? addrFull.slice(0, 14) + '…' + addrFull.slice(-5) : '—'; const lastTested = n.last_tested ? new Date(n.last_tested).toLocaleString() : '—'; const badge = n.last_pass == null ? 'N/A' : n.last_pass ? 'PASS' : 'FAIL'; const mbps = n.actual_mbps != null ? n.actual_mbps.toFixed(2) + ' Mbps' : '—'; - const sa = (n.addr || '').replace(/'/g, ''); - return '' - + '' + (n.moniker || '—') + '' - + '' + addrShort + '' - + '' + (n.country || '—') + '' - + '' + lastTested + '' + + '' + escHtml(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; @@ -2276,15 +2932,15 @@

' + (nd.addr || addr) + ''], - ['Country', nd.country || '—'], - ['City', nd.city || '—'], - ['Service', nd.service_type || '—'], - ['Status', nd.status || '—'], - ['Last Tested', nd.last_tested ? new Date(nd.last_tested).toLocaleString() : '—'], + ['Address', '' + escHtml(nd.addr || addr) + ''], + ['Country', escHtml(nd.country || '—')], + ['City', escHtml(nd.city || '—')], + ['Service', escHtml(nd.service_type || '—')], + ['Status', escHtml(nd.status || '—')], + ['Last Tested', escHtml(nd.last_tested ? new Date(nd.last_tested).toLocaleString() : '—')], ].map(function(pair) { return '
' - + '
' + pair[0] + '
' + + '
' + escHtml(pair[0]) + '
' + '
' + pair[1] + '
'; }).join(''); @@ -2371,7 +3027,7 @@

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

Node Error Details

+ × +
+
Loading…
+ `; + overlay.appendChild(popup); + document.body.appendChild(overlay); + + try { + const res = await fetch('/api/public/node/' + encodeURIComponent(addr) + '/errors?limit=1', { + credentials: 'same-origin', + }); + const data = await res.json(); + const errs = (data && data.errors) || []; + const body = document.getElementById('errorPopupBody'); + if (!body) return; + if (!errs.length) { + body.innerHTML = '
No stored failure log for this node yet.
'; + return; + } + const er = errs[0]; + const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); + const captured = _fmtCapturedUtc(er.captured_at); + const row = (label, value) => ` +
+
${label}
+
${value || '—'}
+
`; + body.innerHTML = ` + ${row('Node', escapeHtml(moniker))} + ${row('Address', `${escapeHtml(addr)}`)} + ${row('Stage', escapeHtml(er.stage))} + ${row('Error Code', `${escapeHtml(er.error_code)}`)} + ${row('Captured', escapeHtml(captured))} + ${row('Message', `${escapeHtml(er.error_message)}`)} + ${er.log_snippet ? ` +
+
Log Snippet
+
${escapeHtml(er.log_snippet)}
+
` : ''} +
+ + +
+ `; + } catch (err) { + const body = document.getElementById('errorPopupBody'); + if (body) body.innerHTML = '
Failed to load: ' + (err.message || 'unknown') + '
'; + } + } + // ═══════════════════════════════════════════════════════════════════════ // ─── Broadcast Live ────────────────────────────────────────────────── // ═══════════════════════════════════════════════════════════════════════ diff --git a/audit/continuous.js b/audit/continuous.js index 7a423fb..e9c71e7 100644 --- a/audit/continuous.js +++ b/audit/continuous.js @@ -243,9 +243,27 @@ async function _runOnePass(loopState, batchId, frozenNodes = null) { }; function batchBroadcast(type, data) { + // Forward live log lines + state snapshots through the scoped emitter so + // the admin dashboard AND /live (when broadcastLive=true) see real-time + // activity during continuous-loop runs — not just batch checkpoints. + if (type === 'log') { + _emitScoped('log', data || {}); + return; + } + if (type === 'state') { + _emitScoped('state', data || {}); + return; + } + if (type === 'progress') { + _emitScoped('progress', data || {}); + return; + } if (type !== 'result') return; // only care about per-node results const raw = data?.result; if (!raw) return; + // Forward the full per-node result to the global stream so the live + // dashboard upserts rows identically to admin. Public SSE will sanitize. + _emitScoped('result', { result: raw, batchId }); const payload = _sanitizeBatchNodeResult(raw, batchId); _emitScoped('batch:node:result', payload); // Persist to batch_results (non-blocking, non-fatal) @@ -305,7 +323,9 @@ async function _resolveSnapshot() { * Main loop body — runs until stopRequested or unrecoverable error. */ async function _runLoop() { - _ctrl.running = true; + // _ctrl.running is set synchronously in start() before this is fire-and-forget + // invoked, so a second start() call cannot race past the `if (_ctrl.running)` + // guard while we're still on the microtask queue. _ctrl.stopRequested = false; _ctrl.startedAt = Date.now(); _ctrl.iteration = 0; @@ -331,9 +351,19 @@ async function _runLoop() { mode: _ctrl.mode, }); - // Fresh pipeline state per iteration so counters reset cleanly + // Fresh pipeline state per iteration so counters reset cleanly. + // Inherit SDK / pricingMode / runMode from _ctrl so per-node failure + // rows record the correct sdk and pipeline branches the right way — + // without this, every continuous-loop result fell back to sdk:'js'. const loopState = createState(); loopState.stopRequested = false; + if (_ctrl.activeSDK) loopState.activeSDK = _ctrl.activeSDK; + if (_ctrl.pricingMode) loopState.pricingMode = _ctrl.pricingMode; + loopState.runMode = _ctrl.mode || 'p2p'; + loopState.runPlanId = _ctrl.planId || null; + loopState.runSubscriptionId = _ctrl.subscriptionId || null; + loopState.runGranter = _ctrl.subscriptionGranter || null; + loopState.testRun = !!_ctrl.testRun; let currentBatchId = 0; let frozenNodes = null; @@ -648,6 +678,13 @@ export async function start(opts = {}) { _ctrl.minDelayMs = minDelayMs; _ctrl.lastError = null; _ctrl.iteration = 0; + _ctrl.activeSDK = opts.activeSDK || _ctrl.activeSDK || 'js'; + _ctrl.pricingMode = opts.pricingMode || _ctrl.pricingMode || null; + _ctrl.testRun = !!opts.testRun; + // Mark running synchronously BEFORE the fire-and-forget so a second + // start() call cannot slip through the `if (_ctrl.running)` guard while + // _runLoop is still on the microtask queue (the C-2 race window). + _ctrl.running = true; _emitScoped('loop:started', { mode, diff --git a/audit/pipeline.js b/audit/pipeline.js index 12eada6..eedde10 100644 --- a/audit/pipeline.js +++ b/audit/pipeline.js @@ -14,13 +14,38 @@ import { } from '../core/constants.js'; import { cachedWalletSetup, createFreshClient, signAndBroadcastRetry } from '../core/wallet.js'; import { getAllNodes, fetchPlanMembership, ensureLcd, getActiveLcd, getRpcClient, rpcFetchAllNodesForPlanPaginated } from '../core/chain.js'; -import { rpcQueryNode } from 'sentinel-dvpn-sdk'; +import { rpcQueryNode } from 'blue-js-sdk'; import { submitBatchPayment, waitForBatchSessions, waitForSessionActive, clearPoisonedSessions, clearPaidNodes, clearAllCredentials, invalidateSessionCache, parseNodePriceUdvpn, } from '../core/session.js'; import { nodeStatusV3 } from '../protocol/v3protocol.js'; -import { speedtestDirect, sleep, resolveCfHost } from '../protocol/speedtest.js'; +import { speedtestDirect, sleep as _rawSleep, resolveCfHost } from '../protocol/speedtest.js'; + +// ─── Stop-aware sleep ──────────────────────────────────────────────────────── +// Every sleep in the pipeline races against a module-scope stop signal. +// When `triggerPipelineStop()` is called (from /api/stop), every pending +// sleep resolves instantly so the loop drops back to its `if (state.stopRequested) break` +// check on the next tick. +const _stopWaiters = new Set(); +let _pipelineStopFlag = false; +function sleep(ms) { + if (_pipelineStopFlag) return Promise.resolve(); + return new Promise((resolve) => { + let timer = setTimeout(() => { _stopWaiters.delete(wake); resolve(); }, ms); + const wake = () => { try { clearTimeout(timer); } catch {} _stopWaiters.delete(wake); resolve(); }; + _stopWaiters.add(wake); + }); +} +export function triggerPipelineStop() { + _pipelineStopFlag = true; + for (const w of _stopWaiters) { try { w(); } catch {} } + _stopWaiters.clear(); +} +export function resetPipelineStop() { + _pipelineStopFlag = false; +} + // Platform-aware imports — Windows has full implementation, others get stubs let WG_AVAILABLE, IS_ADMIN, emergencyCleanupSync, uninstallWgTunnel, checkV2Ray; if (process.platform === 'win32') { @@ -324,6 +349,7 @@ function buildFailResult(node, status, state, errMsg, diag = {}) { // ─── Main Audit ───────────────────────────────────────────────────────────── export async function runAudit(resume, state, broadcast, preloadedNodes = null, opts = {}) { + resetPipelineStop(); state.status = 'running'; state.startedAt = new Date().toISOString(); state.errorMessage = null; diff --git a/bin/cli.js b/bin/cli.js index 22a1f7a..6ea4452 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -52,6 +52,7 @@ const COMMAND_GROUPS = { Discovery: ['list', 'functions', 'verify-sdks'], Read: ['nodes', 'node', 'balance', 'subscriptions', 'plans'], Action: ['speed', 'test', 'audit', 'serve'], + Agent: ['agent'], }; // Flat list for quick lookup @@ -95,6 +96,10 @@ function printHelp() { audit Run full audit loop across all nodes serve Start the browser dashboard server + Agent + agent End-to-end driver hitting every server function over HTTP + (run "sentinel-audit agent map --pretty" for the registry) + Examples: sentinel-audit list sentinel-audit nodes --pretty diff --git a/bin/commands/agent.js b/bin/commands/agent.js new file mode 100644 index 0000000..0d38125 --- /dev/null +++ b/bin/commands/agent.js @@ -0,0 +1,325 @@ +/** + * agent — End-to-end CLI server driver for Sentinel Node Tester. + * + * Lets an AI / automation drive every operator-facing function over HTTP + * against a running `serve` instance. Every subcommand prints structured JSON + * to stdout and exits non-zero on error so it composes cleanly in pipelines. + * + * Discovery: + * sentinel-audit agent map # full machine-readable endpoint registry + * sentinel-audit agent --help # short-form usage + * + * Auth: + * --token prefer this + * $SENTINEL_AUDIT_TOKEN else this + * $ADMIN_TOKEN else this + * (no token) single-user/local mode is allowed by server + * + * Target: + * --base-url http://host:port prefer this + * $SENTINEL_AUDIT_URL else this + * --port else http://localhost: + * (default) http://localhost:3001 + */ + +import { api, apiRequest, resolveBaseUrl, resolveToken } from '../lib/http.js'; +import { printJson } from '../lib/output.js'; + +export const name = 'agent'; +export const description = 'End-to-end agentic driver: hit every Sentinel Node Tester server function from the CLI.'; +export const usage = 'sentinel-audit agent [...args] [--base-url URL] [--token T] [--pretty]'; +export const flags = [ + { flag: '--base-url ', description: 'Server URL (default http://localhost:3001 or $SENTINEL_AUDIT_URL)' }, + { flag: '--port ', description: 'Shorthand to set localhost port (default 3001)' }, + { flag: '--token ', description: 'Bearer admin token (default $SENTINEL_AUDIT_TOKEN or $ADMIN_TOKEN)' }, + { flag: '--pretty', description: 'Pretty-print JSON output' }, + { flag: '--timeout ', description: 'HTTP timeout in seconds (default 30)' }, + { flag: '--watch ', description: 'For events/state subcommands: stream / poll for N seconds' }, + // Pass-through bodies + { flag: '--plan-id ', description: 'Plan ID (start, test-plan, sub-plans)' }, + { flag: '--sub-id ', description: 'Subscription ID (start, test-sub-plan)' }, + { flag: '--sub-granter ', description: 'Subscription granter address (start)' }, + { flag: '--test-run', description: 'Use TEST RUN mode for /api/start' }, + { flag: '--pricing-mode ',description: 'gigabytes | hours (default gigabytes)' }, + { flag: '--addr ', description: 'Node address (sentnode1...) for node, retest, etc.' }, + { flag: '--remote-url ', description: 'Node remote URL (https://host:port) for chain-status' }, + { flag: '--country ', description: 'Country filter (audit, retest)' }, + { flag: '--limit ', description: 'Result limit (errors, results)' }, + { flag: '--num ', description: 'Run number (runs/save, runs/load)' }, + { flag: '--sdk ', description: 'SDK selection (js | tkd)' }, +]; + +// ─── Endpoint registry ─────────────────────────────────────────────────────── +// +// Single source of truth. `agent map` prints this verbatim so an AI calling +// the CLI can introspect every function the server exposes. + +const ENDPOINTS = [ + // Discovery / health + { sub: 'health', method: 'GET', path: '/health', auth: false, desc: 'Public health probe' }, + { sub: 'admin-health', method: 'GET', path: '/api/health', auth: true, desc: 'Admin-side health (chain + balance)' }, + { sub: 'stats', method: 'GET', path: '/api/stats', auth: true, desc: 'Admin run/network stats' }, + { sub: 'state', method: 'GET', path: '/api/state', auth: true, desc: 'Full server runtime state' }, + { sub: 'sdk-versions', method: 'GET', path: '/api/sdk-versions', auth: true, desc: 'Installed SDK versions' }, + { sub: 'sdk-verify', method: 'GET', path: '/api/sdk-verify', auth: true, desc: 'Byte-for-byte SDK verification' }, + { sub: 'sdk-verify-key', method: 'GET', path: '/api/sdk-verify/:key', auth: true, desc: 'SDK file content by verification key', params: ['key'] }, + { sub: 'cross-sdk', method: 'GET', path: '/api/cross-sdk', auth: true, desc: 'Cross-SDK comparison data' }, + { sub: 'failure-analysis',method: 'GET', path: '/api/failure-analysis', auth: true, desc: 'Failure breakdown' }, + { sub: 'transport-cache', method: 'GET', path: '/api/transport-cache', auth: true, desc: 'Inspect transport cache (wg/v2ray)' }, + + // Public reads (no auth) + { sub: 'pub-nodes', method: 'GET', path: '/api/public/nodes', auth: false, desc: 'Public node list' }, + { sub: 'pub-node', method: 'GET', path: '/api/public/node/:addr', auth: false, desc: 'Public single node detail', params: ['addr'] }, + { sub: 'pub-node-errors', method: 'GET', path: '/api/public/node/:addr/errors', auth: false, desc: 'Public per-node error log', params: ['addr'] }, + { sub: 'pub-bandwidth', method: 'GET', path: '/api/public/node/:addr/bandwidth', auth: false, desc: 'Public per-node bandwidth history', params: ['addr'] }, + { sub: 'pub-errors', method: 'GET', path: '/api/public/errors', auth: false, desc: 'Recent failure log entries' }, + { sub: 'pub-countries', method: 'GET', path: '/api/public/countries', auth: false, desc: 'Country breakdown' }, + { sub: 'pub-stats', method: 'GET', path: '/api/public/stats', auth: false, desc: 'Public network stats' }, + { sub: 'pub-run-current', method: 'GET', path: '/api/public/runs/current', auth: false, desc: 'In-flight run snapshot (when broadcast on)' }, + { sub: 'pub-run-last', method: 'GET', path: '/api/public/runs/last', auth: false, desc: 'Last completed run snapshot' }, + { sub: 'pub-runs', method: 'GET', path: '/api/public/runs', auth: false, desc: 'List historical runs' }, + { sub: 'pub-batches', method: 'GET', path: '/api/public/batches', auth: false, desc: 'List broadcast batches' }, + { sub: 'pub-batch', method: 'GET', path: '/api/public/batch/:id', auth: false, desc: 'Single batch detail', params: ['id'] }, + { sub: 'pub-logs', method: 'GET', path: '/api/public/logs', auth: false, desc: 'Public broadcast log buffer' }, + { sub: 'pub-live-state', method: 'GET', path: '/api/public/live-state', auth: false, desc: 'Cold-refresh hydration for /live' }, + { sub: 'pub-test-status', method: 'GET', path: '/api/public/test/status', auth: false, desc: 'Public test status probe' }, + { sub: 'pub-test-start', method: 'POST', path: '/api/public/test/start', auth: false, desc: 'Start gated public test (requires ALLOW_PUBLIC_TEST=true)', + bodyFromFlags: (f) => ({ mode: f['--mode'] === 'subscription' ? 'subscription' : 'p2p' }) }, + { sub: 'pub-test-stop', method: 'POST', path: '/api/public/test/stop', auth: false, desc: 'Stop gated public test (requires ALLOW_PUBLIC_TEST=true)' }, + + // Broadcast toggle + { sub: 'broadcast', method: 'GET', path: '/api/broadcast', auth: false, desc: 'Read current broadcastLive value' }, + { sub: 'broadcast-toggle',method: 'POST', path: '/api/broadcast', auth: true, desc: 'Flip broadcastLive (no body)' }, + + // Audit lifecycle + { sub: 'start', method: 'POST', path: '/api/start', auth: true, desc: 'Start audit run', + bodyFromFlags: (f) => { + const body = {}; + if (f['--plan-id']) body.planId = f['--plan-id']; + if (f['--sub-id']) body.subscriptionId = f['--sub-id']; + if (f['--sub-granter'])body.subscriptionGranter = f['--sub-granter']; + if (f['--test-run']) body.testRun = true; + if (f['--pricing-mode']) body.pricingMode = f['--pricing-mode']; + return body; + } }, + { sub: 'resume', method: 'POST', path: '/api/resume', auth: true, desc: 'Resume the last audit run' }, + { sub: 'stop', method: 'POST', path: '/api/stop', auth: true, desc: 'Stop in-flight audit' }, + { sub: 'rescan', method: 'POST', path: '/api/rescan', auth: true, desc: 'Rescan chain node list' }, + { sub: 'retest-skips', method: 'POST', path: '/api/retest-skips', auth: true, desc: 'Retest only skipped nodes' }, + { sub: 'retest-fails', method: 'POST', path: '/api/retest-fails', auth: true, desc: 'Retest only failed nodes' }, + { sub: 'auto-retest', method: 'POST', path: '/api/auto-retest', auth: true, desc: 'Trigger background auto-retest sweep' }, + { sub: 'clear', method: 'POST', path: '/api/clear', auth: true, desc: 'Clear current results buffer' }, + + // Plans & subscriptions + { sub: 'plans', method: 'GET', path: '/api/plans', auth: true, desc: 'List wallet plans' }, + { sub: 'subscriptions', method: 'GET', path: '/api/subscriptions', auth: true, desc: 'List wallet subscriptions' }, + { sub: 'sub-plans', method: 'GET', path: '/api/sub-plans', auth: true, desc: 'List subscription-plan pairings' }, + { sub: 'admin-plans', method: 'GET', path: '/api/admin/plans', auth: true, desc: 'Admin plan inspector' }, + { sub: 'test-plan', method: 'POST', path: '/api/test-plan', auth: true, desc: 'Run a plan-mode audit', + bodyFromFlags: (f) => ({ planId: f['--plan-id'] }) }, + { sub: 'test-sub-plan', method: 'POST', path: '/api/test-sub-plan', auth: true, desc: 'Run a subscription-plan-mode audit', + bodyFromFlags: (f) => ({ subscriptionId: f['--sub-id'], planId: f['--plan-id'], subscriptionGranter: f['--sub-granter'] }) }, + + // Chain queries + { sub: 'chain-nodes', method: 'GET', path: '/api/chain/nodes', auth: true, desc: 'Direct chain node fetch' }, + { sub: 'chain-status', method: 'GET', path: '/api/chain/node-status', auth: true, desc: 'Per-node status snapshot (requires --remote-url https://host:port)', + queryFromFlags: (f) => { + const url = f['--remote-url'] || f['--remoteUrl']; + return url ? { remoteUrl: url } : {}; + } }, + + // Run history + { sub: 'runs', method: 'GET', path: '/api/runs', auth: true, desc: 'List saved audit runs' }, + { sub: 'run-get', method: 'GET', path: '/api/runs/:num', auth: true, desc: 'Read a specific saved run', params: ['num'] }, + { sub: 'run-save', method: 'POST', path: '/api/runs/save', auth: true, desc: 'Save the current results buffer' }, + { sub: 'run-load', method: 'POST', path: '/api/runs/load/:num', auth: true, desc: 'Load run #N back into the buffer', params: ['num'] }, + + // Results & errors + { sub: 'results', method: 'GET', path: '/api/results', auth: true, desc: 'Current results buffer' }, + + // SDK + DNS knobs + { sub: 'sdk-get', method: 'GET', path: '/api/sdk', auth: true, desc: 'Read active SDK selection' }, + { sub: 'sdk-set', method: 'POST', path: '/api/sdk', auth: true, desc: 'Set active SDK', + bodyFromFlags: (f) => ({ sdk: f['--sdk'] }) }, + { sub: 'dns-get', method: 'GET', path: '/api/dns', auth: true, desc: 'Read DNS configuration' }, + { sub: 'dns-set', method: 'POST', path: '/api/dns', auth: true, desc: 'Update DNS configuration', + bodyFromFlags: (f) => { + const b = {}; + if (f['--dns']) b.servers = String(f['--dns']).split(',').map(s => s.trim()).filter(Boolean); + if (f['--enabled'] !== undefined) b.enabled = f['--enabled'] === 'true' || f['--enabled'] === true; + return b; + } }, + + // Streaming + { sub: 'events', method: 'GET', path: '/api/events', auth: true, desc: 'Admin SSE event stream (use --watch )' }, + { sub: 'pub-events', method: 'GET', path: '/api/public/events', auth: false, desc: 'Public SSE event stream (use --watch )' }, +]; + +// ─── Resolution helpers ────────────────────────────────────────────────────── + +function findEndpoint(sub) { + return ENDPOINTS.find(e => e.sub === sub); +} + +// Map :paramName → fallback flag name when positional is omitted +const PARAM_FLAG_FALLBACK = { + addr: '--addr', + num: '--num', +}; + +function fillPath(rawPath, params, positional, flags) { + if (!params || !params.length) return rawPath; + let p = rawPath; + for (let i = 0; i < params.length; i++) { + let tok = positional[i]; + if (!tok && flags) { + const fallbackFlag = PARAM_FLAG_FALLBACK[params[i]]; + if (fallbackFlag && typeof flags[fallbackFlag] === 'string') { + tok = flags[fallbackFlag]; + } + } + if (!tok) { + throw new Error(`Missing positional argument for :${params[i]} in ${rawPath}`); + } + p = p.replace(`:${params[i]}`, encodeURIComponent(tok)); + } + return p; +} + +// ─── Top-level help / map output ───────────────────────────────────────────── + +function printAgentHelp() { + console.log(` + sentinel-audit agent — End-to-end CLI server driver + + Usage: + sentinel-audit agent [args...] [flags] + + Discovery: + agent map Machine-readable endpoint registry (JSON) + agent --help This text + + Common subcommands: + health Public health probe + state Server runtime state (admin) + plans / subscriptions Wallet plans / subs + start [--test-run] [--pricing-mode hours|gigabytes] + stop / resume / rescan + retest-skips / retest-fails + broadcast / broadcast-toggle + pub-nodes / pub-node / pub-node-errors + runs / run-get / run-save / run-load + events --watch 30 Stream admin SSE for 30s + pub-events --watch 30 Stream public SSE for 30s + + Run "agent map --pretty" for the full registry with HTTP method / path / auth. +`); +} + +// ─── SSE streaming ─────────────────────────────────────────────────────────── + +async function streamSse(path, flags) { + const watchSecs = parseInt(flags['--watch'] || '30', 10); + const res = await apiRequest('GET', path, { flags, raw: true, timeoutMs: (watchSecs + 5) * 1000 }); + if (!res.ok || !res.body) { + throw new Error(`SSE ${path} → HTTP ${res.status}`); + } + + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const start = Date.now(); + const events = []; + + while (Date.now() - start < watchSecs * 1000) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + + let idx; + while ((idx = buf.indexOf('\n\n')) !== -1) { + const chunk = buf.slice(0, idx); + buf = buf.slice(idx + 2); + let event = 'message'; + let data = ''; + for (const line of chunk.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data += line.slice(5).trim(); + } + let parsed = data; + try { parsed = JSON.parse(data); } catch {} + const rec = { t: Date.now() - start, event, data: parsed }; + events.push(rec); + // Live tap to stderr so the user sees progress; stdout is reserved for + // the final aggregate JSON. + console.error(JSON.stringify(rec)); + } + } + + try { await reader.cancel(); } catch {} + return { source: path, watchSeconds: watchSecs, events }; +} + +// ─── Runner ────────────────────────────────────────────────────────────────── + +export async function run({ positional, flags: f }) { + const sub = positional[0]; + + if (!sub || sub === 'help') { + printAgentHelp(); + return; + } + + // Discovery: print the full endpoint registry as JSON + if (sub === 'map' || sub === 'list') { + const base = resolveBaseUrl(f); + const tokenSource = f['--token'] ? '--token' + : process.env.SENTINEL_AUDIT_TOKEN ? '$SENTINEL_AUDIT_TOKEN' + : process.env.ADMIN_TOKEN ? '$ADMIN_TOKEN' + : 'none'; + return { + baseUrl: base, + tokenSource, + tokenPresent: Boolean(resolveToken(f)), + total: ENDPOINTS.length, + endpoints: ENDPOINTS.map(e => ({ + sub: e.sub, + method: e.method, + path: e.path, + auth: e.auth, + params: e.params || [], + description: e.desc, + })), + }; + } + + // Streaming subcommands + if (sub === 'events' || sub === 'pub-events') { + const path = sub === 'events' ? '/api/events' : '/api/public/events'; + return await streamSse(path, f); + } + + // Lookup + const ep = findEndpoint(sub); + if (!ep) { + throw new Error(`unknown agent subcommand: "${sub}". Run "agent map --pretty" for the registry.`); + } + + const tail = positional.slice(1); + const path = fillPath(ep.path, ep.params, tail, f); + + // Build query / body + const query = {}; + if (f['--limit']) query.limit = f['--limit']; + if (f['--country']) query.country = f['--country']; + if (ep.queryFromFlags) Object.assign(query, ep.queryFromFlags(f) || {}); + + let body; + if (ep.method !== 'GET' && ep.method !== 'HEAD') { + body = ep.bodyFromFlags ? ep.bodyFromFlags(f) : {}; + } + + const timeoutMs = parseInt(f['--timeout'] || '30', 10) * 1000; + + return await apiRequest(ep.method, path, { flags: f, body, query, timeoutMs }); +} diff --git a/bin/commands/node.js b/bin/commands/node.js index 837df2c..5bbdf75 100644 --- a/bin/commands/node.js +++ b/bin/commands/node.js @@ -9,7 +9,7 @@ import { getRpcClient, ensureLcd, } from '../../core/chain.js'; -import { rpcQueryNode } from 'sentinel-dvpn-sdk'; +import { rpcQueryNode } from 'blue-js-sdk'; // ─── Metadata ──────────────────────────────────────────────────────────────── diff --git a/bin/lib/args.js b/bin/lib/args.js index 3398d3f..c327152 100644 --- a/bin/lib/args.js +++ b/bin/lib/args.js @@ -24,6 +24,11 @@ const ALIASES = { const VALUE_FLAGS = new Set([ '--lcd', '--sdk', '--port', '--wallet', '--output', '--limit', '--max', '--country', '--timeout', '--out', + // agent driver flags + '--base-url', '--token', '--watch', + '--plan-id', '--sub-id', '--sub-granter', + '--pricing-mode', '--addr', '--remote-url', '--num', '--mode', + '--dns', '--enabled', ]); // ─── Parser ─────────────────────────────────────────────────────────────────── diff --git a/bin/lib/http.js b/bin/lib/http.js new file mode 100644 index 0000000..a407d30 --- /dev/null +++ b/bin/lib/http.js @@ -0,0 +1,108 @@ +/** + * Sentinel Node Tester — HTTP client for the agent CLI + * + * Thin wrapper over fetch() that: + * - Resolves base URL from --base-url, SENTINEL_AUDIT_URL, or http://localhost:PORT + * - Adds Bearer auth from --token / SENTINEL_AUDIT_TOKEN / ADMIN_TOKEN + * - Sets X-Admin-Request: 1 on every non-GET (CSRF gate in core/auth.js) + * - Returns parsed JSON, never swallows errors + */ + +export function resolveBaseUrl(flags = {}) { + const fromFlag = flags['--base-url'] || flags['--url']; + if (fromFlag) return String(fromFlag).replace(/\/+$/, ''); + if (process.env.SENTINEL_AUDIT_URL) return process.env.SENTINEL_AUDIT_URL.replace(/\/+$/, ''); + const port = flags['--port'] || process.env.PORT || '3001'; + return `http://localhost:${port}`; +} + +export function resolveToken(flags = {}) { + return ( + flags['--token'] || + process.env.SENTINEL_AUDIT_TOKEN || + process.env.ADMIN_TOKEN || + null + ); +} + +function buildHeaders(method, token, extra = {}) { + const h = { 'Accept': 'application/json', ...extra }; + if (method && method !== 'GET' && method !== 'HEAD') { + h['Content-Type'] = 'application/json'; + h['X-Admin-Request'] = '1'; + } + if (token) h['Authorization'] = `Bearer ${token}`; + return h; +} + +/** + * @param {string} method - GET/POST/DELETE etc + * @param {string} pathOrUrl - "/api/state" or full URL + * @param {object} opts + * - flags: parsed CLI flags (used for base url + token) + * - body: request body (will be JSON.stringify'd) + * - query: object of query string params + * - timeoutMs: abort after N ms (default 30s) + * - raw: if true, return Response untouched + */ +export async function apiRequest(method, pathOrUrl, opts = {}) { + const { flags = {}, body, query, timeoutMs = 30_000, raw = false } = opts; + const base = resolveBaseUrl(flags); + const token = resolveToken(flags); + + let url = pathOrUrl.startsWith('http') ? pathOrUrl : `${base}${pathOrUrl}`; + if (query && typeof query === 'object') { + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(query)) { + if (v === undefined || v === null) continue; + usp.append(k, String(v)); + } + const qs = usp.toString(); + if (qs) url += (url.includes('?') ? '&' : '?') + qs; + } + + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + + let res; + try { + res = await fetch(url, { + method, + headers: buildHeaders(method, token), + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: ac.signal, + }); + } catch (err) { + clearTimeout(timer); + throw new Error(`HTTP ${method} ${url} failed: ${err.message || err}`); + } + clearTimeout(timer); + + if (raw) return res; + + const ct = res.headers.get('content-type') || ''; + let payload; + if (ct.includes('application/json')) { + payload = await res.json().catch(() => null); + } else { + payload = await res.text().catch(() => ''); + } + + if (!res.ok) { + const msg = (payload && typeof payload === 'object' && payload.error) + ? payload.error + : (typeof payload === 'string' ? payload.slice(0, 240) : `HTTP ${res.status}`); + const e = new Error(`${method} ${url} → ${res.status} ${msg}`); + e.status = res.status; + e.payload = payload; + throw e; + } + + return payload; +} + +export const api = { + get: (p, o = {}) => apiRequest('GET', p, o), + post: (p, body, o = {}) => apiRequest('POST', p, { ...o, body }), + del: (p, o = {}) => apiRequest('DELETE', p, o), +}; diff --git a/core/chain.js b/core/chain.js index a4ab502..0cdbe28 100644 --- a/core/chain.js +++ b/core/chain.js @@ -15,6 +15,7 @@ import { LCD_ENDPOINTS as SDK_LCD_ENDPOINTS, createRpcQueryClientWithFallback, rpcQueryNode, + rpcQueryNodes, rpcQueryNodesForPlan, rpcQueryPlan, rpcQuerySubscriptionsForAccount, @@ -23,7 +24,7 @@ import { queryFeeGrant, broadcastWithFeeGrant as sdkBroadcastWithFeeGrant, disconnectRpc, -} from 'sentinel-dvpn-sdk'; +} from 'blue-js-sdk'; // ─── LCD Endpoint Management ──────────────────────────────────────────────── const LCD_LIST = SDK_LCD_ENDPOINTS.map(e => e.url); @@ -85,43 +86,15 @@ export function cleanupRpc() { // ─── Node List (RPC primary, LCD fallback) ───────────────────────────────── /** - * Fetch all active nodes via RPC with raw ABCI pagination. - * The chain caps ABCI queries at 100 per page, so we loop with next_key. + * Wrapper around SDK's rpcQueryNodes with broadcast logging. * Returns null if RPC is unavailable (signals LCD fallback). */ async function rpcFetchAllNodes(broadcast) { const client = await getRpcClient(); - if (!client) return null; // signal LCD fallback - return rpcFetchAllNodesPaginated(client, broadcast); -} - -/** - * Raw ABCI paginated fetch for all active nodes. - * Loops with next_key until all pages are fetched. - * - * Cosmos PageRequest proto fields: - * 1 = key (bytes), 2 = offset (uint64), 3 = limit (uint64), - * 4 = count_total (bool), 5 = reverse (bool) - * NOTE: The SDK's encodePagination has a bug — it puts limit at field 2 (offset). - * We use correct field numbers here. - */ -async function rpcFetchAllNodesPaginated(client, broadcast) { - // Sentinel v3 chain truncates at `limit` without emitting `next_key`. - // A single large request returns the full set (chain has its own hard ceiling). - const PAGE_SIZE = 10000; + if (!client) return null; try { - const pagination = encodeRpcVarintField(3, PAGE_SIZE); // limit at field 3 - const request = concatBytes([ - encodeRpcVarintField(1, 1), // status = active - encodeRpcEmbedded(2, pagination), // pagination - ]); - const result = await client.queryClient.queryAbci( - '/sentinel.node.v3.QueryService/QueryNodes', - request, - ); - const fields = decodeRpcProto(new Uint8Array(result.value)); - const nodes = (fields[1] || []).map(entry => decodeRpcNode(decodeRpcProto(entry.value))); - if (broadcast) broadcast('log', { msg: ` RPC: ${nodes.length} nodes fetched (limit=${PAGE_SIZE})` }); + const nodes = await rpcQueryNodes(client, { status: 1, limit: 10000 }); + if (broadcast) broadcast('log', { msg: ` RPC: ${nodes.length} nodes fetched` }); return nodes; } catch (err) { if (broadcast) broadcast('log', { msg: ` RPC fetch failed: ${err.message}` }); @@ -130,37 +103,18 @@ async function rpcFetchAllNodesPaginated(client, broadcast) { } /** - * Fetch ALL nodes linked to a plan via RPC with raw ABCI pagination. - * Chain caps ABCI queries at 100 per page; we loop with next_key until done. - * - * QueryNodesForPlanRequest proto: - * 1 = id (uint64 plan id), 2 = status (enum), 3 = pagination (PageRequest) - * PageRequest fields: 1=key, 2=offset, 3=limit, 4=count_total, 5=reverse + * Wrapper around SDK's rpcQueryNodesForPlan with broadcast logging. + * Returns [] on failure (caller falls back to LCD). * * @param {{ queryClient }} client * @param {number|string|bigint} planId * @param {(channel:string, data:any)=>void} [broadcast] - * @returns {Promise>} — empty array on failure + * @returns {Promise>} */ export async function rpcFetchAllNodesForPlanPaginated(client, planId, broadcast) { - // Sentinel v3 chain truncates at `limit` without emitting `next_key`. - // A single large request returns the full set (observed: plan 36 → 803 nodes - // with limit=1000 but only 100 with limit=100, and no next_key either time). - const PAGE_SIZE = 10000; try { - const pagination = encodeRpcVarintField(3, PAGE_SIZE); // limit at field 3 - const request = concatBytes([ - encodeRpcVarintField(1, BigInt(planId)), // plan id - encodeRpcVarintField(2, 1), // status = active - encodeRpcEmbedded(3, pagination), // pagination - ]); - const result = await client.queryClient.queryAbci( - '/sentinel.node.v3.QueryService/QueryNodesForPlan', - request, - ); - const fields = decodeRpcProto(new Uint8Array(result.value)); - const nodes = (fields[1] || []).map(entry => decodeRpcNode(decodeRpcProto(entry.value))); - if (broadcast) broadcast('log', { msg: ` RPC plan ${planId}: ${nodes.length} nodes (limit=${PAGE_SIZE})` }); + const nodes = await rpcQueryNodesForPlan(client, BigInt(planId), { status: 1, limit: 10000 }); + if (broadcast) broadcast('log', { msg: ` RPC plan ${planId}: ${nodes.length} nodes` }); return nodes; } catch (err) { if (broadcast) broadcast('log', { msg: ` RPC plan ${planId} fetch failed: ${err.message}` }); @@ -169,7 +123,8 @@ export async function rpcFetchAllNodesForPlanPaginated(client, planId, broadcast } // ─── Minimal Protobuf Helpers (for raw ABCI pagination) ──────────────────── -// Exported so session.js can reuse for RPC session queries. +// Used by discoverPlans, queryPlanOwnerSent, queryFeeGrantRpcFirst, session.js, +// and the scripts/probe-*.mjs tooling. export function encodeRpcVarint(value) { let n = BigInt(value); @@ -258,24 +213,6 @@ export function decodeRpcString(data) { return new TextDecoder().decode(data); } -function decodeRpcPrice(fields) { - return { - denom: fields[1]?.[0] ? decodeRpcString(fields[1][0].value) : '', - base_value: fields[2]?.[0] ? decodeRpcString(fields[2][0].value) : '0', - quote_value: fields[3]?.[0] ? decodeRpcString(fields[3][0].value) : '0', - }; -} - -function decodeRpcNode(fields) { - return { - address: fields[1]?.[0] ? decodeRpcString(fields[1][0].value) : '', - gigabyte_prices: (fields[2] || []).map(f => decodeRpcPrice(decodeRpcProto(f.value))), - hourly_prices: (fields[3] || []).map(f => decodeRpcPrice(decodeRpcProto(f.value))), - remote_addrs: (fields[4] || []).map(f => decodeRpcString(f.value)), - status: fields[6]?.[0] ? Number(fields[6][0].value) : 0, - }; -} - // ─── Node List (RPC primary → LCD fallback) ──────────────────────────────── /** @@ -648,17 +585,20 @@ function isActiveStatus(status) { } export async function querySubscriptions(walletAddress) { - // RPC-first: rpcQuerySubscriptionsForAccount returns raw Any-bytes per sub. - // Decode each entry to extract plan_id and subscription_id via _decodePlanSubscription. + // RPC-first for direct subs (fast). RPC's QuerySubscriptionsForAccount does + // NOT return shared-plan allocations (e.g. plan 36 where wallet is allocatee + // not acc_address) — those only appear via LCD. So we always do an LCD pass + // afterwards and merge any IDs RPC missed. + const rpcOut = []; try { const rpcClient = await getRpcClient(); if (rpcClient) { - const rawEntries = await rpcQuerySubscriptionsForAccount(rpcClient, walletAddress); + // Bug fix: SDK default limit is 100 — wallets with long sub history (older + // expired subs still iterated by chain) silently drop newer subscriptions. + const rawEntries = await rpcQuerySubscriptionsForAccount(rpcClient, walletAddress, { limit: 5000 }); if (rawEntries && rawEntries.length > 0) { - const out = []; for (const entry of rawEntries) { try { - // Each entry may be an Any-wrapped bytes blob or already-decoded object const bytes = entry instanceof Uint8Array ? entry : entry.value instanceof Uint8Array ? entry.value : null; @@ -666,24 +606,25 @@ export async function querySubscriptions(walletAddress) { const anyDecoded = _decodeAny(bytes); if (!anyDecoded.valueBytes) continue; const sub = _decodePlanSubscription(anyDecoded.valueBytes); - if (!sub.planId) continue; // not a plan subscription - out.push({ + if (!sub.planId) continue; + rpcOut.push({ id: sub.subscriptionId, plan_id: sub.planId, - status: 'STATUS_ACTIVE', // RPC only returns active subs when status=1 + status: 'STATUS_ACTIVE', expiry: null, + ownerAddress: walletAddress, // RPC only returns direct subs + viaAllocation: false, + grantedBytes: null, }); - } catch { /* skip malformed entries */ } + } catch { /* skip malformed */ } } - if (out.length > 0) return out; - // RPC returned entries but none decoded as plan subs — fall through to LCD } } } catch (rpcErr) { console.warn('[querySubscriptions] RPC failed, falling back to LCD:', rpcErr.message); } - // LCD fallback: direct query with ?status=1 so chain pre-filters at source. + // LCD pass: direct query with ?status=1 so chain pre-filters at source. // Also filter client-side with isActiveStatus() to handle both chain v2 // ("active") and chain v3 ("STATUS_ACTIVE") response formats. const lcd = await ensureLcd(); @@ -695,17 +636,54 @@ export async function querySubscriptions(walletAddress) { if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); const raw = Array.isArray(data.subscriptions) ? data.subscriptions : []; - return raw - .filter(s => s.acc_address === walletAddress) - .filter(s => isActiveStatus(s.status)) - .map(s => ({ - id: s.id, + // /accounts/{addr}/subscriptions returns BOTH direct subs and subs where + // addr is an allocatee on a shared plan. For non-direct subs, verify a + // non-zero allocation via v2 /subscriptions/{id}/allocations. + const active = raw.filter(s => isActiveStatus(s.status)); + const haveIds = new Set(rpcOut.map(s => String(s.id))); + const out = [...rpcOut]; + for (const s of active) { + const isOwner = s.acc_address === walletAddress; + const sid = String(s.id); + // Enrich expiry on RPC entries + if (haveIds.has(sid)) { + const existing = out.find(x => String(x.id) === sid); + if (existing && !existing.expiry) existing.expiry = s.inactive_at || null; + if (existing && s.acc_address) existing.ownerAddress = s.acc_address; + continue; + } + let viaAllocation = false; + let grantedBytes = null; + if (!isOwner) { + try { + const ar = await fetch( + `${lcd}/sentinel/subscription/v2/subscriptions/${sid}/allocations?pagination.limit=500`, + { signal: AbortSignal.timeout(10_000) }, + ); + if (ar.ok) { + const aj = await ar.json(); + const mine = (aj.allocations || []).find(a => a.address === walletAddress); + if (mine && mine.granted_bytes && mine.granted_bytes !== '0') { + viaAllocation = true; + grantedBytes = mine.granted_bytes; + } + } + } catch { /* skip */ } + if (!viaAllocation) continue; + } + out.push({ + id: sid, plan_id: s.plan_id, status: s.status, expiry: s.inactive_at || null, - })); + ownerAddress: s.acc_address, + viaAllocation, + grantedBytes, + }); + } + return out; } catch { - return []; + return rpcOut; } } @@ -848,6 +826,9 @@ export async function querySubscriberPlansEnriched(walletAddress) { feeGrantActive, feeGrantCheckFailed, nodeCount, + viaAllocation: !!s.viaAllocation, + grantedBytes: s.grantedBytes || null, + subOwnerAddress: s.ownerAddress || null, }); } return results; @@ -893,14 +874,60 @@ export async function queryFeeGrantRpcFirst(client, lcd, granter, grantee) { request, ); if (result?.value && result.value.length > 0) { - // Successfully got a response — the allowance exists. - // Decode the response: field 1 = Grant proto (embedded) - const fields = decodeRpcProto(new Uint8Array(result.value)); - if (fields[1]?.[0]) { - // Minimal: return a truthy object indicating active grant. - // Full decode would require the Grant proto schema; enough to signal "exists". - return { exists: true, _rpcSource: true }; + // QueryAllowanceResponse { Grant allowance = 1 } + // Grant { string granter = 1; string grantee = 2; Any allowance = 3 } + // Any { string type_url = 1; bytes value = 2 } + const top = decodeRpcProto(new Uint8Array(result.value)); + const grantBytes = top[1]?.[0]?.value; + if (!grantBytes) return null; + const grant = decodeRpcProto(grantBytes); + const anyBytes = grant[3]?.[0]?.value; + if (!anyBytes) return { exists: true, _rpcSource: true }; + const anyFields = decodeRpcProto(anyBytes); + const typeUrl = anyFields[1]?.[0]?.value ? decodeRpcString(anyFields[1][0].value) : null; + const innerBytes = anyFields[2]?.[0]?.value; + + // Walk through wrapping allowances (PeriodicAllowance/AllowedMsgAllowance) to + // reach the BasicAllowance that carries spend_limit. + let outerType = typeUrl; + let basicBytes = innerBytes; + for (let depth = 0; depth < 3 && basicBytes; depth++) { + if (outerType?.endsWith('BasicAllowance')) break; + const wrap = decodeRpcProto(basicBytes); + // PeriodicAllowance.basic = 1 (BasicAllowance) | AllowedMsgAllowance.allowance = 1 (Any) + const innerAnyBytes = wrap[1]?.[0]?.value; + if (!innerAnyBytes) break; + if (outerType?.endsWith('PeriodicAllowance')) { + outerType = '/cosmos.feegrant.v1beta1.BasicAllowance'; + basicBytes = innerAnyBytes; + break; + } + // AllowedMsgAllowance: inner is Any + const innerAny = decodeRpcProto(innerAnyBytes); + outerType = innerAny[1]?.[0]?.value ? decodeRpcString(innerAny[1][0].value) : null; + basicBytes = innerAny[2]?.[0]?.value; + } + + // BasicAllowance { repeated Coin spend_limit = 1; Timestamp expiration = 2 } + // Coin { string denom = 1; string amount = 2 } + const spend_limit = []; + if (outerType?.endsWith('BasicAllowance') && basicBytes) { + const basic = decodeRpcProto(basicBytes); + for (const coinEntry of (basic[1] || [])) { + const coin = decodeRpcProto(coinEntry.value); + spend_limit.push({ + denom: coin[1]?.[0]?.value ? decodeRpcString(coin[1][0].value) : '', + amount: coin[2]?.[0]?.value ? decodeRpcString(coin[2][0].value) : '0', + }); + } } + + return { + exists: true, + _rpcSource: true, + '@type': typeUrl, + spend_limit: spend_limit.length ? spend_limit : null, + }; } // Empty response = no grant found return null; diff --git a/core/constants.js b/core/constants.js index 6292168..2d7bf2d 100644 --- a/core/constants.js +++ b/core/constants.js @@ -23,9 +23,12 @@ export const LOW_BALANCE_WARN = 500_000; // 0.5 P2P export const PORT = parseInt(process.env.PORT || '3001', 10); // ─── DNS Configuration ────────────────────────────────────────────────────── -// Default: HNS (Handshake) — decentralized DNS, 0.01% error rate across 9,298 tests +// Default: HNS (Handshake) — decentralized DNS, 0.01% error rate across 9,298 tests. +// SDK uses key `handshake`; tester uses `hns` historically — both keys resolve to the same servers. +const _hns = ['103.196.38.38', '103.196.38.39']; export const DNS_PRESETS = { - hns: ['103.196.38.38', '103.196.38.39'], + hns: _hns, + handshake: _hns, google: ['8.8.8.8', '8.8.4.4'], cloudflare: ['1.1.1.1', '1.0.0.1'], quad9: ['9.9.9.9', '149.112.112.112'], @@ -36,7 +39,7 @@ if (ACTIVE_DNS.length === 0) ACTIVE_DNS = DNS_PRESETS.hns; export function setActiveDns(servers) { ACTIVE_DNS = servers; } // ─── Protocol Message Types (from SDK — single source of truth) ───────────── -import { MSG_TYPES } from 'sentinel-dvpn-sdk'; +import { MSG_TYPES } from 'blue-js-sdk'; export { MSG_TYPES }; export const V3_MSG_TYPE = MSG_TYPES.START_SESSION; export const V3_SUB_TYPE = MSG_TYPES.START_SUBSCRIPTION; diff --git a/core/countries.js b/core/countries.js index 4d103a7..3c62306 100644 --- a/core/countries.js +++ b/core/countries.js @@ -1,65 +1,31 @@ /** - * Country/Flag helpers — copied from Sentinel SDK js-sdk/app-helpers.js - * 80+ countries confirmed on the Sentinel network as of 2026-03. + * Country/Flag helpers — base map from Blue JS SDK, layered with tester-only + * extras (Central Asia + Balkans) that should be upstreamed. */ -// ─── Country Name → ISO Code Map ──────────────────────────────────────────── +import { + COUNTRY_MAP as SDK_COUNTRY_MAP, + countryNameToCode as sdkCountryNameToCode, + getFlagUrl as sdkGetFlagUrl, + getFlagEmoji as sdkGetFlagEmoji, +} from 'blue-js-sdk'; -export const COUNTRY_MAP = Object.freeze({ - // Standard names - 'united states': 'US', 'germany': 'DE', 'france': 'FR', 'united kingdom': 'GB', - 'netherlands': 'NL', 'canada': 'CA', 'japan': 'JP', 'singapore': 'SG', - 'australia': 'AU', 'brazil': 'BR', 'india': 'IN', 'south korea': 'KR', - 'turkey': 'TR', 'romania': 'RO', 'poland': 'PL', 'spain': 'ES', - 'italy': 'IT', 'sweden': 'SE', 'norway': 'NO', 'finland': 'FI', - 'switzerland': 'CH', 'austria': 'AT', 'ireland': 'IE', 'portugal': 'PT', - 'czech republic': 'CZ', 'hungary': 'HU', 'bulgaria': 'BG', 'greece': 'GR', - 'ukraine': 'UA', 'russia': 'RU', 'hong kong': 'HK', 'taiwan': 'TW', - 'thailand': 'TH', 'vietnam': 'VN', 'indonesia': 'ID', 'philippines': 'PH', - 'mexico': 'MX', 'argentina': 'AR', 'chile': 'CL', 'colombia': 'CO', - 'south africa': 'ZA', 'israel': 'IL', 'united arab emirates': 'AE', - 'nigeria': 'NG', 'latvia': 'LV', 'lithuania': 'LT', 'estonia': 'EE', - 'croatia': 'HR', 'serbia': 'RS', 'denmark': 'DK', 'belgium': 'BE', - 'luxembourg': 'LU', 'malta': 'MT', 'cyprus': 'CY', 'iceland': 'IS', - 'new zealand': 'NZ', 'malaysia': 'MY', 'bangladesh': 'BD', 'pakistan': 'PK', - 'egypt': 'EG', 'kenya': 'KE', 'morocco': 'MA', 'peru': 'PE', - 'venezuela': 'VE', 'georgia': 'GE', 'guatemala': 'GT', 'puerto rico': 'PR', - 'china': 'CN', 'saudi arabia': 'SA', 'kazakhstan': 'KZ', 'mongolia': 'MN', - 'slovakia': 'SK', 'albania': 'AL', 'moldova': 'MD', 'jamaica': 'JM', - 'bolivia': 'BO', 'ecuador': 'EC', 'uruguay': 'UY', 'bahrain': 'BH', - 'dr congo': 'CD', 'costa rica': 'CR', 'panama': 'PA', 'paraguay': 'PY', - 'dominican republic': 'DO', 'el salvador': 'SV', 'honduras': 'HN', - 'nicaragua': 'NI', 'cuba': 'CU', 'haiti': 'HT', 'trinidad and tobago': 'TT', +// ─── Country Name → ISO Code Map ──────────────────────────────────────────── +// Tester-only extras not yet in SDK COUNTRY_MAP. PR candidate upstream. +const TESTER_COUNTRY_EXTRAS = { 'kyrgyzstan': 'KG', 'uzbekistan': 'UZ', 'tajikistan': 'TJ', 'bosnia and herzegovina': 'BA', 'north macedonia': 'MK', 'montenegro': 'ME', 'kosovo': 'XK', 'slovenia': 'SI', + // Short codes for the extras above + 'kg': 'KG', 'uz': 'UZ', 'tj': 'TJ', +}; - // Variant names the chain returns - 'the netherlands': 'NL', 'türkiye': 'TR', 'turkiye': 'TR', 'czechia': 'CZ', - 'russian federation': 'RU', 'viet nam': 'VN', 'korea': 'KR', - 'republic of korea': 'KR', 'uae': 'AE', 'uk': 'GB', 'usa': 'US', - 'democratic republic of the congo': 'CD', 'congo': 'CD', - - // Short codes (some nodes return these) - 'us': 'US', 'de': 'DE', 'fr': 'FR', 'gb': 'GB', 'nl': 'NL', 'ca': 'CA', - 'jp': 'JP', 'sg': 'SG', 'au': 'AU', 'br': 'BR', 'in': 'IN', 'kr': 'KR', - 'tr': 'TR', 'ro': 'RO', 'pl': 'PL', 'es': 'ES', 'it': 'IT', 'se': 'SE', - 'no': 'NO', 'fi': 'FI', 'ch': 'CH', 'at': 'AT', 'ie': 'IE', 'pt': 'PT', - 'cz': 'CZ', 'hu': 'HU', 'bg': 'BG', 'gr': 'GR', 'ua': 'UA', 'ru': 'RU', - 'hk': 'HK', 'tw': 'TW', 'th': 'TH', 'vn': 'VN', 'id': 'ID', 'ph': 'PH', - 'mx': 'MX', 'ar': 'AR', 'cl': 'CL', 'co': 'CO', 'za': 'ZA', 'il': 'IL', - 'ae': 'AE', 'ng': 'NG', 'lv': 'LV', 'lt': 'LT', 'ee': 'EE', 'hr': 'HR', - 'rs': 'RS', 'dk': 'DK', 'be': 'BE', 'lu': 'LU', 'mt': 'MT', 'cy': 'CY', - 'is': 'IS', 'nz': 'NZ', 'my': 'MY', 'bd': 'BD', 'pk': 'PK', 'eg': 'EG', - 'ke': 'KE', 'ma': 'MA', 'pe': 'PE', 've': 'VE', 'ge': 'GE', 'gt': 'GT', - 'pr': 'PR', 'cn': 'CN', 'sa': 'SA', 'kz': 'KZ', 'mn': 'MN', 'sk': 'SK', - 'al': 'AL', 'md': 'MD', 'jm': 'JM', 'bo': 'BO', 'ec': 'EC', 'uy': 'UY', - 'bh': 'BH', 'cd': 'CD', 'kg': 'KG', 'uz': 'UZ', 'tj': 'TJ', -}); +export const COUNTRY_MAP = Object.freeze({ ...SDK_COUNTRY_MAP, ...TESTER_COUNTRY_EXTRAS }); /** * Convert a country name to ISO 3166-1 alpha-2 code. * Handles standard names, chain variants, and short codes. + * Uses tester's superset map (SDK + extras) — falls through to SDK on miss. */ export function countryNameToCode(name) { if (!name) return null; @@ -70,25 +36,11 @@ export function countryNameToCode(name) { for (const [key, code] of Object.entries(COUNTRY_MAP)) { if (key.length > 2 && (lower.includes(key) || key.includes(lower))) return code; } - return null; + return sdkCountryNameToCode(name); } -/** - * Get flag image URL from flagcdn.com (for native apps where emoji doesn't render). - */ -export function getFlagUrl(code, width = 40) { - if (!code || code.length !== 2) return ''; - return `https://flagcdn.com/w${width}/${code.toLowerCase()}.png`; -} - -/** - * Get emoji flag for a country code (for web/browser). - */ -export function getFlagEmoji(code) { - if (!code || code.length !== 2) return ''; - const upper = code.toUpperCase(); - return String.fromCodePoint(upper.charCodeAt(0) + 0x1F1A5, upper.charCodeAt(1) + 0x1F1A5); -} +export const getFlagUrl = sdkGetFlagUrl; +export const getFlagEmoji = sdkGetFlagEmoji; // ─── Country Code → Continent Map ────────────────────────────────────────── diff --git a/core/db.js b/core/db.js index 3729146..c4c8c48 100644 --- a/core/db.js +++ b/core/db.js @@ -274,7 +274,8 @@ function runMigrations(db) { if (current < 7) { db.transaction(() => { - // ── Migration v7: rename legacy runs.mode='dry' to 'test' ──────────────── + // ── Migration v7: rename run mode 'dry' → 'test' ──────────────────────── + // Idempotent: WHERE mode='dry' matches nothing if already migrated. db.exec(`UPDATE runs SET mode='test' WHERE mode='dry'`); db.prepare('UPDATE schema_version SET version = 7').run(); })(); @@ -580,7 +581,7 @@ export function getNetworkStats(which) { const db = getDb(which); // Latest result per node (MAX(id) tiebreaker for equal tested_at). - // No mode filter needed: real and dry data live in separate DB files. + // No mode filter needed: real and test data live in separate DB files. const rows = db.prepare(` SELECT r.actual_mbps, r.session_ok FROM results r @@ -1012,8 +1013,7 @@ export function searchErrors({ q = null, stage = null, limit = 100, offset = 0 } el.error_message, el.log_snippet, el.captured_at, - r.run_id, - r.iteration + r.run_id FROM error_logs el INNER JOIN results r ON el.result_id = r.id ${where} diff --git a/core/errors.js b/core/errors.js index caab52e..f6d14cd 100644 --- a/core/errors.js +++ b/core/errors.js @@ -10,7 +10,7 @@ import { SentinelError, ValidationError, NodeError, ChainError as SdkChainError, TunnelError as SdkTunnelError, SecurityError, ErrorCodes, ERROR_SEVERITY, isRetryable, userMessage, -} from 'sentinel-dvpn-sdk'; +} from 'blue-js-sdk'; // Re-export SDK error utilities for consumers export { ErrorCodes, ERROR_SEVERITY, isRetryable, userMessage, SentinelError, ValidationError, NodeError, SecurityError }; diff --git a/core/sdk-verify.js b/core/sdk-verify.js index bc34eae..7c53c4f 100644 --- a/core/sdk-verify.js +++ b/core/sdk-verify.js @@ -1,7 +1,7 @@ /** * Sentinel Node Tester — SDK Verification * - * Verifies that the installed copies of `sentinel-dvpn-sdk` (Blue JS) and + * Verifies that the installed copies of `blue-js-sdk` (Blue JS) and * `@sentinel-official/sentinel-js-sdk` (TKD) match the published GitHub * source at their current tag. * @@ -34,7 +34,7 @@ import { Readable } from 'stream'; // ─── Config ────────────────────────────────────────────────────────────────── const TRACKED_SDKS = [ - { key: 'blue-js', pkg: 'sentinel-dvpn-sdk' }, + { key: 'blue-js', pkg: 'blue-js-sdk' }, { key: 'tkd-js', pkg: '@sentinel-official/sentinel-js-sdk' }, ]; diff --git a/core/session.js b/core/session.js index ea1bce8..4891b91 100644 --- a/core/session.js +++ b/core/session.js @@ -7,46 +7,44 @@ * credential cache (local disk), session map (audit TTL), dedup guard. */ -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { DENOM, GIGS, CREDS_FILE, SESSION_MAP_TTL, V3_MSG_TYPE } from './constants.js'; +import { DENOM, GIGS, SESSION_MAP_TTL, V3_MSG_TYPE } from './constants.js'; import { signAndBroadcastRetry, assertIsDeliverTxSuccess } from './wallet.js'; -import { - getActiveLcd, getRpcClient, - encodeRpcVarint, encodeRpcVarintField, encodeRpcBytes, encodeRpcEmbedded, - concatBytes, decodeRpcProto, decodeRpcString, -} from './chain.js'; +import { getActiveLcd, getRpcClient } from './chain.js'; import { sleep } from '../protocol/speedtest.js'; import { extractAllSessionIds as sdkExtractAllSessionIds, - markSessionPoisoned as sdkMarkPoisoned, - isSessionPoisoned as sdkIsPoisoned, - querySessions as sdkQuerySessions, - querySessionAllocation as sdkQueryAllocation, -} from 'sentinel-dvpn-sdk'; - -// ─── Session Credential Cache (disk-persistent) ───────────────────────────── -let credentialCache = {}; -if (existsSync(CREDS_FILE)) { - try { credentialCache = JSON.parse(readFileSync(CREDS_FILE, 'utf8')); } catch { credentialCache = {}; } -} + rpcQuerySessionsForAccount, + rpcQuerySession as sdkRpcQuerySession, + saveCredentials as sdkSaveCredentials, + loadCredentials as sdkLoadCredentials, + clearCredentials as sdkClearCredentials, + clearAllCredentials as sdkClearAllCredentials, +} from 'blue-js-sdk'; + +// ─── Session Credential Cache (SDK encrypted store: ~/.sentinel-sdk/) ────── +// Tester API stays { saveCredential, getCredential, clearCredential, clearAllCredentials } +// — call sites pass a flat object containing `sessionId` + credential fields. We unpack +// sessionId here and delegate to SDK's encrypted, pruned, sessionId-keyed credential store. export function saveCredential(nodeAddr, data) { - credentialCache[nodeAddr] = { ...data, savedAt: new Date().toISOString() }; - writeFileSync(CREDS_FILE, JSON.stringify(credentialCache, null, 2), 'utf8'); + const { sessionId, ...creds } = data || {}; + sdkSaveCredentials(nodeAddr, String(sessionId || ''), creds); } export function getCredential(nodeAddr) { - return credentialCache[nodeAddr] || null; + const stored = sdkLoadCredentials(nodeAddr); + if (!stored) return null; + // Caller-side accesses .sessionId / .type / .uuid / .wgPrivateKey / etc. flat. + // SDK already returns flat shape with sessionId at top level — pass through. + return stored; } export function clearCredential(nodeAddr) { - delete credentialCache[nodeAddr]; - writeFileSync(CREDS_FILE, JSON.stringify(credentialCache, null, 2), 'utf8'); + sdkClearCredentials(nodeAddr); } export function clearAllCredentials() { - credentialCache = {}; - writeFileSync(CREDS_FILE, '{}', 'utf8'); + sdkClearAllCredentials(); } // ─── Session Reuse Map ────────────────────────────────────────────────────── @@ -92,93 +90,18 @@ export function addToSessionMap(nodeAddr, sessionId) { sessionMap.set(nodeAddr, { sessionId, maxBytes: GIGS * 1_000_000_000, usedBytes: 0 }); } -// ─── RPC Session Decoder ──────────────────────────────────────────────────── -// BaseSession proto fields (sentinel.session.v3.BaseSession): -// 1=id(uint64), 2=acc_address(string), 3=node_address(string), -// 4=download_bytes(string), 5=upload_bytes(string), 6=max_bytes(string), -// 7=duration, 8=max_duration, 9=status(enum), 10=inactive_at, 11=start_at, 12=status_at -// Session wrappers: field 1 = base_session (embedded) - -function decodeRpcBaseSession(buf) { - const f = decodeRpcProto(buf); - return { - id: f[1]?.[0] ? f[1][0].value : 0n, - acc_address: f[2]?.[0] ? decodeRpcString(f[2][0].value) : '', - node_address: f[3]?.[0] ? decodeRpcString(f[3][0].value) : '', - download_bytes: f[4]?.[0] ? decodeRpcString(f[4][0].value) : '0', - upload_bytes: f[5]?.[0] ? decodeRpcString(f[5][0].value) : '0', - max_bytes: f[6]?.[0] ? decodeRpcString(f[6][0].value) : '0', - status: f[9]?.[0] ? Number(f[9][0].value) : 0, - }; -} - -function decodeRpcSession(anyBuf) { - // Unwrap google.protobuf.Any: field 1 = type_url, field 2 = value - const anyFields = decodeRpcProto(anyBuf); - const innerBuf = anyFields[2]?.[0]?.value; - if (!innerBuf) return null; - // Session wrapper: field 1 = base_session (embedded) - const sessionFields = decodeRpcProto(innerBuf); - if (!sessionFields[1]?.[0]) return null; - return decodeRpcBaseSession(sessionFields[1][0].value); -} +// ─── RPC Session Fetch (delegates to SDK) ────────────────────────────────── +// SDK's rpcQuerySessionsForAccount handles ABCI encoding + Any-unwrapping + +// BaseSession decoding. Sentinel v3 truncates at `limit` without emitting +// next_key, so a single large request returns the full set. -/** - * Fetch sessions for account via RPC (ABCI query) with pagination. - * Returns array of decoded session objects, or null if RPC unavailable. - */ async function rpcFetchAllSessions(walletAddress) { const client = await getRpcClient(); if (!client) return null; - - const allSessions = []; - let nextKeyBytes = null; - let page = 0; - const PAGE_SIZE = 100; - const MAX_PAGES = 20; - try { - do { - const paginationParts = []; - if (nextKeyBytes) { - paginationParts.push(encodeRpcBytes(1, nextKeyBytes)); - } - paginationParts.push(encodeRpcVarintField(3, PAGE_SIZE)); - - const request = concatBytes([ - encodeRpcBytes(1, new TextEncoder().encode(walletAddress)), // address (string = field 1) - encodeRpcEmbedded(2, concatBytes(paginationParts)), // pagination - ]); - - const result = await client.queryClient.queryAbci( - '/sentinel.session.v3.QueryService/QuerySessionsForAccount', - request, - ); - const resp = new Uint8Array(result.value); - if (resp.length <= 2) break; // empty pagination-only response - - const fields = decodeRpcProto(resp); - - // Field 1 = repeated google.protobuf.Any (sessions) - const sessions = (fields[1] || []).map(entry => decodeRpcSession(entry.value)).filter(Boolean); - allSessions.push(...sessions); - page++; - - // Extract pagination response (field 2) for next_key - nextKeyBytes = null; - if (fields[2]?.[0]) { - const pagResp = decodeRpcProto(fields[2][0].value); - if (pagResp[1]?.[0]?.value?.length > 0) { - nextKeyBytes = pagResp[1][0].value; - } - } - - if (sessions.length < PAGE_SIZE) break; - } while (nextKeyBytes && page < MAX_PAGES); - - return allSessions; + return await rpcQuerySessionsForAccount(client, walletAddress, { limit: 2000 }); } catch { - return null; // signal LCD fallback + return null; } } @@ -333,6 +256,9 @@ export function extractSessionMap(txResult, nodeAddrs) { * Returns Map for all NEW sessions. */ export async function submitBatchPayment(client, account, denom, gigabytes, batch, state, broadcast) { + const pricingMode = state?.pricingMode === 'hours' ? 'hours' : 'gigabytes'; + const sessionGigabytes = pricingMode === 'hours' ? 0 : gigabytes; + const sessionHours = pricingMode === 'hours' ? 1 : 0; const result = new Map(); const reusedAddrs = new Set(); const toPayBatch = []; @@ -341,15 +267,20 @@ export async function submitBatchPayment(client, account, denom, gigabytes, batc if (broadcast) broadcast('log', { msg: ` ⏭ Skip ${node.address.slice(0, 20)}… — already paid this run` }); continue; } - const priceEntry = (node.gigabyte_prices || []).find(p => p.denom === denom); - if (priceEntry) toPayBatch.push({ node, priceEntry }); + const priceList = pricingMode === 'hours' ? (node.hourly_prices || []) : (node.gigabyte_prices || []); + const priceEntry = priceList.find(p => p.denom === denom); + if (priceEntry) { + toPayBatch.push({ node, priceEntry }); + } else if (broadcast && pricingMode === 'hours') { + broadcast('log', { msg: ` ⏭ Skip ${node.address.slice(0, 20)}… — no hourly price` }); + } } if (toPayBatch.length > 0) { const messages = toPayBatch.map(({ node, priceEntry }) => ({ typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes, hours: 0, + gigabytes: sessionGigabytes, hours: sessionHours, max_price: { denom: priceEntry.denom, base_value: priceEntry.base_value, quote_value: priceEntry.quote_value }, }, })); @@ -369,7 +300,7 @@ export async function submitBatchPayment(client, account, denom, gigabytes, batc typeUrl: V3_MSG_TYPE, value: { from: account.address, node_address: node.address, - gigabytes, hours: 0, + gigabytes: sessionGigabytes, hours: sessionHours, }, })); txResult = await signAndBroadcastRetry(client, account.address, messagesNoMax, fee, broadcast); @@ -410,8 +341,10 @@ export async function submitBatchPayment(client, account, denom, gigabytes, batc } if (broadcast) broadcast('log', { msg: ` Batch tx (${n} msgs): ${txResult.transactionHash.slice(0, 16)}…` }); toPayBatch.forEach(({ node }) => { - const priceEntry = (node.gigabyte_prices || []).find(p => p.denom === denom); - if (priceEntry) state.spentUdvpn += Math.round(parseFloat(priceEntry.quote_value) || 0) * gigabytes; + const list = pricingMode === 'hours' ? (node.hourly_prices || []) : (node.gigabyte_prices || []); + const priceEntry = list.find(p => p.denom === denom); + const units = pricingMode === 'hours' ? sessionHours : gigabytes; + if (priceEntry) state.spentUdvpn += Math.round(parseFloat(priceEntry.quote_value) || 0) * units; }); state.spentUdvpn += 200000 * n; state.balance = `${(Math.max(0, state.balanceUdvpn - state.spentUdvpn) / 1_000_000).toFixed(4)} P2P (est. remaining)`; @@ -458,24 +391,14 @@ export async function waitForBatchSessions(nodeAddrs, walletAddr, maxWaitMs = 20 } /** - * Query a single session by ID via RPC. - * Returns decoded base session or null. + * Query a single session by ID via RPC. Delegates to SDK. + * Returns decoded session object or null. */ async function rpcQuerySession(sessionId) { const client = await getRpcClient(); if (!client) return null; try { - const request = encodeRpcVarintField(1, Number(sessionId)); - const result = await client.queryClient.queryAbci( - '/sentinel.session.v3.QueryService/QuerySession', - request, - ); - const resp = new Uint8Array(result.value); - if (resp.length <= 2) return null; - const fields = decodeRpcProto(resp); - // Field 1 = google.protobuf.Any (session) - if (!fields[1]?.[0]) return null; - return decodeRpcSession(fields[1][0].value); + return await sdkRpcQuerySession(client, sessionId); } catch { return null; } diff --git a/core/transport-cache.js b/core/transport-cache.js index 5ced6a6..070eb50 100644 --- a/core/transport-cache.js +++ b/core/transport-cache.js @@ -10,8 +10,21 @@ import { readFileSync, writeFileSync, existsSync } from 'fs'; import path from 'path'; +import { recordTransportResult } from 'blue-js-sdk'; import { RESULTS_DIR } from './constants.js'; +// SDK rate-key format mirrors connection/tunnel.js _transportRateKey: +// network=='grpc' or security=='tls' get suffix 'tls' (or special 'grpc/none'), +// otherwise just the network. Tester records protocol-aware key locally and +// dual-writes a normalized key into the SDK rate cache so embedded SDK calls +// benefit from tester's broad-coverage observations. +function sdkTransportKey(network, security) { + if (!network) return null; + if (security === 'tls') return `${network}/tls`; + if (network === 'grpc') return 'grpc/none'; + return network; +} + // ─── Cache File ────────────────────────────────────────────────────────────── const CACHE_FILE = path.join(RESULTS_DIR, 'transport-cache.json'); @@ -68,6 +81,11 @@ export function recordTransportSuccess(nodeAddr, transport) { cache.globalStats[key].attempts++; cache.globalStats[key].rate = cache.globalStats[key].successes / cache.globalStats[key].attempts; + // Dual-write into SDK's getDynamicRate cache so embedded SDK code paths + // (setupV2Ray transport ordering) benefit from tester observations. + const sdkKey = sdkTransportKey(network, security); + if (sdkKey) try { recordTransportResult(sdkKey, true); } catch { } + dirty = true; } @@ -87,6 +105,9 @@ export function recordTransportFailure(transport) { cache.globalStats[key].attempts++; cache.globalStats[key].rate = cache.globalStats[key].successes / cache.globalStats[key].attempts; + const sdkKey = sdkTransportKey(network, security); + if (sdkKey) try { recordTransportResult(sdkKey, false); } catch { } + dirty = true; } diff --git a/core/types.js b/core/types.js index 97e8364..e0a1a43 100644 --- a/core/types.js +++ b/core/types.js @@ -78,8 +78,16 @@ * @property {string|null} errorMessage * @property {boolean} stopRequested * @property {boolean} lowBalanceWarning - * @property {boolean} economyMode * @property {string|null} pauseReason - Why audit is paused (VPN interference, etc.) + * @property {'p2p'|'subscription'|'test'|null} runMode - Active run mode (persisted) + * @property {boolean} testRun - Skip-only TEST RUN demo flag (persisted) + * @property {string|number|null} runPlanId - Plan ID when runMode='p2p' + * @property {string|number|null} runSubscriptionId - Subscription ID when runMode='subscription' + * @property {string|null} runGranter - Fee-grant granter address (admin-only; never broadcast over public SSE) + * @property {string|null} pricingMode - Active pricing strategy + * @property {string|null} activeSDK - Active SDK identifier + * @property {boolean} continuousLoop - True when a continuous loop run is active + * @property {boolean} broadcastLive - When true, public SSE forwards live events; when false, public sees last-completed snapshot only */ export {}; diff --git a/core/wallet.js b/core/wallet.js index 571d052..bf0eb80 100644 --- a/core/wallet.js +++ b/core/wallet.js @@ -15,9 +15,10 @@ import { buildRegistry, broadcast as sdkBroadcast, createSafeBroadcaster, + clearWalletCache, RPC_ENDPOINTS as SDK_RPC_ENDPOINTS, SDK_VERSION, -} from 'sentinel-dvpn-sdk'; +} from 'blue-js-sdk'; import { RPC_ENDPOINTS as LOCAL_RPC_ENDPOINTS, GAS_PRICE as GAS_PRICE_STR } from './constants.js'; // Use SDK RPC endpoints (5 endpoints), fall back to local constants @@ -82,6 +83,7 @@ export async function getOrReconnectClient() { export async function forceReconnect() { if (_managedClient) { try { _managedClient.disconnect(); } catch { } } _managedClient = null; + clearWalletCache(); _activeRpcIdx = (_activeRpcIdx + 1) % RPC_LIST.length; return getOrReconnectClient(); } diff --git a/dictator.html b/dictator.html deleted file mode 100644 index 23902f1..0000000 --- a/dictator.html +++ /dev/null @@ -1,944 +0,0 @@ - - - - - - - Dictator Mode — Sentinel Network Audit - - - - - - - - -
-
-
Countries
-
-
loading
-
-
-
Google Open
-
-
-
-
-
Google Blocked
-
-
-
-
-
Mixed / Unknown
-
-
-
-
-
Total Nodes
-
-
-
-
- -
-
-
Countries by Google Accessibility
-
-
- -
-
-
- Loading audit data -
-
-
- -
Copied
- - - - - diff --git a/docs/ARCHITECTURE-PUBLIC-LIVE.md b/docs/ARCHITECTURE-PUBLIC-LIVE.md index 3ed680d..41686f4 100644 --- a/docs/ARCHITECTURE-PUBLIC-LIVE.md +++ b/docs/ARCHITECTURE-PUBLIC-LIVE.md @@ -55,9 +55,9 @@ One database: `audit.db`. All runs — live and dry — write to it. - Normal audit run: `mode='live'` (or absent/null for legacy rows). - Test-run (`?testRun=1` on `/api/start`): `mode='test'`. Every node row gets `actualMbps: null, errorCode: 'TEST_RUN_SKIP'`. Visually distinguishable in the admin table but stored in the same file. -- `audit-test.db` no longer exists. +- `audit-dry.db` no longer exists. -## TEST RUN (Dry Run) +## TEST RUN (Test Run) TEST RUN is not a mode — it is an optional parameter on the normal start endpoint. @@ -97,5 +97,5 @@ No wallet, plan ID, or fee-grant internals are forwarded on the public SSE strea - Mode overlay UI in `admin.html`. - `POST /api/admin/public-test/start`, `POST /api/admin/public-test/stop`, `GET /api/admin/public-test/status`. - `POST /api/public/test/start`, `POST /api/public/test/stop`, `GET /api/public/test/status`. -- `audit-test.db` — superseded by the `mode='test'` column in `audit.db`. +- `audit-dry.db` — superseded by the `mode='test'` column in `audit.db`. - Economy mode (deprecated earlier in the same session). diff --git a/docs/FRONTEND-SPEC.md b/docs/FRONTEND-SPEC.md index 8fb34a0..b610c66 100644 --- a/docs/FRONTEND-SPEC.md +++ b/docs/FRONTEND-SPEC.md @@ -757,8 +757,6 @@ Integrator equivalent: | GET | `/api/sdk` | Get SDK | Not used by frontend | `{ sdk }` | | GET | `/api/dns` | Get DNS config | DNS startup loader | `{ servers, presets }` | | POST | `/api/dns` | Set DNS | setDNS() | `{ ok, servers, preset }` | -| GET | `/dictator` | Dictator page | Link in header | HTML | -| GET | `/api/dictator` | Dictator data | Not used by main frontend | `{ sdk, countries, ... }` | | GET | `/health` | Health check | Not used by frontend | `{ status, uptime }` | --- @@ -944,9 +942,7 @@ Integrator equivalent: 5. **Low balance warning** -- `state.lowBalanceWarning` is set by server but never shown in frontend. -6. **Dictator Mode link** -- EXISTS in header but leads to separate `dictator.html` (not part of main dashboard code review). - -7. **Moniker display** -- Result rows show truncated address, not moniker. The moniker field exists in results but is unused in the table. +6. **Moniker display** -- Result rows show truncated address, not moniker. The moniker field exists in results but is unused in the table. --- @@ -963,7 +959,6 @@ These exist in server.js but have no corresponding frontend function: | `POST /api/auto-retest` | Server-side filtered retest | Frontend uses client-side filtered retest-fails | | `GET /api/runs/:num` | Get run details without loading | Frontend loads runs directly | | `GET /api/sdk` | Get current SDK | Frontend syncs via SSE state | -| `GET /api/dictator` | Dictator data | Used by dictator.html, not main dashboard | | `GET /health` | Health check | For monitoring, not UI | --- diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 78fa1e8..59ba770 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -191,7 +191,7 @@ es.addEventListener('state', e => { ``` ┌─ Header ──────────────────────────────────────────────────────┐ -│ [Logo] SENTINEL AUDIT [JS|C#] [DNS▼] [Dictator Mode] │ +│ [Logo] SENTINEL AUDIT [JS|C#] [DNS▼] │ ├─ Stats ───────────────────────────────────────────────────────┤ │ Total: 1002 | Tested: 975 | Remaining: 27 | Rate: 97.2% │ │ [New Test] [Resume] [Rescan] [Retest Failed] [Stop] [Economy] │ diff --git a/docs/TECHNICAL-BLUEPRINT.md b/docs/TECHNICAL-BLUEPRINT.md index 9e97436..826d71f 100644 --- a/docs/TECHNICAL-BLUEPRINT.md +++ b/docs/TECHNICAL-BLUEPRINT.md @@ -472,7 +472,7 @@ testWithRetry(testFn, broadcast, state, nodeAddr): ## Dashboard UI (index.html) ### Layout Sections -1. **Header:** Logo, Windows badge, SDK toggle (JS/C#), DNS dropdown, Dictator Mode link +1. **Header:** Logo, Windows badge, SDK toggle (JS/C#), DNS dropdown 2. **Stats grid:** Total, Tested, Remaining, Pass Rate, >10Mbps, Balance 3. **Controls:** New Test, Resume, Rescan, Retest Failed, Stop, Economy, Plan Test, Reset 4. **Speed history:** Last 10 speeds as color-coded pills (green ≥15, yellow ≥5, red <5) diff --git a/docs/UX-FEATURE-PARITY.md b/docs/UX-FEATURE-PARITY.md index 68b6b92..78e3a18 100644 --- a/docs/UX-FEATURE-PARITY.md +++ b/docs/UX-FEATURE-PARITY.md @@ -16,7 +16,6 @@ Some features only apply to the standalone CLI/Browser tester. When embedded in | Mnemonic in .env | ✓ Required | ✗ App manages auth | App handles credentials | | SentinelAudit.vbs | ✓ Required | ✗ App has own launcher | App handles elevation | | Port 3001 | ✓ Default | Configurable or embedded | May conflict with app | -| Dictator Mode link | ✓ Optional | ✗ Remove | App-specific feature | | Economy mode | ✓ Optional | ✓ Keep | Users want to limit spending | | Plan Test | ✓ Optional | Only if app uses plans | Depends on app type | diff --git a/easy.js b/easy.js index b60ef0a..1097cf1 100644 --- a/easy.js +++ b/easy.js @@ -17,7 +17,7 @@ */ import { getAllNodes, queryNodeStatusDirect, cleanupRpc } from './core/chain.js'; -import { cachedWalletSetup, createFreshClient, buildV3Registry } from './core/wallet.js'; +import { cachedWalletSetup, createFreshClient } from './core/wallet.js'; import { findExistingSession, submitBatchPayment, clearPaidNodes, buildSessionMap } from './core/session.js'; import { testNode } from './audit/node-test.js'; import { testWithRetry } from './audit/retry.js'; diff --git a/index.html b/index.html index 03ae20d..bacfd61 100644 --- a/index.html +++ b/index.html @@ -535,7 +535,6 @@

SENTINEL AUDIT

- Dictator Mode

-
-
-
-

Dictator Mode

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

@@ -569,14 +568,13 @@

SENTINEL AUDIT

-
- +
@@ -883,18 +881,16 @@

SENTINEL AUDIT

const label = paused ? 'Paused' : 'Running...'; btnStart.textContent = label; btnStart.classList.add('loading'); - btnStop.textContent = 'Stop Audit'; + btnStop.textContent = 'Stop Test'; btnStop.classList.remove('loading'); } else { btnStart.textContent = 'New Test'; btnStart.classList.remove('loading'); if (btnResume) { btnResume.textContent = 'Resume'; btnResume.classList.remove('loading'); btnResume.disabled = false; } - btnStop.textContent = 'Stop Audit'; + btnStop.textContent = 'Stop Test'; btnStop.classList.remove('loading'); } - if (state.economyMode != null) updateEconomyBtn(state.economyMode); - // Sync SDK toggle with server state (don't override user's click) if (state.activeSDK && state.activeSDK !== activeSDK) { setSDK(state.activeSDK); @@ -1235,34 +1231,6 @@

SENTINEL AUDIT

if (body.children.length > 500) body.removeChild(body.firstChild); } - async function toggleEconomy() { - try { - const res = await fetch('/api/economy', { method: 'POST' }); - const data = await res.json(); - updateEconomyBtn(data.economyMode); - } catch {} - } - - function updateEconomyBtn(on) { - const btn = document.getElementById('btnEconomy'); - if (on) { - btn.style.background = 'rgba(0,200,83,0.15)'; - btn.style.borderColor = 'var(--accent-green)'; - btn.style.color = 'var(--accent-green)'; - btn.textContent = '♻ Economy ON'; - } else { - btn.style.background = 'transparent'; - btn.style.borderColor = 'rgba(255,255,255,0.15)'; - btn.style.color = '#888'; - btn.textContent = '♻ Economy'; - } - } - - // Sync economy state on page load - fetch('/api/state').then(r => r.json()).then(d => { - if (d.state?.economyMode != null) updateEconomyBtn(d.state.economyMode); - }).catch(() => {}); - // ─── Test Run Management ──────────────────────────────────────────────── async function loadRunsList() { try { @@ -1503,15 +1471,10 @@

-
Stop Audit
+
Stop Test
Gracefully stops the current scan after the active node finishes. Results are saved. You can Resume later. -
-
Economy Mode
- Caps the number of nodes tested to what the wallet can afford. Prevents running out of P2P mid-scan. -
-
Test via Plan (WIP)
Plan testing is under development. Currently hidden from the dashboard. Coming in a future release. @@ -1571,7 +1534,7 @@

×

- Only tests nodes in the selected plan. Every session TX is fee-granted by the plan owner — this wallet pays zero gas. + Only tests nodes in the selected plan. Session TXs are fee-granted by the plan owner — but only if that plan owner has an active feegrant for this wallet. Plans without a feegrant are flagged below and disabled (this wallet would otherwise pay gas itself).
Loading your subscriptions…
diff --git a/live.html b/live.html index b712959..fa7c238 100644 --- a/live.html +++ b/live.html @@ -74,6 +74,41 @@ margin: 0 0 18px 0; } .live-page-header-left { display: flex; align-items: center; gap: 14px; flex: 1 1 auto; min-width: 0; } + .brand-logo { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); } + /* TEST RUN pill — sits next to the page title when a test-run audit is active */ + .test-run-badge { + display: inline-block; + vertical-align: middle; + margin-left: 10px; + padding: 4px 10px; + border-radius: 999px; + background: var(--accent); + color: var(--bg-card-solid); + font-size: 10px; + font-weight: 800; + letter-spacing: 1.5px; + font-family: var(--font-display); + } + /* TEST RUN banner — full-width, static accent strip. Shown when a test-run + audit is active so the public can't mistake skipped rows for real + measurements. No animation — keeps the page calm. */ + .test-run-banner { + display: none; + align-items: center; + justify-content: center; + gap: 10px; + padding: 12px 16px; + margin-bottom: 22px; + background: var(--accent); + color: var(--bg-card-solid); + font-family: var(--font-display); + font-weight: 800; + font-size: 13px; + letter-spacing: 2px; + text-transform: uppercase; + border-bottom: 2px solid var(--accent); + } + .test-run-banner.is-visible { display: flex; } .live-page-header-right { display: flex; align-items: center; gap: 0; flex: 0 0 auto; @@ -267,18 +302,35 @@ .table-wrap { overflow-y: auto; flex: 1 1 auto; min-height: 0; padding: 0 8px 8px 8px; margin-top: 8px; } - table { width: 100%; border-collapse: separate; border-spacing: 0 4px; } + table { width: 100%; border-collapse: separate; border-spacing: 0 4px; table-layout: fixed; } + td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + td.td-num, th.th-num { font-variant-numeric: tabular-nums; text-align: right; } + td.td-moniker { max-width: 180px; } + td.td-error { max-width: 240px; } + + .pager .btn-paginate { + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + cursor: pointer; + } + .pager .btn-paginate:disabled { opacity: 0.4; cursor: not-allowed; } + .pager .btn-paginate:not(:disabled):hover { border-color: var(--border-hover); } th { text-align: left; - padding: 0 6px 6px 6px; + padding: 8px 6px; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid var(--border); color: var(--text-secondary); position: sticky; top: 0; - background: var(--glass-bg); + /* Opaque so scrolling rows don't bleed through the header bar */ + background: var(--bg-card-solid); z-index: 10; } @@ -460,7 +512,7 @@ position: absolute; right: -6px; bottom: -6px; z-index: 2; width: 46px; height: 46px; border-radius: 50%; background: var(--yellow); - color: #000; + color: var(--bg); display: flex; align-items: center; justify-content: center; border: 3px solid var(--bg); box-shadow: 0 0 0 0 rgba(255, 200, 0, 0.45); @@ -563,18 +615,33 @@

View on Desktop

+ +
+
-

dVPN Node Test

-
Live public testing — Sentinel dVPN network
+

+ Sentinel dVPN Node Test + + +

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

dVPN Node Test

Current Batch
-
0 / 0 nodes tested
-
Batch size: @@ -693,6 +758,11 @@

dVPN Node Test

+
@@ -732,7 +802,8 @@

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + function fmtUtc(ts) { + if (!ts) return ''; + const n = Number(ts); + const ms = Number.isFinite(n) ? (n < 1e12 ? n * 1000 : n) : new Date(ts).getTime(); + if (!Number.isFinite(ms)) return ''; + const d = new Date(ms); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const hh = String(d.getUTCHours()).padStart(2, '0'); + const mi = String(d.getUTCMinutes()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`; + } function shortAddr(a) { if (!a) return '--'; return a.length > 22 ? a.slice(0, 12) + '…' + a.slice(-5) : a; @@ -888,6 +1043,7 @@

dVPN Node Test

// ─── Filter ─── function setFilter(f) { activeFilter = f; + _livePage = 1; for (const id of ['filterAll','filterPass','filterFail','filterSlow','filterWg','filterV2']) { const el = document.getElementById(id); if (el) { @@ -915,30 +1071,66 @@

dVPN Node Test

// ─── Table render ─── function renderTable() { const tbody = document.getElementById('resultsBody'); + const wrap = tbody.closest('.table-wrap'); + const prevScroll = wrap ? wrap.scrollTop : 0; tbody.innerHTML = ''; - const filtered = applyFilter(resultsArr); - if (filtered.length === 0) { + const filtered = applyFilter(resultsArr).slice().reverse(); + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / LIVE_PAGE_SIZE)); + if (_livePage > totalPages) _livePage = totalPages; + const start = (_livePage - 1) * LIVE_PAGE_SIZE; + const slice = filtered.slice(start, start + LIVE_PAGE_SIZE); + if (total === 0) { tbody.innerHTML = 'No results match current filter.'; } else { - filtered.slice(-MAX_ROWS).reverse().forEach(r => appendRowHtml(r, tbody)); + slice.forEach(r => appendRowHtml(r, tbody)); } const label = activeFilter === 'all' ? resultsArr.length + ' Entries' : filtered.length + '/' + resultsArr.length + ' (' + activeFilter.toUpperCase() + ')'; document.getElementById('resultsCountLabel').textContent = label; + renderLivePager(total, totalPages); + if (wrap) wrap.scrollTop = prevScroll; + } + + function renderLivePager(total, totalPages) { + const pager = document.getElementById('liveResultsPager'); + if (!pager) return; + if (totalPages <= 1) { pager.style.display = 'none'; return; } + pager.style.display = 'flex'; + const startIdx = (_livePage - 1) * LIVE_PAGE_SIZE + 1; + const endIdx = Math.min(_livePage * LIVE_PAGE_SIZE, total); + document.getElementById('liveResultsPageInfo').textContent = + `Page ${_livePage} of ${totalPages} — ${startIdx}–${endIdx} of ${total}`; + document.getElementById('liveResultsPrev').disabled = _livePage <= 1; + document.getElementById('liveResultsNext').disabled = _livePage >= totalPages; + } + + function liveGoPage(delta) { + const filtered = applyFilter(resultsArr); + const totalPages = Math.max(1, Math.ceil(filtered.length / LIVE_PAGE_SIZE)); + const next = Math.min(totalPages, Math.max(1, _livePage + delta)); + if (next === _livePage) return; + _livePage = next; + renderTable(); } function addSingleRow(r) { - const tbody = document.getElementById('resultsBody'); - // Clear placeholder row if present - const placeholder = tbody.querySelector('tr td[colspan]'); - if (placeholder) tbody.innerHTML = ''; - for (const row of tbody.querySelectorAll('tr')) { - if (row.dataset.addr === r.address) { row.remove(); break; } + // Re-render current page only when the affected row falls on the visible page; + // otherwise just bump the count label. + const filtered = applyFilter(resultsArr).slice().reverse(); + const idxInFiltered = filtered.findIndex(x => x.address === r.address); + const onVisiblePage = idxInFiltered >= 0 + && idxInFiltered >= (_livePage - 1) * LIVE_PAGE_SIZE + && idxInFiltered < _livePage * LIVE_PAGE_SIZE; + if (onVisiblePage) { + renderTable(); + } else { + document.getElementById('resultsCountLabel').textContent = resultsArr.length + ' Entries'; + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / LIVE_PAGE_SIZE)); + renderLivePager(total, totalPages); } - appendRowHtml(r, tbody, true); - document.getElementById('resultsCountLabel').textContent = resultsArr.length + ' Entries'; - if (tbody.children.length > MAX_ROWS) tbody.removeChild(tbody.lastChild); } function appendRowHtml(r, tbody, prepend = false) { @@ -962,8 +1154,12 @@

dVPN Node Test

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

dVPN Node Test

const isFail = r.actualMbps == null && !r.skipped; const copyCell = isFail && addrFull - ? `` + ? ` + ` : ''; tr.innerHTML = ` ${proto} ${transportHtml} - ${escHtml(monikerText)} + ${escHtml(monikerText)} ${addr} ${countryCol} ${cityCol} - ${peersHtml} - ${act} - ${blAt} + ${peersHtml} + ${act} + ${blAt} ${badge} ${copyCell} `; @@ -1102,6 +1299,7 @@

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

if (!d || !d.type) return; try { handleEvent(d); } catch (_) {} }; - _sse.onopen = () => { _sseRetry = 1000; }; + _sse.onopen = () => { + _sseRetry = 1000; + // On reconnect, re-fetch current batch via REST so the table doesn't + // sit on stale data until the next live event arrives. + if (_sseEverConnected) { + try { loadCurrentBatch(); } catch (_) {} + } + _sseEverConnected = true; + }; _sse.onerror = () => { try { _sse.close(); } catch (_) {} _sse = null; @@ -1518,6 +2093,13 @@

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

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

dVPN Node Test

'Stage: ' + (er.stage || '—'), 'Error Code: ' + (er.error_code || '—'), 'Message: ' + (er.error_message || '—'), - 'Captured: ' + (er.captured_at || '—'), + 'Captured: ' + (er.captured_at ? fmtUtc(er.captured_at) : '—'), '', ]; if (er.log_snippet) { lines.push('Log Snippet:'); lines.push(er.log_snippet); } @@ -1619,6 +2219,72 @@

dVPN Node Test

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

Node Error Details

+ × +
+
Loading…
+ `; + overlay.appendChild(popup); + document.body.appendChild(overlay); + + try { + const res = await fetch('/api/public/node/' + encodeURIComponent(addr) + '/errors?limit=1', { credentials: 'same-origin' }); + const data = await res.json(); + const errs = (data && data.errors) || []; + const body = document.getElementById('errorPopupBody'); + if (!body) return; + if (!errs.length) { + body.innerHTML = '
No stored failure log for this node yet.
'; + return; + } + const er = errs[0]; + const eh = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); + const captured = er.captured_at ? fmtUtc(er.captured_at) : '—'; + const row = (label, value) => ` +
+
${label}
+
${value || '—'}
+
`; + body.innerHTML = ` + ${row('Node', eh(moniker))} + ${row('Address', `${eh(addr)}`)} + ${row('Stage', eh(er.stage))} + ${row('Error Code', `${eh(er.error_code)}`)} + ${row('Captured', eh(captured))} + ${row('Message', `${eh(er.error_message)}`)} + ${er.log_snippet ? ` +
+
Log Snippet
+
${eh(er.log_snippet)}
+
` : ''} +
+ +
+ `; + } catch (err) { + const body = document.getElementById('errorPopupBody'); + if (body) body.innerHTML = '
Failed to load: ' + (err.message || 'unknown') + '
'; + } + } 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/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 1da91fe..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; @@ -1660,6 +1660,20 @@

View on Desktop

return Math.floor(d / 86400000) + 'd ago'; } + 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 escHtml(s) { if (s == null) return ''; return String(s) @@ -1969,7 +1983,13 @@

View on Desktop

renderPage(); populateCountries(allNodes); updateFilterChips(); - document.getElementById('lastUpdatedLabel').textContent = 'Updated ' + new Date().toLocaleTimeString(); + // 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); } @@ -2039,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(); @@ -2078,7 +2104,8 @@

View on Desktop

const countryFull = `${ctry}${city}`; const isFail = n.handshake_ok === false; const rowCopy = isFail - ? `` + ? ` + ` : ''; tr.innerHTML = ` ${idx} @@ -2111,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 ─── @@ -2151,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) { @@ -2174,6 +2204,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 ? fmtUtc(er.captured_at) : '—'; + const row = (label, value) => ` +
+
${label}
+
${value || '—'}
+
`; + body.innerHTML = ` + ${row('Node', eh(moniker))} + ${row('Address', `${eh(addr)}`)} + ${row('Stage', eh(er.stage))} + ${row('Error Code', `${eh(er.error_code)}`)} + ${row('Captured', eh(captured))} + ${row('Message', `${eh(er.error_message)}`)} + ${er.log_snippet ? ` +
+
Log Snippet
+
${eh(er.log_snippet)}
+
` : ''} +
+ +
+ `; + } catch (err) { + const body = document.getElementById('errorPopupBody'); + if (body) body.innerHTML = '
Failed to load: ' + (err.message || 'unknown') + '
'; + } + } + // ─── Detail Drawer ─── async function openDetail(node) { selectedAddr = node.address; @@ -2357,7 +2453,7 @@

View on Desktop

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

View on Desktop

// 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'; @@ -2777,7 +2873,10 @@

View on Desktop

filterAndSort(); renderPage(); updateFilterChips(); - document.getElementById('lastUpdatedLabel').textContent = 'Snapshot from batch #' + batchId; + const _bts = data?.batch?.started_at || null; + document.getElementById('lastUpdatedLabel').textContent = _bts + ? 'Snapshot ' + fmtUtc(_bts) + : 'Snapshot from batch #' + batchId; } catch (err) { setTableMessage('Failed to load batch: ' + escHtml(err.message), true); } @@ -2891,7 +2990,7 @@

View on Desktop

handshake_ok: (d.skipped || err === 'TEST_RUN_SKIP') ? null : (mbps > 0 ? true : (err ? false : null)), last_tested_at: tested, - test_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); @@ -2923,6 +3022,7 @@

View on Desktop

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

View on Desktop

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; @@ -2956,10 +3067,12 @@

View on Desktop

b.classList.toggle('active', parseInt(b.dataset.win, 10) === passWindow); }); startStatsRefresh(); - loadBatchList(); connectSSE(); - loadNodes(); + (async () => { + await loadBatchList(); + loadNodes(); + })(); diff --git a/server.js b/server.js index 762991b..12718bd 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, @@ -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 { } } @@ -202,11 +212,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'); @@ -354,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 @@ -574,6 +610,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 +902,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 +1032,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 +1046,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 +1076,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 +1173,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 }); }); @@ -1052,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); @@ -1112,6 +1259,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 { } @@ -1145,6 +1296,8 @@ 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 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'; if (state.status === 'running' || state.status === 'paused') return res.json({ error: 'Already running' }); if (continuous.status().running) { @@ -1160,21 +1313,47 @@ app.post('/api/start', adminOnly, async (req, res) => { } 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'; - const { newNum } = startFreshRun(`Test #${getNextRunNumber()}`, { mode: runMode }); + 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})${testRun ? ' [TEST RUN]' : ''}` }); - res.json({ ok: true, testNumber: newNum, testRun }); - runAudit(false, state, broadcast, null, { testRun }).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,26 +1379,78 @@ 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 }); - }); + // 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 }); + + 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; - res.json({ ok: 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 }); + })(); -// DEPRECATED 2026-04-25: Economy mode removed. Endpoint kept as 410 Gone for any old client. -app.post('/api/economy', adminOnly, (req, res) => { - res.status(410).json({ error: 'ECONOMY_MODE_DEPRECATED' }); + res.json({ ok: true }); }); app.post('/api/retest-skips', adminOnly, async (req, res) => { @@ -1318,20 +1549,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) => { @@ -1572,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) => { @@ -1672,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() }); @@ -1742,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/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 ──────────────────────────────────────────────────────────────────── diff --git a/test/smoke.test.js b/test/smoke.test.js index 0ffc359..5784251 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -145,7 +145,6 @@ async function run() { assert(state.testedNodes === 0, 'initial testedNodes=0'); assert(state.failedNodes === 0, 'initial failedNodes=0'); assert(state.stopRequested === false, 'initial stopRequested=false'); - assert(state.economyMode === false, 'initial economyMode=false'); assert(state.baselineMbps === null, 'initial baselineMbps=null'); // ─── 9. Sleep Utility ───────────────────────────────────────────────── diff --git a/types/index.d.ts b/types/index.d.ts index e87b5f0..60bc59c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -108,7 +108,6 @@ export interface AuditState { errorMessage: string | null; stopRequested: boolean; lowBalanceWarning: boolean; - economyMode: boolean; pauseReason: string | null; /** Present during retest mode */ retestMode?: boolean; @@ -117,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. */ @@ -270,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 ─────────────────────────────────────────────────────────