fix(traces): overlay per-hop SNR on path graph for TRACE packets (#1004) (#1622)

## 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 `<text
class="hop-snr">` 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) `<text
class="hop-snr">` 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` → `<text class="hop-snr">` 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 <bot@corescope.local>
Co-authored-by: clawbot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-06-07 07:58:06 -07:00
committed by GitHub
parent 064d142cb9
commit e9aed641bd
3 changed files with 66 additions and 3 deletions
+1
View File
@@ -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
+26 -3
View File
@@ -147,12 +147,12 @@
</div>
</div>
${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 += `<text class="hop-snr" x="${mx}" y="${my}" text-anchor="middle" font-size="10" font-weight="600" fill="var(--text, #111827)">${label}</text>`;
}
}
}
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 });
+39
View File
@@ -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 <text class="hop-snr"> labels with values', () => {
const html = renderPathGraph(paths, paths, decodedTrace);
assert.ok(/class="hop-snr"/.test(html), 'expected <text class="hop-snr"> in output');
// Each numeric value should appear in a hop-snr label.
const labels = html.match(/<text[^>]*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`);