From a45ac71508e59b85620aaff85a6ca801437b647d Mon Sep 17 00:00:00 2001 From: efiten Date: Fri, 3 Apr 2026 01:54:59 +0200 Subject: [PATCH] fix: restore color-coded hex breakdown in packet detail (#329) (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `BuildBreakdown` was never ported from the deleted Node.js `decoder.js` to Go — the server has returned `breakdown: {}` since the Go migration (commit `742ed865`), so `createColoredHexDump()` and `buildHexLegend()` in the frontend always received an empty `ranges` array and rendered everything as monochrome - Implemented `BuildBreakdown()` in `decoder.go` — computes labeled byte ranges matching the frontend's `LABEL_CLASS` map: `Header`, `Transport Codes`, `Path Length`, `Path`, `Payload`; ADVERT packets get sub-ranges: `PubKey`, `Timestamp`, `Signature`, `Flags`, `Latitude`, `Longitude`, `Name` - Wired into `handlePacketDetail` (was `struct{}{}`) - Also adds per-section color classes to the field breakdown table (`section-header`, `section-transport`, `section-path`, `section-payload`) so the table rows get matching background tints ## Test plan - [x] Open any packet detail pane — hex dump should show color-coded sections (red header, orange path length, blue transport codes, green path hops, yellow/colored payload) - [x] Legend below action buttons should appear with color swatches - [x] ADVERT packets: PubKey/Timestamp/Signature/Flags each get their own distinct color - [x] Field breakdown table section header rows should be tinted per section - [x] 8 new Go tests: all pass Closes #329 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 --- cmd/server/decoder.go | 100 +++++++++++++++++++++++++ cmd/server/decoder_test.go | 149 +++++++++++++++++++++++++++++++++++++ cmd/server/routes.go | 3 +- cmd/server/types.go | 2 +- public/packets.js | 12 +-- public/style.css | 4 + 6 files changed, 262 insertions(+), 8 deletions(-) diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index b902930a..d9dc77f6 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -397,6 +397,106 @@ func DecodePacket(hexString string) (*DecodedPacket, error) { }, nil } +// HexRange represents a labeled byte range for the hex breakdown visualization. +type HexRange struct { + Start int `json:"start"` + End int `json:"end"` + Label string `json:"label"` +} + +// Breakdown holds colored byte ranges returned by the packet detail endpoint. +type Breakdown struct { + Ranges []HexRange `json:"ranges"` +} + +// BuildBreakdown computes labeled byte ranges for each section of a MeshCore packet. +// The returned ranges are consumed by createColoredHexDump() and buildHexLegend() +// in the frontend (public/app.js). +func BuildBreakdown(hexString string) *Breakdown { + hexString = strings.ReplaceAll(hexString, " ", "") + hexString = strings.ReplaceAll(hexString, "\n", "") + hexString = strings.ReplaceAll(hexString, "\r", "") + buf, err := hex.DecodeString(hexString) + if err != nil || len(buf) < 2 { + return &Breakdown{Ranges: []HexRange{}} + } + + var ranges []HexRange + offset := 0 + + // Byte 0: Header + ranges = append(ranges, HexRange{Start: 0, End: 0, Label: "Header"}) + offset = 1 + + header := decodeHeader(buf[0]) + + // Bytes 1-4: Transport Codes (TRANSPORT_FLOOD / TRANSPORT_DIRECT only) + if isTransportRoute(header.RouteType) { + if len(buf) < offset+4 { + return &Breakdown{Ranges: ranges} + } + ranges = append(ranges, HexRange{Start: offset, End: offset + 3, Label: "Transport Codes"}) + offset += 4 + } + + if offset >= len(buf) { + return &Breakdown{Ranges: ranges} + } + + // Next byte: Path Length (bits 7-6 = hashSize-1, bits 5-0 = hashCount) + ranges = append(ranges, HexRange{Start: offset, End: offset, Label: "Path Length"}) + pathByte := buf[offset] + offset++ + + hashSize := int(pathByte>>6) + 1 + hashCount := int(pathByte & 0x3F) + pathBytes := hashSize * hashCount + + // Path hops + if hashCount > 0 && offset+pathBytes <= len(buf) { + ranges = append(ranges, HexRange{Start: offset, End: offset + pathBytes - 1, Label: "Path"}) + } + offset += pathBytes + + if offset >= len(buf) { + return &Breakdown{Ranges: ranges} + } + + payloadStart := offset + + // Payload — break ADVERT into named sub-fields; everything else is one Payload range + if header.PayloadType == PayloadADVERT && len(buf)-payloadStart >= 100 { + ranges = append(ranges, HexRange{Start: payloadStart, End: payloadStart + 31, Label: "PubKey"}) + ranges = append(ranges, HexRange{Start: payloadStart + 32, End: payloadStart + 35, Label: "Timestamp"}) + ranges = append(ranges, HexRange{Start: payloadStart + 36, End: payloadStart + 99, Label: "Signature"}) + + appStart := payloadStart + 100 + if appStart < len(buf) { + ranges = append(ranges, HexRange{Start: appStart, End: appStart, Label: "Flags"}) + appFlags := buf[appStart] + fOff := appStart + 1 + if appFlags&0x10 != 0 && fOff+8 <= len(buf) { + ranges = append(ranges, HexRange{Start: fOff, End: fOff + 3, Label: "Latitude"}) + ranges = append(ranges, HexRange{Start: fOff + 4, End: fOff + 7, Label: "Longitude"}) + fOff += 8 + } + if appFlags&0x20 != 0 && fOff+2 <= len(buf) { + fOff += 2 + } + if appFlags&0x40 != 0 && fOff+2 <= len(buf) { + fOff += 2 + } + if appFlags&0x80 != 0 && fOff < len(buf) { + ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "Name"}) + } + } + } else { + ranges = append(ranges, HexRange{Start: payloadStart, End: len(buf) - 1, Label: "Payload"}) + } + + return &Breakdown{Ranges: ranges} +} + // ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars). func ComputeContentHash(rawHex string) string { buf, err := hex.DecodeString(rawHex) diff --git a/cmd/server/decoder_test.go b/cmd/server/decoder_test.go index e2b7f6be..0a49855b 100644 --- a/cmd/server/decoder_test.go +++ b/cmd/server/decoder_test.go @@ -93,3 +93,152 @@ func TestDecodePacket_FloodHasNoCodes(t *testing.T) { t.Error("expected no transport codes for FLOOD route") } } + +func TestBuildBreakdown_InvalidHex(t *testing.T) { + b := BuildBreakdown("not-hex!") + if len(b.Ranges) != 0 { + t.Errorf("expected empty ranges for invalid hex, got %d", len(b.Ranges)) + } +} + +func TestBuildBreakdown_TooShort(t *testing.T) { + b := BuildBreakdown("11") // 1 byte — no path byte + if len(b.Ranges) != 0 { + t.Errorf("expected empty ranges for too-short packet, got %d", len(b.Ranges)) + } +} + +func TestBuildBreakdown_FloodNonAdvert(t *testing.T) { + // Header 0x15: route=1/FLOOD, payload=5/GRP_TXT + // PathByte 0x01: 1 hop, 1-byte hash + // PathHop: AA + // Payload: FF0011 + b := BuildBreakdown("1501AAFFFF00") + labels := rangeLabels(b.Ranges) + expect := []string{"Header", "Path Length", "Path", "Payload"} + if !equalLabels(labels, expect) { + t.Errorf("expected labels %v, got %v", expect, labels) + } + // Verify byte positions + assertRange(t, b.Ranges, "Header", 0, 0) + assertRange(t, b.Ranges, "Path Length", 1, 1) + assertRange(t, b.Ranges, "Path", 2, 2) + assertRange(t, b.Ranges, "Payload", 3, 5) +} + +func TestBuildBreakdown_TransportFlood(t *testing.T) { + // Header 0x14: route=0/TRANSPORT_FLOOD, payload=5/GRP_TXT + // TransportCodes: AABBCCDD (4 bytes) + // PathByte 0x01: 1 hop, 1-byte hash + // PathHop: EE + // Payload: FF00 + b := BuildBreakdown("14AABBCCDD01EEFF00") + assertRange(t, b.Ranges, "Header", 0, 0) + assertRange(t, b.Ranges, "Transport Codes", 1, 4) + assertRange(t, b.Ranges, "Path Length", 5, 5) + assertRange(t, b.Ranges, "Path", 6, 6) + assertRange(t, b.Ranges, "Payload", 7, 8) +} + +func TestBuildBreakdown_FloodNoHops(t *testing.T) { + // Header 0x15: FLOOD/GRP_TXT; PathByte 0x00: 0 hops; Payload: AABB + b := BuildBreakdown("150000AABB") + assertRange(t, b.Ranges, "Header", 0, 0) + assertRange(t, b.Ranges, "Path Length", 1, 1) + // No Path range since hashCount=0 + for _, r := range b.Ranges { + if r.Label == "Path" { + t.Error("expected no Path range for zero-hop packet") + } + } + assertRange(t, b.Ranges, "Payload", 2, 4) +} + +func TestBuildBreakdown_AdvertBasic(t *testing.T) { + // Header 0x11: FLOOD/ADVERT + // PathByte 0x01: 1 hop, 1-byte hash + // PathHop: AA + // Payload: 100 bytes (PubKey32 + Timestamp4 + Signature64) + Flags=0x02 (repeater, no extras) + pubkey := repeatHex("AB", 32) + ts := "00000000" // 4 bytes + sig := repeatHex("CD", 64) + flags := "02" + hex := "1101AA" + pubkey + ts + sig + flags + b := BuildBreakdown(hex) + assertRange(t, b.Ranges, "Header", 0, 0) + assertRange(t, b.Ranges, "Path Length", 1, 1) + assertRange(t, b.Ranges, "Path", 2, 2) + assertRange(t, b.Ranges, "PubKey", 3, 34) + assertRange(t, b.Ranges, "Timestamp", 35, 38) + assertRange(t, b.Ranges, "Signature", 39, 102) + assertRange(t, b.Ranges, "Flags", 103, 103) +} + +func TestBuildBreakdown_AdvertWithLocation(t *testing.T) { + // flags=0x12: hasLocation bit set + pubkey := repeatHex("00", 32) + ts := "00000000" + sig := repeatHex("00", 64) + flags := "12" // 0x10 = hasLocation + latBytes := "00000000" + lonBytes := "00000000" + hex := "1101AA" + pubkey + ts + sig + flags + latBytes + lonBytes + b := BuildBreakdown(hex) + assertRange(t, b.Ranges, "Latitude", 104, 107) + assertRange(t, b.Ranges, "Longitude", 108, 111) +} + +func TestBuildBreakdown_AdvertWithName(t *testing.T) { + // flags=0x82: hasName bit set + pubkey := repeatHex("00", 32) + ts := "00000000" + sig := repeatHex("00", 64) + flags := "82" // 0x80 = hasName + name := "4E6F6465" // "Node" in hex + hex := "1101AA" + pubkey + ts + sig + flags + name + b := BuildBreakdown(hex) + assertRange(t, b.Ranges, "Name", 104, 107) +} + +// helpers + +func rangeLabels(ranges []HexRange) []string { + out := make([]string, len(ranges)) + for i, r := range ranges { + out[i] = r.Label + } + return out +} + +func equalLabels(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func assertRange(t *testing.T, ranges []HexRange, label string, wantStart, wantEnd int) { + t.Helper() + for _, r := range ranges { + if r.Label == label { + if r.Start != wantStart || r.End != wantEnd { + t.Errorf("range %q: want [%d,%d], got [%d,%d]", label, wantStart, wantEnd, r.Start, r.End) + } + return + } + } + t.Errorf("range %q not found in %v", label, rangeLabels(ranges)) +} + +func repeatHex(byteHex string, n int) string { + s := "" + for i := 0; i < n; i++ { + s += byteHex + } + return s +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 87e5f71b..85ba5da6 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -761,10 +761,11 @@ func (s *Server) handlePacketDetail(w http.ResponseWriter, r *http.Request) { pathHops = []interface{}{} } + rawHex, _ := packet["raw_hex"].(string) writeJSON(w, PacketDetailResponse{ Packet: packet, Path: pathHops, - Breakdown: struct{}{}, + Breakdown: BuildBreakdown(rawHex), ObservationCount: observationCount, Observations: mapSliceToObservations(observations), }) diff --git a/cmd/server/types.go b/cmd/server/types.go index 559e1cc6..2da4ca80 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -289,7 +289,7 @@ type PacketTimestampsResponse struct { type PacketDetailResponse struct { Packet interface{} `json:"packet"` Path []interface{} `json:"path"` - Breakdown interface{} `json:"breakdown"` + Breakdown *Breakdown `json:"breakdown"` ObservationCount int `json:"observation_count"` Observations []ObservationResp `json:"observations,omitempty"` } diff --git a/public/packets.js b/public/packets.js index 18432a62..a3ce5324 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1689,7 +1689,7 @@ let rows = ''; // Header section - rows += sectionRow('Header'); + rows += sectionRow('Header', 'section-header'); rows += fieldRow(0, 'Header Byte', '0x' + (buf.slice(0, 2) || '??'), `Route: ${routeTypeName(pkt.route_type)}, Payload: ${payloadTypeName(pkt.payload_type)}`); const pathByte0 = parseInt(buf.slice(2, 4), 16); const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1); @@ -1699,7 +1699,7 @@ // Transport codes let off = 2; if (pkt.route_type === 0 || pkt.route_type === 3) { - rows += sectionRow('Transport Codes'); + rows += sectionRow('Transport Codes', 'section-transport'); rows += fieldRow(off, 'Next Hop', buf.slice(off * 2, (off + 2) * 2), ''); rows += fieldRow(off + 2, 'Last Hop', buf.slice((off + 2) * 2, (off + 4) * 2), ''); off += 4; @@ -1707,7 +1707,7 @@ // Path if (pathHops.length > 0) { - rows += sectionRow('Path (' + pathHops.length + ' hops)'); + rows += sectionRow('Path (' + pathHops.length + ' hops)', 'section-path'); const pathByte = parseInt(buf.slice(2, 4), 16); const hashSize = (pathByte >> 6) + 1; for (let i = 0; i < pathHops.length; i++) { @@ -1719,7 +1719,7 @@ } // Payload - rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type)); + rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type), 'section-payload'); if (decoded.type === 'ADVERT') { rows += fieldRow(1, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(2, 4) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1)); @@ -1769,8 +1769,8 @@ `; } - function sectionRow(label) { - return `${label}`; + function sectionRow(label, cls) { + return `${label}`; } function fieldRow(offset, name, value, desc) { return `${offset}${name}${value}${desc || ''}`; diff --git a/public/style.css b/public/style.css index 426c2e9a..2e98cfdb 100644 --- a/public/style.css +++ b/public/style.css @@ -375,6 +375,10 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible background: var(--section-bg, #eef2ff); font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--accent); } +.field-table .section-header td { background: rgba(243,139,168,0.18); } +.field-table .section-transport td { background: rgba(137,180,250,0.18); } +.field-table .section-path td { background: rgba(166,227,161,0.18); } +.field-table .section-payload td { background: rgba(249,226,175,0.18); } /* === Path display === */ .path-hops {