From 7d71dc857bea680120bc0f077cd3322006278207 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Tue, 7 Apr 2026 21:19:45 -0700 Subject: [PATCH] feat: expose hopsCompleted for TRACE packets, show real path on live map (#656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary TRACE packets on the live map previously animated the **full intended route** regardless of how far the trace actually reached. This made it impossible to distinguish a completed route from a failed one — undermining the primary diagnostic purpose of trace packets. ## Changes ### Backend — `cmd/server/decoder.go` - Added `HopsCompleted *int` field to the `Path` struct - For TRACE packets, the header path contains SNR bytes (one per hop that actually forwarded). Before overwriting `path.Hops` with the full intended route from the payload, we now capture the header path's `HashCount` as `hopsCompleted` - This field is included in API responses and WebSocket broadcasts via the existing JSON serialization ### Frontend — `public/live.js` - For TRACE packets with `hopsCompleted < totalHops`: - Animate only the **completed** portion (solid line + pulse) - Draw the **unreached** remainder as a dashed/ghosted line (25% opacity, `6,8` dash pattern) with ghost markers - Dashed lines and ghost markers auto-remove after 10 seconds - When `hopsCompleted` is absent or equals total hops, behavior is unchanged ### Tests — `cmd/server/decoder_test.go` - `TestDecodePacket_TraceHopsCompleted` — partial completion (2 of 4 hops) - `TestDecodePacket_TraceNoSNR` — zero completion (trace not forwarded yet) - `TestDecodePacket_TraceFullyCompleted` — all hops completed ## How it works The MeshCore firmware appends an SNR byte to `pkt->path[]` at each hop that forwards a TRACE packet. The count of these SNR bytes (`path_len`) indicates how far the trace actually got. CoreScope's decoder already parsed the header path, but the TRACE-specific code overwrote it with the payload hops (full intended route) without preserving the progress information. Now we save that count first. Fixes #651 --------- Co-authored-by: you --- cmd/server/decoder.go | 13 +++++-- cmd/server/decoder_test.go | 80 ++++++++++++++++++++++++++++++++++++++ public/live.js | 51 +++++++++++++++++++++++- public/style.css | 3 ++ 4 files changed, 143 insertions(+), 4 deletions(-) diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index 84cf2291..3c3f8106 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -60,9 +60,10 @@ type TransportCodes struct { // Path holds decoded path/hop information. type Path struct { - HashSize int `json:"hashSize"` - HashCount int `json:"hashCount"` - Hops []string `json:"hops"` + HashSize int `json:"hashSize"` + HashCount int `json:"hashCount"` + Hops []string `json:"hops"` + HopsCompleted *int `json:"hopsCompleted,omitempty"` } // AdvertFlags holds decoded advert flag bits. @@ -376,7 +377,12 @@ func DecodePacket(hexString string) (*DecodedPacket, error) { // TRACE packets store hop IDs in the payload (buf[9:]) rather than the header // path field. The header path byte still encodes hashSize in bits 6-7, which // we use to split the payload path data into individual hop prefixes. + // The header path contains SNR bytes — one per hop that actually forwarded. + // We expose hopsCompleted (count of SNR bytes) so consumers can distinguish + // how far the trace got vs the full intended route. if header.PayloadType == PayloadTRACE && payload.PathData != "" { + // The header path hops count represents SNR entries = completed hops + hopsCompleted := path.HashCount pathBytes, err := hex.DecodeString(payload.PathData) if err == nil && path.HashSize > 0 { hops := make([]string, 0, len(pathBytes)/path.HashSize) @@ -385,6 +391,7 @@ func DecodePacket(hexString string) (*DecodedPacket, error) { } path.Hops = hops path.HashCount = len(hops) + path.HopsCompleted = &hopsCompleted } } diff --git a/cmd/server/decoder_test.go b/cmd/server/decoder_test.go index fc409092..67b6b999 100644 --- a/cmd/server/decoder_test.go +++ b/cmd/server/decoder_test.go @@ -323,3 +323,83 @@ func repeatHex(byteHex string, n int) string { } return s } + +func TestDecodePacket_TraceHopsCompleted(t *testing.T) { + // Build a TRACE packet: + // header: route=FLOOD(1), payload=TRACE(9), version=0 → (0<<6)|(9<<2)|1 = 0x25 + // path_length: hash_size bits=0b00 (1-byte), hash_count=2 (2 SNR bytes) → 0x02 + // path: 2 SNR bytes: 0xAA, 0xBB + // payload: tag(4 LE) + authCode(4 LE) + flags(1) + 4 hop hashes (1 byte each) + hex := "2502AABB" + // header + path_length + 2 SNR bytes + "01000000" + // tag = 1 + "02000000" + // authCode = 2 + "00" + // flags = 0 + "DEADBEEF" // 4 hops (1-byte hash each) + + pkt, err := DecodePacket(hex) + if err != nil { + t.Fatalf("DecodePacket error: %v", err) + } + if pkt.Payload.Type != "TRACE" { + t.Fatalf("expected TRACE, got %s", pkt.Payload.Type) + } + // Full intended route = 4 hops from payload + if len(pkt.Path.Hops) != 4 { + t.Errorf("expected 4 hops, got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops) + } + // HopsCompleted = 2 (from header path SNR count) + if pkt.Path.HopsCompleted == nil { + t.Fatal("expected HopsCompleted to be set") + } + if *pkt.Path.HopsCompleted != 2 { + t.Errorf("expected HopsCompleted=2, got %d", *pkt.Path.HopsCompleted) + } +} + +func TestDecodePacket_TraceNoSNR(t *testing.T) { + // TRACE with 0 SNR bytes (trace hasn't been forwarded yet) + // path_length: hash_size=0b00 (1-byte), hash_count=0 → 0x00 + hex := "2500" + // header + path_length (0 hops in header) + "01000000" + // tag + "02000000" + // authCode + "00" + // flags + "AABBCC" // 3 hops intended + + pkt, err := DecodePacket(hex) + if err != nil { + t.Fatalf("DecodePacket error: %v", err) + } + if pkt.Path.HopsCompleted == nil { + t.Fatal("expected HopsCompleted to be set") + } + if *pkt.Path.HopsCompleted != 0 { + t.Errorf("expected HopsCompleted=0, got %d", *pkt.Path.HopsCompleted) + } + if len(pkt.Path.Hops) != 3 { + t.Errorf("expected 3 hops, got %d", len(pkt.Path.Hops)) + } +} + +func TestDecodePacket_TraceFullyCompleted(t *testing.T) { + // TRACE where all hops completed (SNR count = hop count) + // path_length: hash_size=0b00 (1-byte), hash_count=3 → 0x03 + hex := "2503AABBCC" + // header + path_length + 3 SNR bytes + "01000000" + // tag + "02000000" + // authCode + "00" + // flags + "DDEEFF" // 3 hops intended + + pkt, err := DecodePacket(hex) + if err != nil { + t.Fatalf("DecodePacket error: %v", err) + } + if pkt.Path.HopsCompleted == nil { + t.Fatal("expected HopsCompleted to be set") + } + if *pkt.Path.HopsCompleted != 3 { + t.Errorf("expected HopsCompleted=3, got %d", *pkt.Path.HopsCompleted) + } + if len(pkt.Path.Hops) != 3 { + t.Errorf("expected 3 hops, got %d", len(pkt.Path.Hops)) + } +} diff --git a/public/live.js b/public/live.js index 9b793bf8..bb6d2b71 100644 --- a/public/live.js +++ b/public/live.js @@ -1899,7 +1899,56 @@ } } firstPathDone = true; - animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop); + // For TRACE packets, split at hopsCompleted: solid for completed, dashed for remaining + var hopsCompleted = decoded.path && decoded.path.hopsCompleted; + if (typeName === 'TRACE' && hopsCompleted != null && hopsCompleted < allPaths[ai].hopPositions.length) { + var completedPositions = allPaths[ai].hopPositions.slice(0, hopsCompleted + 1); + var remainingPositions = allPaths[ai].hopPositions.slice(hopsCompleted); + if (completedPositions.length >= 2) { + animatePath(completedPositions, typeName, color, allPaths[ai].raw, onHop); + } else if (completedPositions.length === 1) { + pulseNode(completedPositions[0].key, completedPositions[0].pos, typeName); + } + if (remainingPositions.length >= 2) { + drawDashedPath(remainingPositions, color); + } + } else { + animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop); + } + } + } + + // Draw a static dashed/ghosted line for unreached TRACE hops + function drawDashedPath(hopPositions, color) { + var GHOST_TIMEOUT_MS = 10000; + var ghostColor = getComputedStyle(document.documentElement).getPropertyValue('--trace-ghost-color').trim() || '#94a3b8'; + if (!pathsLayer) return; + for (var i = 0; i < hopPositions.length - 1; i++) { + var from = hopPositions[i].pos; + var to = hopPositions[i + 1].pos; + var line = L.polyline([from, to], { + color: color, weight: 2, opacity: 0.25, dashArray: '6, 8' + }).addTo(pathsLayer); + // Pulse the unreached hop nodes as ghost markers + if (i > 0) { + var hp = hopPositions[i]; + if (!nodeMarkers[hp.key]) { + var ghost = L.circleMarker(hp.pos, { + radius: 3, fillColor: ghostColor, fillOpacity: 0.2, color: color, weight: 1, opacity: 0.3 + }).addTo(pathsLayer); + setTimeout((function(g) { return function() { if (pathsLayer.hasLayer(g)) pathsLayer.removeLayer(g); }; })(ghost), GHOST_TIMEOUT_MS); + } + } + // Remove dashed line after timeout + setTimeout((function(l) { return function() { if (pathsLayer.hasLayer(l)) pathsLayer.removeLayer(l); }; })(line), GHOST_TIMEOUT_MS); + } + // Ghost marker for the final unreached hop + var last = hopPositions[hopPositions.length - 1]; + if (!nodeMarkers[last.key]) { + var ghostEnd = L.circleMarker(last.pos, { + radius: 4, fillColor: ghostColor, fillOpacity: 0.25, color: color, weight: 1, opacity: 0.35 + }).addTo(pathsLayer); + setTimeout(function() { if (pathsLayer.hasLayer(ghostEnd)) pathsLayer.removeLayer(ghostEnd); }, GHOST_TIMEOUT_MS); } } diff --git a/public/style.css b/public/style.css index 310bd73b..8ddd13e6 100644 --- a/public/style.css +++ b/public/style.css @@ -30,6 +30,7 @@ --content-bg: var(--surface-0); --card-bg: var(--surface-1); --hover-bg: rgba(0,0,0, 0.04); + --trace-ghost-color: #94a3b8; } /* ⚠️ DARK THEME VARIABLES — KEEP BOTH BLOCKS IN SYNC @@ -55,6 +56,7 @@ --input-bg: #1e1e34; --selected-bg: #1e3a5f; --hover-bg: rgba(255,255,255, 0.06); + --trace-ghost-color: #94a3b8; --section-bg: #1e1e34; } } @@ -78,6 +80,7 @@ --input-bg: #1e1e34; --selected-bg: #1e3a5f; --hover-bg: rgba(255,255,255, 0.06); + --trace-ghost-color: #94a3b8; --section-bg: #1e1e34; }