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`);