From e9aed641bde2f9de5f0f09f26230cb28de4ad671 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Sun, 7 Jun 2026 07:58:06 -0700 Subject: [PATCH] fix(traces): overlay per-hop SNR on path graph for TRACE packets (#1004) (#1622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Phase 2 of #979 — overlay per-hop relay SNR onto the Traces page path graph for TRACE-type packets. When the viewed packet is a firmware TRACE and `decoded.snrValues` is non-empty, each hop edge in the existing path graph gets a small `` label at its midpoint with the corresponding numeric SNR value (Tufte: numeric overlay only — edge color encodes observer attribution, thickness encodes count; per triage, do **not** double-encode). Non-TRACE packets render unchanged. Observer-level SNR in the timeline is unaffected (different concept: observer receive SNR vs relay hop SNR). ## TDD - **Red commit:** `8d441aa51e4b38dec962c7a32d31e9f7080f2786` — adds 4 assertions in `test-traces.js` against the (not-yet-emitted) `` element. CI run: see Actions on this PR. - **Green commit:** implements the SNR-label emission in `renderPathGraph` (`public/traces.js`). ## Test `test-traces.js` asserts: - TRACE + non-empty `snrValues` → `` labels render with the numeric values - non-TRACE → labels absent (regression gate for AC2) - TRACE + empty `snrValues` → labels absent - `decoded` omitted → labels absent (back-compat) Fixes #1004 --------- Co-authored-by: corescope-bot Co-authored-by: clawbot --- .github/workflows/deploy.yml | 1 + public/traces.js | 29 ++++++++++++++++++++++++--- test-traces.js | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0a9a5d33..e17cd9e6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -138,6 +138,7 @@ jobs: node test-issue-1619-feed-detail-card-draggable.js node test-xss-escape-sinks.js node test-preflight-xss-gate.js + node test-traces.js - name: 🛡️ Preflight XSS gate — actual --diff check (PR only) # The fixture self-test above (test-preflight-xss-gate.js) only diff --git a/public/traces.js b/public/traces.js index d3c02416..e0221d43 100644 --- a/public/traces.js +++ b/public/traces.js @@ -147,12 +147,12 @@ - ${allPaths.length > 0 ? renderPathGraph(allPaths, allPathsRaw) : ''} + ${allPaths.length > 0 ? renderPathGraph(allPaths, allPathsRaw, decoded) : ''} ${traceData.length > 0 ? renderTimeline(t0, spreadMs) : ''} `; } - function renderPathGraph(allPaths, allPathsRaw) { + function renderPathGraph(allPaths, allPathsRaw, decoded) { // Collect unique nodes and edges across all observed paths const nodeSet = new Set(); const edgeMap = new Map(); // "from→to" => Set of observer labels @@ -261,6 +261,29 @@ } let nodesSvg = ''; + // Per-hop SNR overlay: TRACE packets only, numeric labels at hop midpoints + // (Tufte: no double-encoding via color/thickness — number is the signal). + const snrValues = (decoded && decoded.type === 'TRACE' && Array.isArray(decoded.snrValues)) + ? decoded.snrValues : null; + if (snrValues && snrValues.length > 0) { + // Build the hop chain from the first observed full path (Origin → hops → Dest). + const firstPath = allPaths[0]; + if (firstPath && Array.isArray(firstPath.hops)) { + const chain = ['Origin', ...firstPath.hops, 'Dest']; + const hopCount = Math.min(snrValues.length, chain.length - 1); + for (let i = 0; i < hopCount; i++) { + const p1 = nodePos.get(chain[i]); + const p2 = nodePos.get(chain[i + 1]); + if (!p1 || !p2) continue; + const v = snrValues[i]; + if (v == null || !Number.isFinite(Number(v))) continue; + const mx = (p1.x + p2.x) / 2; + const my = (p1.y + p2.y) / 2 - 6; + const label = Number(v).toFixed(1); + nodesSvg += `${label}`; + } + } + } for (const [node, pos] of nodePos) { const isEndpoint = node === 'Origin' || node === 'Dest'; const r = isEndpoint ? 18 : 16; @@ -335,7 +358,7 @@ } if (typeof window !== 'undefined') { - window.TracesHelpers = { dedupePrefixPaths }; + window.TracesHelpers = { dedupePrefixPaths, renderPathGraph }; } registerPage('traces', { init, destroy }); diff --git a/test-traces.js b/test-traces.js index a5d36ff0..1809ec20 100644 --- a/test-traces.js +++ b/test-traces.js @@ -119,6 +119,45 @@ console.log('\n=== traces.js: dedupePrefixPaths ==='); }); } +// ===== renderPathGraph: per-hop SNR overlay (#1004 Phase 2 of #979) ===== +console.log('\n=== traces.js: renderPathGraph hop-SNR overlay ==='); +{ + const ctx = makeSandbox(); + loadTracesJs(ctx); + const { renderPathGraph } = ctx.TracesHelpers; + assert.strictEqual(typeof renderPathGraph, 'function', 'renderPathGraph must be exported'); + + const paths = [{ hops: ['R1', 'R2'], observer: 'OBS' }]; + const decodedTrace = { type: 'TRACE', snrValues: [-3.5, -7.0, -12.25] }; + const decodedNonTrace = { type: 'CHAN', snrValues: [-3.5, -7.0] }; + + test('TRACE with snrValues emits labels with values', () => { + const html = renderPathGraph(paths, paths, decodedTrace); + assert.ok(/class="hop-snr"/.test(html), 'expected in output'); + // Each numeric value should appear in a hop-snr label. + const labels = html.match(/]*class="hop-snr"[^>]*>([^<]+)<\/text>/g) || []; + assert.ok(labels.length >= 1, 'expected at least one hop-snr label'); + const joined = labels.join(' '); + assert.ok(/-3\.5/.test(joined), 'expected -3.5 in hop-snr label, got: ' + joined); + assert.ok(/-7(\.0)?/.test(joined), 'expected -7 in hop-snr label, got: ' + joined); + }); + + test('non-TRACE packet: hop-snr labels are ABSENT even when snrValues present', () => { + const html = renderPathGraph(paths, paths, decodedNonTrace); + assert.ok(!/class="hop-snr"/.test(html), 'hop-snr must not render for non-TRACE'); + }); + + test('TRACE with empty snrValues: no hop-snr labels', () => { + const html = renderPathGraph(paths, paths, { type: 'TRACE', snrValues: [] }); + assert.ok(!/class="hop-snr"/.test(html), 'hop-snr must not render when snrValues empty'); + }); + + test('decoded omitted: no hop-snr labels (back-compat)', () => { + const html = renderPathGraph(paths, paths); + assert.ok(!/class="hop-snr"/.test(html), 'hop-snr must not render when decoded omitted'); + }); +} + // ===== SUMMARY ===== console.log(`\n${'═'.repeat(40)}`); console.log(` traces.js: ${passed} passed, ${failed} failed`);