Compare commits

...

5 Commits

Author SHA1 Message Date
you
31745f9edc fix: add section colors to THEME_CSS_MAP, trailing bytes label, and missing tests
- Add section color CSS variables to THEME_CSS_MAP in customize.js
- Add AppData catch-all label for trailing bytes when hasName flag is not set
- Add test: ADVERT with payload < 100 bytes (generic Payload branch)
- Add test: ADVERT with combined location + name flags
- Add test: trailing bytes without name flag get AppData label
2026-04-02 23:50:43 +00:00
you
a89b577ce5 fix: use CSS variables for section colors, fix overlap test, remove redundant repeatHex
- style.css: section-row rules now use var(--section-*-bg) instead of
  hardcoded rgba() values, completing the review feedback fix
- decoder_test.go: overlap check condition was too strict (required both
  <= AND <, effectively only catching <). Fixed to just <=
- decoder_test.go: replaced custom repeatHex() with strings.Repeat()
  from stdlib (DRY)
2026-04-02 23:44:50 +00:00
you
6cd616bcef fix: address PR #500 review feedback
- Replace hardcoded rgba() section colors with CSS variables defined in :root
  and both dark theme blocks
- Label previously unlabeled ADVERT flag bytes 0x20/0x40 as Feature1/Feature2
- Extract shared cleanHex() and parsePacketFrame() to eliminate DRY violation
  between DecodePacket and BuildBreakdown
- Add tests: combined ADVERT flags, feat1-only ADVERT, TRANSPORT_DIRECT
  breakdown and decode, simple payload breakdown
2026-04-02 23:42:41 +00:00
you
1d1cd46d3b test: add Playwright E2E tests for hex breakdown colors (#329)
Convert the manual test plan from the PR description into actual
Playwright tests that verify:
- Hex dump shows color-coded spans (not monochrome)
- Hex legend appears with color swatch items
- Field breakdown table section rows have tinted color classes
2026-04-02 23:42:41 +00:00
efiten
bc92b8b5c9 fix: restore color-coded hex breakdown in packet detail (#329)
The buildBreakdown function was never ported when the Go backend replaced
Node.js. The server has returned breakdown:{} since the Go migration,
causing createColoredHexDump() and buildHexLegend() to render everything
as monochrome.

- Add BuildBreakdown() to decoder.go: computes labeled byte ranges for
  Header, Transport Codes, Path Length, Path, and Payload sections.
  ADVERT payloads are further broken down into PubKey, Timestamp,
  Signature, Flags, Latitude, Longitude, and Name sub-ranges.
- Wire into handlePacketDetail in routes.go (was struct{}{}).
- Update PacketDetailResponse.Breakdown from interface{} to *Breakdown.
- Add per-section CSS classes (section-header/transport/path/payload) to
  sectionRow() in packets.js so the field breakdown table also carries
  distinct background tints per section.
- Add matching CSS rules in style.css.
- 8 new tests covering all section types, transport routes, zero-hop
  packets, and ADVERT sub-fields (location, name).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 23:42:41 +00:00
8 changed files with 553 additions and 36 deletions

View File

@@ -163,6 +163,62 @@ func isTransportRoute(routeType int) bool {
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
}
// cleanHex removes whitespace from a hex string.
func cleanHex(s string) string {
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, "\r", "")
return s
}
// packetFrame holds parsed packet frame offsets used by both DecodePacket and BuildBreakdown.
type packetFrame struct {
buf []byte
header Header
hasTransport bool
transportOffset int // start of transport codes (if present)
pathOffset int // offset of path length byte
pathByte byte
hashSize int
hashCount int
pathDataOffset int // start of path hop data
payloadOffset int // start of payload
}
// parsePacketFrame parses the common packet frame structure (header, transport codes, path).
// Returns nil if the packet is too short.
func parsePacketFrame(buf []byte) *packetFrame {
if len(buf) < 2 {
return nil
}
f := &packetFrame{buf: buf}
f.header = decodeHeader(buf[0])
offset := 1
f.hasTransport = isTransportRoute(f.header.RouteType)
if f.hasTransport {
if len(buf) < offset+4 {
return nil
}
f.transportOffset = offset
offset += 4
}
if offset >= len(buf) {
return nil
}
f.pathOffset = offset
f.pathByte = buf[offset]
offset++
f.hashSize = int(f.pathByte>>6) + 1
f.hashCount = int(f.pathByte & 0x3F)
f.pathDataOffset = offset
offset += f.hashSize * f.hashCount
f.payloadOffset = offset
return f
}
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)}
@@ -334,49 +390,34 @@ func decodePayload(payloadType int, buf []byte) Payload {
// DecodePacket decodes a hex-encoded MeshCore packet.
func DecodePacket(hexString string) (*DecodedPacket, error) {
hexString = strings.ReplaceAll(hexString, " ", "")
hexString = strings.ReplaceAll(hexString, "\n", "")
hexString = strings.ReplaceAll(hexString, "\r", "")
hexString = cleanHex(hexString)
buf, err := hex.DecodeString(hexString)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(buf) < 2 {
return nil, fmt.Errorf("packet too short (need at least header + pathLength)")
}
header := decodeHeader(buf[0])
offset := 1
f := parsePacketFrame(buf)
if f == nil {
return nil, fmt.Errorf("packet too short")
}
var tc *TransportCodes
if isTransportRoute(header.RouteType) {
if len(buf) < offset+4 {
return nil, fmt.Errorf("packet too short for transport codes")
}
if f.hasTransport {
tc = &TransportCodes{
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
Code1: strings.ToUpper(hex.EncodeToString(buf[f.transportOffset : f.transportOffset+2])),
Code2: strings.ToUpper(hex.EncodeToString(buf[f.transportOffset+2 : f.transportOffset+4])),
}
offset += 4
}
if offset >= len(buf) {
return nil, fmt.Errorf("packet too short (no path byte)")
}
pathByte := buf[offset]
offset++
path, bytesConsumed := decodePath(pathByte, buf, offset)
offset += bytesConsumed
payloadBuf := buf[offset:]
payload := decodePayload(header.PayloadType, payloadBuf)
path, _ := decodePath(f.pathByte, buf, f.pathDataOffset)
payloadBuf := buf[f.payloadOffset:]
payload := decodePayload(f.header.PayloadType, payloadBuf)
// 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.
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
if f.header.PayloadType == PayloadTRACE && payload.PathData != "" {
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && path.HashSize > 0 {
hops := make([]string, 0, len(pathBytes)/path.HashSize)
@@ -389,7 +430,7 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
}
return &DecodedPacket{
Header: header,
Header: f.header,
TransportCodes: tc,
Path: path,
Payload: payload,
@@ -397,6 +438,94 @@ 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/packets.js).
func BuildBreakdown(hexString string) *Breakdown {
hexString = cleanHex(hexString)
buf, err := hex.DecodeString(hexString)
if err != nil || len(buf) < 2 {
return &Breakdown{Ranges: []HexRange{}}
}
f := parsePacketFrame(buf)
if f == nil {
return &Breakdown{Ranges: []HexRange{{Start: 0, End: 0, Label: "Header"}}}
}
var ranges []HexRange
// Header byte
ranges = append(ranges, HexRange{Start: 0, End: 0, Label: "Header"})
// Transport codes
if f.hasTransport {
ranges = append(ranges, HexRange{Start: f.transportOffset, End: f.transportOffset + 3, Label: "Transport Codes"})
}
// Path length byte
ranges = append(ranges, HexRange{Start: f.pathOffset, End: f.pathOffset, Label: "Path Length"})
// Path hops
pathBytes := f.hashSize * f.hashCount
if f.hashCount > 0 && f.pathDataOffset+pathBytes <= len(buf) {
ranges = append(ranges, HexRange{Start: f.pathDataOffset, End: f.pathDataOffset + pathBytes - 1, Label: "Path"})
}
if f.payloadOffset >= len(buf) {
return &Breakdown{Ranges: ranges}
}
// Payload — break ADVERT into named sub-fields; everything else is one Payload range
if f.header.PayloadType == PayloadADVERT && len(buf)-f.payloadOffset >= 100 {
ps := f.payloadOffset
ranges = append(ranges, HexRange{Start: ps, End: ps + 31, Label: "PubKey"})
ranges = append(ranges, HexRange{Start: ps + 32, End: ps + 35, Label: "Timestamp"})
ranges = append(ranges, HexRange{Start: ps + 36, End: ps + 99, Label: "Signature"})
appStart := ps + 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) {
ranges = append(ranges, HexRange{Start: fOff, End: fOff + 1, Label: "Feature1"})
fOff += 2
}
if appFlags&0x40 != 0 && fOff+2 <= len(buf) {
ranges = append(ranges, HexRange{Start: fOff, End: fOff + 1, Label: "Feature2"})
fOff += 2
}
if appFlags&0x80 != 0 && fOff < len(buf) {
ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "Name"})
} else if fOff < len(buf) {
ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "AppData"})
}
}
} else {
ranges = append(ranges, HexRange{Start: f.payloadOffset, 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)

View File

@@ -1,6 +1,7 @@
package main
import (
"strings"
"testing"
)
@@ -93,3 +94,323 @@ 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 := strings.Repeat("AB", 32)
ts := "00000000" // 4 bytes
sig := strings.Repeat("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 := strings.Repeat("00", 32)
ts := "00000000"
sig := strings.Repeat("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 := strings.Repeat("00", 32)
ts := "00000000"
sig := strings.Repeat("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))
}
// --- BuildBreakdown tests (PR #500 review feedback) ---
func TestBuildBreakdown_SimplePayload(t *testing.T) {
// Header 0x11 = ADVERT + ZERO_HOP, path byte 0x00 = no hops
// Payload < 100 bytes → single "Payload" range
h := "1100" + strings.Repeat("AB", 10)
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
expect := []string{"Header", "Path Length", "Payload"}
if len(labels) != len(expect) {
t.Fatalf("expected %v, got %v", expect, labels)
}
for i, e := range expect {
if labels[i] != e {
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
}
}
}
func TestBuildBreakdown_TransportDirect(t *testing.T) {
// TXT_MSG (0x01) + TRANSPORT_DIRECT (route 3) = 0x07
h := "07" + "AABBCCDD" + "00" + strings.Repeat("EE", 5)
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
if len(labels) < 4 {
t.Fatalf("expected ≥4 ranges, got %v", labels)
}
if labels[1] != "Transport Codes" {
t.Errorf("expected Transport Codes, got %s", labels[1])
}
if bd.Ranges[1].Start != 1 || bd.Ranges[1].End != 4 {
t.Errorf("transport range wrong: %d-%d", bd.Ranges[1].Start, bd.Ranges[1].End)
}
}
func TestBuildBreakdown_AdvertAllFlags(t *testing.T) {
// ADVERT + ZERO_HOP = 0x11, path 0x00
// flags 0xF2 = location(0x10) + feat1(0x20) + feat2(0x40) + name(0x80) + type 2
pubkey := strings.Repeat("AA", 32)
ts := "01020304"
sig := strings.Repeat("BB", 64)
flags := "F2"
loc := "0100000002000000"
feat1 := "C1C2"
feat2 := "D1D2"
name := strings.Repeat("48", 5)
h := "11" + "00" + pubkey + ts + sig + flags + loc + feat1 + feat2 + name
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
"Flags", "Latitude", "Longitude", "Feature1", "Feature2", "Name"}
if len(labels) != len(expect) {
t.Fatalf("expected %v, got %v", expect, labels)
}
for i, e := range expect {
if labels[i] != e {
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
}
}
// Verify no overlaps
for i := 1; i < len(bd.Ranges); i++ {
if bd.Ranges[i].Start <= bd.Ranges[i-1].End {
t.Errorf("overlap: %s [%d-%d] and %s [%d-%d]",
bd.Ranges[i-1].Label, bd.Ranges[i-1].Start, bd.Ranges[i-1].End,
bd.Ranges[i].Label, bd.Ranges[i].Start, bd.Ranges[i].End)
}
}
// Feature1 & Feature2 are each 2 bytes
if sz := bd.Ranges[8].End - bd.Ranges[8].Start + 1; sz != 2 {
t.Errorf("Feature1 should be 2 bytes, got %d", sz)
}
if sz := bd.Ranges[9].End - bd.Ranges[9].Start + 1; sz != 2 {
t.Errorf("Feature2 should be 2 bytes, got %d", sz)
}
}
func TestBuildBreakdown_AdvertFeat1Only(t *testing.T) {
// flags 0xA1 = feat1(0x20) + name(0x80) + type 1, no location
pubkey := strings.Repeat("AA", 32)
ts := "01020304"
sig := strings.Repeat("BB", 64)
h := "11" + "00" + pubkey + ts + sig + "A1" + "F1F2" + strings.Repeat("4E", 4)
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
"Flags", "Feature1", "Name"}
if len(labels) != len(expect) {
t.Fatalf("expected %v, got %v", expect, labels)
}
for i, e := range expect {
if labels[i] != e {
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
}
}
}
func TestDecodePacket_TransportDirect(t *testing.T) {
h := "07" + "AABBCCDD" + "00" + strings.Repeat("EE", 5)
pkt, err := DecodePacket(h)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pkt.Header.RouteType != RouteTransportDirect {
t.Errorf("expected route %d, got %d", RouteTransportDirect, pkt.Header.RouteType)
}
if pkt.TransportCodes == nil {
t.Fatal("expected transport codes")
}
if pkt.TransportCodes.Code1 != "AABB" {
t.Errorf("Code1: expected AABB, got %s", pkt.TransportCodes.Code1)
}
}
func TestBuildBreakdown_AdvertShortPayload(t *testing.T) {
// ADVERT with payload < 100 bytes should get generic "Payload" label, not sub-ranges
h := "11" + "00" + strings.Repeat("FF", 50) // header + path + 50 bytes payload (< 100)
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
// Should have Header, Path Length, Payload — no PubKey/Timestamp/Signature sub-ranges
if labels[len(labels)-1] != "Payload" {
t.Errorf("expected last range to be 'Payload', got %q", labels[len(labels)-1])
}
for _, l := range labels {
if l == "PubKey" || l == "Timestamp" || l == "Signature" || l == "Flags" {
t.Errorf("unexpected sub-range %q in short ADVERT", l)
}
}
}
func TestBuildBreakdown_AdvertLocationAndName(t *testing.T) {
// flags 0x91 = location(0x10) + name(0x80) + type 1
pubkey := strings.Repeat("AA", 32)
ts := "01020304"
sig := strings.Repeat("BB", 64)
lat := "11223344"
lon := "55667788"
name := strings.Repeat("4E", 6) // "NNNNNN"
h := "11" + "00" + pubkey + ts + sig + "91" + lat + lon + name
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
"Flags", "Latitude", "Longitude", "Name"}
if len(labels) != len(expect) {
t.Fatalf("expected %v, got %v", expect, labels)
}
for i, e := range expect {
if labels[i] != e {
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
}
}
}
func TestBuildBreakdown_AdvertTrailingBytesNoName(t *testing.T) {
// flags 0x11 = location(0x10) + type 1, NO name bit — trailing bytes should be "AppData"
pubkey := strings.Repeat("AA", 32)
ts := "01020304"
sig := strings.Repeat("BB", 64)
lat := "11223344"
lon := "55667788"
trailing := "DEADBEEF"
h := "11" + "00" + pubkey + ts + sig + "11" + lat + lon + trailing
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
lastLabel := labels[len(labels)-1]
if lastLabel != "AppData" {
t.Errorf("expected trailing bytes labeled 'AppData', got %q", lastLabel)
}
}
// rangeLabels is defined earlier in this file

View File

@@ -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),
})

View File

@@ -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"`
}

View File

@@ -100,6 +100,11 @@
selectedBg: '--selected-bg',
font: '--font',
mono: '--mono',
// Hex breakdown section colors
sectionHeaderBg: '--section-header-bg',
sectionTransportBg: '--section-transport-bg',
sectionPathBg: '--section-path-bg',
sectionPayloadBg: '--section-payload-bg',
};
/* ── Theme Presets ── */

View File

@@ -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 @@
</table>`;
}
function sectionRow(label) {
return `<tr class="section-row"><td colspan="4">${label}</td></tr>`;
function sectionRow(label, cls) {
return `<tr class="section-row${cls ? ' ' + cls : ''}"><td colspan="4">${label}</td></tr>`;
}
function fieldRow(offset, name, value, desc) {
return `<tr><td class="mono">${offset}</td><td>${name}</td><td class="mono">${value}</td><td class="text-muted">${desc || ''}</td></tr>`;

View File

@@ -30,6 +30,10 @@
--content-bg: var(--surface-0);
--card-bg: var(--surface-1);
--hover-bg: rgba(0,0,0, 0.04);
--section-header-bg: rgba(243,139,168,0.18);
--section-transport-bg: rgba(137,180,250,0.18);
--section-path-bg: rgba(166,227,161,0.18);
--section-payload-bg: rgba(249,226,175,0.18);
}
/* ⚠️ DARK THEME VARIABLES — KEEP BOTH BLOCKS IN SYNC
@@ -56,6 +60,10 @@
--selected-bg: #1e3a5f;
--hover-bg: rgba(255,255,255, 0.06);
--section-bg: #1e1e34;
--section-header-bg: rgba(243,139,168,0.15);
--section-transport-bg: rgba(137,180,250,0.15);
--section-path-bg: rgba(166,227,161,0.15);
--section-payload-bg: rgba(249,226,175,0.15);
}
}
/* ⚠️ DARK THEME VARIABLES — KEEP IN SYNC with @media block above */
@@ -79,6 +87,10 @@
--selected-bg: #1e3a5f;
--hover-bg: rgba(255,255,255, 0.06);
--section-bg: #1e1e34;
--section-header-bg: rgba(243,139,168,0.15);
--section-transport-bg: rgba(137,180,250,0.15);
--section-path-bg: rgba(166,227,161,0.15);
--section-payload-bg: rgba(249,226,175,0.15);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -375,6 +387,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: var(--section-header-bg); }
.field-table .section-transport td { background: var(--section-transport-bg); }
.field-table .section-path td { background: var(--section-path-bg); }
.field-table .section-payload td { background: var(--section-payload-bg); }
/* === Path display === */
.path-hops {

View File

@@ -543,6 +543,51 @@ async function run() {
assert(hasChannelHash, 'Undecrypted GRP_TXT detail should show "Channel Hash"');
});
// --- Group: Hex breakdown colors (#329) ---
await test('Packet detail hex dump shows color-coded sections', async () => {
// Find any packet with raw_hex via API
const hash = await page.evaluate(async () => {
try {
const res = await fetch('/api/packets?limit=100');
const data = await res.json();
for (const p of (data.packets || [])) {
if (p.raw_hex && p.raw_hex.length > 10) return p.hash;
}
} catch {}
return null;
});
if (!hash) { console.log(' ⏭️ Skipped (no packets with raw_hex found)'); return; }
await page.goto(`${BASE}/#/packets/${hash}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
const panel = document.getElementById('pktRight');
if (!panel || panel.classList.contains('empty')) return false;
return panel.textContent.length > 50 && !panel.textContent.includes('Loading');
}, { timeout: 8000 });
// Verify hex dump has colored spans (not monochrome)
const coloredSpans = await page.$$eval('.hex-dump span[class*="hex-"]', els => els.length);
assert(coloredSpans > 0, 'Hex dump should have color-coded spans with hex-* classes');
});
await test('Packet detail shows hex legend with color swatches', async () => {
// Re-use the packet detail page from previous test
const legendItems = await page.$$eval('.hex-legend .legend-item, .hex-legend span', els => els.length);
assert(legendItems > 0, 'Hex legend should show color swatch items');
});
await test('Field breakdown table has tinted section rows', async () => {
// Check that section-row elements have per-section color classes
const sectionClasses = await page.$$eval('.field-table .section-row', rows =>
rows.map(r => r.className)
);
assert(sectionClasses.length > 0, 'Field table should have section rows');
const hasTinted = sectionClasses.some(c =>
c.includes('section-header') || c.includes('section-transport') ||
c.includes('section-path') || c.includes('section-payload')
);
assert(hasTinted, 'Section rows should have tinted color classes (section-header, section-path, etc.)');
});
// --- Group: Analytics page (test 8 + sub-tabs) ---
// Test 8: Analytics page loads with overview