mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-11 23:18:55 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31745f9edc | |||
| a89b577ce5 | |||
| 6cd616bcef | |||
| 1d1cd46d3b | |||
| bc92b8b5c9 | |||
| 016b87b33c | |||
| 889107a5e1 | |||
| 50f94603c1 | |||
| b799f54700 | |||
| d5b300a8ba | |||
| 2af4259eca | |||
| bf2e721dd7 | |||
| f20431d816 | |||
| f9cfad9cd4 | |||
| 96d0bbe487 | |||
| 6712da7d7c | |||
| 6aef83c82a | |||
| 9f14c74b3e | |||
| 0b8b1e91a6 | |||
| c678555e75 | |||
| 623ebc879b | |||
| 0b1924d401 | |||
| 0f502370c5 | |||
| e47c39ffda | |||
| 1499a55ba7 | |||
| f71e117cdd |
@@ -246,6 +246,12 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Free disk space
|
||||
run: |
|
||||
docker system prune -af 2>/dev/null || true
|
||||
docker builder prune -af 2>/dev/null || true
|
||||
df -h /
|
||||
|
||||
- name: Build Go Docker image
|
||||
run: |
|
||||
echo "${GITHUB_SHA::7}" > .git-commit
|
||||
|
||||
@@ -33,7 +33,7 @@ public/ — Frontend (vanilla JS, one file per page) — ACTIVE, NOT
|
||||
style.css — Main styles, CSS variables for theming
|
||||
live.css — Live page styles
|
||||
home.css — Home page styles
|
||||
index.html — SPA shell, script/style tags with cache busters
|
||||
index.html — SPA shell, script/style tags with __BUST__ placeholder (auto-replaced at server startup)
|
||||
test-fixtures/ — Real data SQLite fixture from staging (used for E2E tests)
|
||||
scripts/ — Tooling (coverage collector, fixture capture, frontend instrumentation)
|
||||
```
|
||||
@@ -84,12 +84,8 @@ Every change that touches logic MUST have tests. For Go backend: `cd cmd/server
|
||||
### 2. No commit without browser validation
|
||||
After pushing, verify the change works in an actual browser. Use `browser profile=openclaw` against the running instance. Take a screenshot if the change is visual. If you can't validate it, say so — don't claim it works.
|
||||
|
||||
### 3. Cache busters — ALWAYS bump them
|
||||
Every time you change a `.js` or `.css` file in `public/`, bump the cache buster in `index.html`. This has caused 7 separate production regressions. Use:
|
||||
```bash
|
||||
NEWV=$(date +%s) && sed -i "s/v=[0-9]*/v=$NEWV/g" public/index.html
|
||||
```
|
||||
Do this in the SAME commit as the code change, not as a follow-up.
|
||||
### 3. Cache busters are automatic — do NOT manually edit them
|
||||
Cache busters are injected automatically by the Go server at startup. The `__BUST__` placeholder in `index.html` is replaced with a Unix timestamp when the server reads the file. No manual bumping needed — every server restart picks up new asset versions. Do NOT replace `__BUST__` with hardcoded timestamps.
|
||||
|
||||
### 4. Verify API response shape before building UI
|
||||
Before writing client code that consumes an API endpoint, check what the endpoint ACTUALLY returns. Use `curl` or check the server code. Don't assume fields exist — grouped packets (`groupByHash=true`) have different fields than raw packets. This has caused multiple breakages.
|
||||
@@ -351,7 +347,7 @@ One logical change per commit. Each commit is deployable. Each commit has its te
|
||||
|
||||
| Pitfall | Times it happened | Prevention |
|
||||
|---------|-------------------|------------|
|
||||
| Forgot cache busters | 7 | Always bump in same commit |
|
||||
| Forgot cache busters | 7 | Now automatic — `__BUST__` replaced at server startup |
|
||||
| Grouped packets missing fields | 3 | curl the actual API first |
|
||||
| last_seen vs last_heard mismatch | 4 | Always use `last_heard \|\| last_seen` |
|
||||
| CSS selectors don't match SVG | 2 | Manipulate SVG in JS after generation |
|
||||
|
||||
+12
-3
@@ -36,8 +36,9 @@ type Store struct {
|
||||
stmtUpsertNode *sql.Stmt
|
||||
stmtIncrementAdvertCount *sql.Stmt
|
||||
stmtUpsertObserver *sql.Stmt
|
||||
stmtGetObserverRowid *sql.Stmt
|
||||
stmtUpdateNodeTelemetry *sql.Stmt
|
||||
stmtGetObserverRowid *sql.Stmt
|
||||
stmtUpdateObserverLastSeen *sql.Stmt
|
||||
stmtUpdateNodeTelemetry *sql.Stmt
|
||||
}
|
||||
|
||||
// OpenStore opens or creates a SQLite DB at the given path, applying the
|
||||
@@ -369,6 +370,11 @@ func (s *Store) prepareStatements() error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.stmtUpdateObserverLastSeen, err = s.db.Prepare("UPDATE observers SET last_seen = ? WHERE rowid = ?")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.stmtUpdateNodeTelemetry, err = s.db.Prepare(`
|
||||
UPDATE nodes SET
|
||||
battery_mv = COALESCE(?, battery_mv),
|
||||
@@ -428,13 +434,16 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
|
||||
s.Stats.DuplicateTransmissions.Add(1)
|
||||
}
|
||||
|
||||
// Resolve observer_idx
|
||||
// Resolve observer_idx and update last_seen
|
||||
var observerIdx *int64
|
||||
if data.ObserverID != "" {
|
||||
var rowid int64
|
||||
err := s.stmtGetObserverRowid.QueryRow(data.ObserverID).Scan(&rowid)
|
||||
if err == nil {
|
||||
observerIdx = &rowid
|
||||
// Update observer last_seen on every packet to prevent
|
||||
// low-traffic observers from appearing offline (#463)
|
||||
_, _ = s.stmtUpdateObserverLastSeen.Exec(now, rowid)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -516,6 +516,56 @@ func TestInsertTransmissionWithObserver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// #463: Verify that inserting a packet updates the observer's last_seen,
|
||||
// so low-traffic observers don't incorrectly appear offline.
|
||||
func TestInsertTransmissionUpdatesObserverLastSeen(t *testing.T) {
|
||||
s, err := OpenStore(tempDBPath(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
// Insert observer with an old last_seen
|
||||
if err := s.UpsertObserver("obs1", "Observer1", "SJC", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Backdate last_seen to 2 hours ago
|
||||
oldTime := "2026-03-24T22:00:00Z"
|
||||
s.db.Exec("UPDATE observers SET last_seen = ? WHERE id = ?", oldTime, "obs1")
|
||||
|
||||
// Verify it was backdated
|
||||
var lastSeenBefore string
|
||||
s.db.QueryRow("SELECT last_seen FROM observers WHERE id = ?", "obs1").Scan(&lastSeenBefore)
|
||||
if lastSeenBefore != oldTime {
|
||||
t.Fatalf("expected last_seen=%s, got %s", oldTime, lastSeenBefore)
|
||||
}
|
||||
|
||||
// Insert a packet from this observer
|
||||
data := &PacketData{
|
||||
RawHex: "0A00D69F",
|
||||
Timestamp: "2026-03-25T01:00:00Z",
|
||||
ObserverID: "obs1",
|
||||
Hash: "lastseentest123456",
|
||||
RouteType: 2,
|
||||
PayloadType: 2,
|
||||
PathJSON: "[]",
|
||||
DecodedJSON: `{"type":"TXT_MSG"}`,
|
||||
}
|
||||
if _, err := s.InsertTransmission(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify last_seen was updated
|
||||
var lastSeenAfter string
|
||||
s.db.QueryRow("SELECT last_seen FROM observers WHERE id = ?", "obs1").Scan(&lastSeenAfter)
|
||||
if lastSeenAfter == oldTime {
|
||||
t.Error("observer last_seen was NOT updated after packet insertion — low-traffic observers will appear offline")
|
||||
}
|
||||
if lastSeenAfter != "2026-03-25T01:00:00Z" {
|
||||
t.Errorf("expected last_seen=2026-03-25T01:00:00Z, got %s", lastSeenAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndToEndIngest(t *testing.T) {
|
||||
s, err := OpenStore(tempDBPath(t))
|
||||
if err != nil {
|
||||
|
||||
+157
-28
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -326,6 +327,84 @@ func TestSpaHandler(t *testing.T) {
|
||||
t.Errorf("expected no-cache header for .html, got %s", cc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("root path serves index.html", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if body != "<html>SPA</html>" {
|
||||
t.Errorf("expected SPA index.html content, got %s", body)
|
||||
}
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("expected text/html content type, got %s", ct)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("/index.html serves pre-processed content", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if body != "<html>SPA</html>" {
|
||||
t.Errorf("expected SPA index.html content, got %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpaHandlerCacheBust(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
htmlWithBust := `<html><script src="app.js?v=__BUST__"></script><link href="style.css?v=__BUST__"></html>`
|
||||
os.WriteFile(filepath.Join(dir, "index.html"), []byte(htmlWithBust), 0644)
|
||||
|
||||
fs := http.FileServer(http.Dir(dir))
|
||||
handler := spaHandler(dir, fs)
|
||||
|
||||
t.Run("__BUST__ is replaced with a Unix timestamp", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "__BUST__") {
|
||||
t.Errorf("__BUST__ placeholder was not replaced in response: %s", body)
|
||||
}
|
||||
// Verify it was replaced with digits (Unix timestamp)
|
||||
if !strings.Contains(body, "v=") {
|
||||
t.Errorf("expected v= query params in response, got: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SPA fallback also has busted values", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/nonexistent/route", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "__BUST__") {
|
||||
t.Errorf("__BUST__ placeholder was not replaced in SPA fallback: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("/index.html also has busted values", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "__BUST__") {
|
||||
t.Errorf("__BUST__ placeholder was not replaced for /index.html: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteJSON(t *testing.T) {
|
||||
@@ -345,3 +424,29 @@ func TestWriteJSON(t *testing.T) {
|
||||
t.Errorf("expected 'value', got %v", body["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHaversineKm(t *testing.T) {
|
||||
// Same point should be 0
|
||||
if d := haversineKm(37.0, -122.0, 37.0, -122.0); d != 0 {
|
||||
t.Errorf("same point: expected 0, got %f", d)
|
||||
}
|
||||
|
||||
// SF to LA ~559km
|
||||
d := haversineKm(37.7749, -122.4194, 34.0522, -118.2437)
|
||||
if d < 550 || d > 570 {
|
||||
t.Errorf("SF to LA: expected ~559km, got %f", d)
|
||||
}
|
||||
|
||||
// Symmetry
|
||||
d1 := haversineKm(37.7749, -122.4194, 34.0522, -118.2437)
|
||||
d2 := haversineKm(34.0522, -118.2437, 37.7749, -122.4194)
|
||||
if d1 != d2 {
|
||||
t.Errorf("not symmetric: %f vs %f", d1, d2)
|
||||
}
|
||||
|
||||
// Oslo to Stockholm ~415km (old Euclidean dLat*111, dLon*85 would give ~627km)
|
||||
d = haversineKm(59.9, 10.7, 59.3, 18.0)
|
||||
if d < 400 || d > 430 {
|
||||
t.Errorf("Oslo to Stockholm: expected ~415km, got %f", d)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-2
@@ -11,9 +11,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -242,11 +242,35 @@ func main() {
|
||||
}
|
||||
|
||||
// spaHandler serves static files, falling back to index.html for SPA routes.
|
||||
// It reads index.html once at creation time and replaces the __BUST__ placeholder
|
||||
// with a Unix timestamp so browsers fetch fresh JS/CSS after each server restart.
|
||||
func spaHandler(root string, fs http.Handler) http.Handler {
|
||||
// Pre-process index.html: replace __BUST__ with a cache-bust timestamp
|
||||
indexPath := filepath.Join(root, "index.html")
|
||||
rawHTML, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
log.Printf("[static] warning: could not read index.html for cache-bust: %v", err)
|
||||
rawHTML = []byte("<!DOCTYPE html><html><body><h1>CoreScope</h1><p>index.html not found</p></body></html>")
|
||||
}
|
||||
bustValue := fmt.Sprintf("%d", time.Now().Unix())
|
||||
indexHTML := []byte(strings.ReplaceAll(string(rawHTML), "__BUST__", bustValue))
|
||||
log.Printf("[static] cache-bust value: %s", bustValue)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve pre-processed index.html for root and /index.html
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Write(indexHTML)
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(root, r.URL.Path)
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
http.ServeFile(w, r, filepath.Join(root, "index.html"))
|
||||
// SPA fallback — serve pre-processed index.html
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Write(indexHTML)
|
||||
return
|
||||
}
|
||||
// Disable caching for JS/CSS/HTML
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPerfStatsConcurrentAccess verifies that concurrent writes and reads
|
||||
// to PerfStats do not trigger data races. Run with: go test -race
|
||||
func TestPerfStatsConcurrentAccess(t *testing.T) {
|
||||
ps := NewPerfStats()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 50
|
||||
const iterations = 200
|
||||
|
||||
// Concurrent writers (simulating perfMiddleware)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
ms := float64(j) * 0.5
|
||||
key := "/api/test"
|
||||
if id%2 == 0 {
|
||||
key = "/api/other"
|
||||
}
|
||||
|
||||
ps.mu.Lock()
|
||||
ps.Requests++
|
||||
ps.TotalMs += ms
|
||||
if _, ok := ps.Endpoints[key]; !ok {
|
||||
ps.Endpoints[key] = &EndpointPerf{Recent: make([]float64, 0, 100)}
|
||||
}
|
||||
ep := ps.Endpoints[key]
|
||||
ep.Count++
|
||||
ep.TotalMs += ms
|
||||
if ms > ep.MaxMs {
|
||||
ep.MaxMs = ms
|
||||
}
|
||||
ep.Recent = append(ep.Recent, ms)
|
||||
if len(ep.Recent) > 100 {
|
||||
ep.Recent = ep.Recent[1:]
|
||||
}
|
||||
if ms > 50 {
|
||||
ps.SlowQueries = append(ps.SlowQueries, SlowQuery{
|
||||
Path: key,
|
||||
Ms: ms,
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if len(ps.SlowQueries) > 50 {
|
||||
ps.SlowQueries = ps.SlowQueries[1:]
|
||||
}
|
||||
}
|
||||
ps.mu.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent readers (simulating handlePerf / handleHealth)
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
ps.mu.Lock()
|
||||
_ = ps.Requests
|
||||
_ = ps.TotalMs
|
||||
for _, ep := range ps.Endpoints {
|
||||
_ = ep.Count
|
||||
_ = ep.MaxMs
|
||||
c := make([]float64, len(ep.Recent))
|
||||
copy(c, ep.Recent)
|
||||
}
|
||||
s := make([]SlowQuery, len(ps.SlowQueries))
|
||||
copy(s, ps.SlowQueries)
|
||||
ps.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify consistency
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
expectedRequests := int64(goroutines * iterations)
|
||||
if ps.Requests != expectedRequests {
|
||||
t.Errorf("expected %d requests, got %d", expectedRequests, ps.Requests)
|
||||
}
|
||||
if len(ps.Endpoints) == 0 {
|
||||
t.Error("expected endpoints to be populated")
|
||||
}
|
||||
}
|
||||
+65
-32
@@ -42,6 +42,7 @@ type Server struct {
|
||||
|
||||
// PerfStats tracks request performance.
|
||||
type PerfStats struct {
|
||||
mu sync.Mutex
|
||||
Requests int64
|
||||
TotalMs float64
|
||||
Endpoints map[string]*EndpointPerf
|
||||
@@ -162,10 +163,7 @@ func (s *Server) perfMiddleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
ms := float64(time.Since(start).Microseconds()) / 1000.0
|
||||
|
||||
s.perfStats.Requests++
|
||||
s.perfStats.TotalMs += ms
|
||||
|
||||
// Normalize key: prefer mux route template (like Node.js req.route.path)
|
||||
// Normalize key outside lock (no shared state needed)
|
||||
key := r.URL.Path
|
||||
if route := mux.CurrentRoute(r); route != nil {
|
||||
if tmpl, err := route.GetPathTemplate(); err == nil {
|
||||
@@ -175,6 +173,11 @@ func (s *Server) perfMiddleware(next http.Handler) http.Handler {
|
||||
if key == r.URL.Path {
|
||||
key = perfHexFallback.ReplaceAllString(key, ":id")
|
||||
}
|
||||
|
||||
s.perfStats.mu.Lock()
|
||||
s.perfStats.Requests++
|
||||
s.perfStats.TotalMs += ms
|
||||
|
||||
if _, ok := s.perfStats.Endpoints[key]; !ok {
|
||||
s.perfStats.Endpoints[key] = &EndpointPerf{Recent: make([]float64, 0, 100)}
|
||||
}
|
||||
@@ -200,6 +203,7 @@ func (s *Server) perfMiddleware(next http.Handler) http.Handler {
|
||||
s.perfStats.SlowQueries = s.perfStats.SlowQueries[1:]
|
||||
}
|
||||
}
|
||||
s.perfStats.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -365,7 +369,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
lastPauseMs = float64(m.PauseNs[(m.NumGC+255)%256]) / 1e6
|
||||
}
|
||||
|
||||
// Build slow queries list
|
||||
// Build slow queries list (copy under lock)
|
||||
s.perfStats.mu.Lock()
|
||||
recentSlow := make([]SlowQuery, 0)
|
||||
sliceEnd := s.perfStats.SlowQueries
|
||||
if len(sliceEnd) > 5 {
|
||||
@@ -374,6 +379,10 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
for _, sq := range sliceEnd {
|
||||
recentSlow = append(recentSlow, sq)
|
||||
}
|
||||
perfRequests := s.perfStats.Requests
|
||||
perfTotalMs := s.perfStats.TotalMs
|
||||
perfSlowCount := len(s.perfStats.SlowQueries)
|
||||
s.perfStats.mu.Unlock()
|
||||
|
||||
writeJSON(w, HealthResponse{
|
||||
Status: "ok",
|
||||
@@ -403,9 +412,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
EstimatedMB: pktEstMB,
|
||||
},
|
||||
Perf: HealthPerfStats{
|
||||
TotalRequests: int(s.perfStats.Requests),
|
||||
AvgMs: safeAvg(s.perfStats.TotalMs, float64(s.perfStats.Requests)),
|
||||
SlowQueries: len(s.perfStats.SlowQueries),
|
||||
TotalRequests: int(perfRequests),
|
||||
AvgMs: safeAvg(perfTotalMs, float64(perfRequests)),
|
||||
SlowQueries: perfSlowCount,
|
||||
RecentSlow: recentSlow,
|
||||
},
|
||||
})
|
||||
@@ -465,22 +474,50 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handlePerf(w http.ResponseWriter, r *http.Request) {
|
||||
// Endpoint performance summary
|
||||
// Copy perfStats under lock to avoid data races
|
||||
s.perfStats.mu.Lock()
|
||||
type epSnapshot struct {
|
||||
path string
|
||||
count int
|
||||
totalMs float64
|
||||
maxMs float64
|
||||
recent []float64
|
||||
}
|
||||
epSnapshots := make([]epSnapshot, 0, len(s.perfStats.Endpoints))
|
||||
for path, ep := range s.perfStats.Endpoints {
|
||||
recentCopy := make([]float64, len(ep.Recent))
|
||||
copy(recentCopy, ep.Recent)
|
||||
epSnapshots = append(epSnapshots, epSnapshot{path, ep.Count, ep.TotalMs, ep.MaxMs, recentCopy})
|
||||
}
|
||||
uptimeSec := int(time.Since(s.perfStats.StartedAt).Seconds())
|
||||
totalRequests := s.perfStats.Requests
|
||||
totalMs := s.perfStats.TotalMs
|
||||
slowQueries := make([]SlowQuery, 0)
|
||||
sliceEnd := s.perfStats.SlowQueries
|
||||
if len(sliceEnd) > 20 {
|
||||
sliceEnd = sliceEnd[len(sliceEnd)-20:]
|
||||
}
|
||||
for _, sq := range sliceEnd {
|
||||
slowQueries = append(slowQueries, sq)
|
||||
}
|
||||
s.perfStats.mu.Unlock()
|
||||
|
||||
// Process snapshots outside lock
|
||||
type epEntry struct {
|
||||
path string
|
||||
data *EndpointStatsResp
|
||||
}
|
||||
var entries []epEntry
|
||||
for path, ep := range s.perfStats.Endpoints {
|
||||
sorted := sortedCopy(ep.Recent)
|
||||
for _, snap := range epSnapshots {
|
||||
sorted := sortedCopy(snap.recent)
|
||||
d := &EndpointStatsResp{
|
||||
Count: ep.Count,
|
||||
AvgMs: safeAvg(ep.TotalMs, float64(ep.Count)),
|
||||
Count: snap.count,
|
||||
AvgMs: safeAvg(snap.totalMs, float64(snap.count)),
|
||||
P50Ms: round(percentile(sorted, 0.5), 1),
|
||||
P95Ms: round(percentile(sorted, 0.95), 1),
|
||||
MaxMs: round(ep.MaxMs, 1),
|
||||
MaxMs: round(snap.maxMs, 1),
|
||||
}
|
||||
entries = append(entries, epEntry{path, d})
|
||||
entries = append(entries, epEntry{snap.path, d})
|
||||
}
|
||||
// Sort by total time spent (count * avg) descending, matching Node.js
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
@@ -521,22 +558,10 @@ func (s *Server) handlePerf(w http.ResponseWriter, r *http.Request) {
|
||||
sqliteStats = &ss
|
||||
}
|
||||
|
||||
uptimeSec := int(time.Since(s.perfStats.StartedAt).Seconds())
|
||||
|
||||
// Convert slow queries
|
||||
slowQueries := make([]SlowQuery, 0)
|
||||
sliceEnd := s.perfStats.SlowQueries
|
||||
if len(sliceEnd) > 20 {
|
||||
sliceEnd = sliceEnd[len(sliceEnd)-20:]
|
||||
}
|
||||
for _, sq := range sliceEnd {
|
||||
slowQueries = append(slowQueries, sq)
|
||||
}
|
||||
|
||||
writeJSON(w, PerfResponse{
|
||||
Uptime: uptimeSec,
|
||||
TotalRequests: s.perfStats.Requests,
|
||||
AvgMs: safeAvg(s.perfStats.TotalMs, float64(s.perfStats.Requests)),
|
||||
TotalRequests: totalRequests,
|
||||
AvgMs: safeAvg(totalMs, float64(totalRequests)),
|
||||
Endpoints: summary,
|
||||
SlowQueries: slowQueries,
|
||||
Cache: perfCS,
|
||||
@@ -560,7 +585,13 @@ func (s *Server) handlePerf(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handlePerfReset(w http.ResponseWriter, r *http.Request) {
|
||||
s.perfStats = NewPerfStats()
|
||||
s.perfStats.mu.Lock()
|
||||
s.perfStats.Requests = 0
|
||||
s.perfStats.TotalMs = 0
|
||||
s.perfStats.Endpoints = make(map[string]*EndpointPerf)
|
||||
s.perfStats.SlowQueries = make([]SlowQuery, 0)
|
||||
s.perfStats.StartedAt = time.Now()
|
||||
s.perfStats.mu.Unlock()
|
||||
writeJSON(w, OkResp{Ok: true})
|
||||
}
|
||||
|
||||
@@ -730,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),
|
||||
})
|
||||
@@ -1204,7 +1236,8 @@ func (s *Server) handleAnalyticsHashSizes(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func (s *Server) handleAnalyticsHashCollisions(w http.ResponseWriter, r *http.Request) {
|
||||
if s.store != nil {
|
||||
writeJSON(w, s.store.GetAnalyticsHashCollisions())
|
||||
region := r.URL.Query().Get("region")
|
||||
writeJSON(w, s.store.GetAnalyticsHashCollisions(region))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
|
||||
@@ -2680,9 +2680,9 @@ func TestHashCollisionsNoNullArrays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsRegionParamIgnored(t *testing.T) {
|
||||
// Issue #417: region param was accepted but ignored.
|
||||
// After fix, the endpoint should work without region and not cache per-region.
|
||||
func TestHashCollisionsRegionParam(t *testing.T) {
|
||||
// Issue #438: region param should be accepted and used for filtering.
|
||||
// With no region observers configured, results should be identical to global.
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
// Request without region
|
||||
@@ -2693,7 +2693,7 @@ func TestHashCollisionsRegionParamIgnored(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d", w1.Code)
|
||||
}
|
||||
|
||||
// Request with region param (should be ignored, same result)
|
||||
// Request with region param (no observers for this region, so falls back to global)
|
||||
req2 := httptest.NewRequest("GET", "/api/analytics/hash-collisions?region=us-west", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
@@ -2701,9 +2701,9 @@ func TestHashCollisionsRegionParamIgnored(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d", w2.Code)
|
||||
}
|
||||
|
||||
// Both should return identical results
|
||||
// With no region observers configured, both should return identical results
|
||||
if w1.Body.String() != w2.Body.String() {
|
||||
t.Error("responses differ with/without region param — region should be ignored")
|
||||
t.Error("responses differ with/without region param when no region observers configured")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+59
-9
@@ -80,7 +80,7 @@ type PacketStore struct {
|
||||
rfCache map[string]*cachedResult // region → cached RF result
|
||||
topoCache map[string]*cachedResult // region → cached topology result
|
||||
hashCache map[string]*cachedResult // region → cached hash-sizes result
|
||||
collisionCache *cachedResult // cached hash-collisions result (no region filtering)
|
||||
collisionCache map[string]*cachedResult // cached hash-collisions result keyed by region ("" = global)
|
||||
chanCache map[string]*cachedResult // region → cached channels result
|
||||
distCache map[string]*cachedResult // region → cached distance result
|
||||
subpathCache map[string]*cachedResult // params → cached subpaths result
|
||||
@@ -176,6 +176,7 @@ func NewPacketStore(db *DB, cfg *PacketStoreConfig) *PacketStore {
|
||||
topoCache: make(map[string]*cachedResult),
|
||||
hashCache: make(map[string]*cachedResult),
|
||||
|
||||
collisionCache: make(map[string]*cachedResult),
|
||||
chanCache: make(map[string]*cachedResult),
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
@@ -696,7 +697,7 @@ func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
||||
s.rfCache = make(map[string]*cachedResult)
|
||||
s.topoCache = make(map[string]*cachedResult)
|
||||
s.hashCache = make(map[string]*cachedResult)
|
||||
s.collisionCache = nil
|
||||
s.collisionCache = make(map[string]*cachedResult)
|
||||
s.chanCache = make(map[string]*cachedResult)
|
||||
s.distCache = make(map[string]*cachedResult)
|
||||
s.subpathCache = make(map[string]*cachedResult)
|
||||
@@ -716,7 +717,7 @@ func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
||||
}
|
||||
if inv.hasNewTransmissions {
|
||||
s.hashCache = make(map[string]*cachedResult)
|
||||
s.collisionCache = nil
|
||||
s.collisionCache = make(map[string]*cachedResult)
|
||||
}
|
||||
if inv.hasChannelData {
|
||||
s.chanCache = make(map[string]*cachedResult)
|
||||
@@ -4181,20 +4182,20 @@ type hashSizeNodeInfo struct {
|
||||
|
||||
// GetAnalyticsHashCollisions returns pre-computed hash collision analysis.
|
||||
// This moves the O(n²) distance computation from the frontend to the server.
|
||||
func (s *PacketStore) GetAnalyticsHashCollisions() map[string]interface{} {
|
||||
func (s *PacketStore) GetAnalyticsHashCollisions(region string) map[string]interface{} {
|
||||
s.cacheMu.Lock()
|
||||
if s.collisionCache != nil && time.Now().Before(s.collisionCache.expiresAt) {
|
||||
if cached, ok := s.collisionCache[region]; ok && time.Now().Before(cached.expiresAt) {
|
||||
s.cacheHits++
|
||||
s.cacheMu.Unlock()
|
||||
return s.collisionCache.data
|
||||
return cached.data
|
||||
}
|
||||
s.cacheMisses++
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
result := s.computeHashCollisions()
|
||||
result := s.computeHashCollisions(region)
|
||||
|
||||
s.cacheMu.Lock()
|
||||
s.collisionCache = &cachedResult{data: result, expiresAt: time.Now().Add(s.collisionCacheTTL)}
|
||||
s.collisionCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.collisionCacheTTL)}
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
return result
|
||||
@@ -4236,11 +4237,60 @@ type twoByteCellInfo struct {
|
||||
CollisionCount int `json:"collision_count"`
|
||||
}
|
||||
|
||||
func (s *PacketStore) computeHashCollisions() map[string]interface{} {
|
||||
func (s *PacketStore) computeHashCollisions(region string) map[string]interface{} {
|
||||
// Get all nodes from DB
|
||||
nodes := s.getAllNodes()
|
||||
hashInfo := s.GetNodeHashSizeInfo()
|
||||
|
||||
// If region is specified, filter to only nodes seen by regional observers
|
||||
if region != "" {
|
||||
regionObs := s.resolveRegionObservers(region)
|
||||
if regionObs != nil {
|
||||
s.mu.RLock()
|
||||
regionNodePKs := make(map[string]bool)
|
||||
for _, tx := range s.packets {
|
||||
match := false
|
||||
for _, obs := range tx.Observations {
|
||||
if regionObs[obs.ObserverID] {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
// Collect node public keys from advert packets
|
||||
if tx.DecodedJSON != "" {
|
||||
var d map[string]interface{}
|
||||
if json.Unmarshal([]byte(tx.DecodedJSON), &d) == nil {
|
||||
if pk, ok := d["pubKey"].(string); ok && pk != "" {
|
||||
regionNodePKs[pk] = true
|
||||
}
|
||||
if pk, ok := d["public_key"].(string); ok && pk != "" {
|
||||
regionNodePKs[pk] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Include observers themselves as nodes in the region
|
||||
for _, obs := range tx.Observations {
|
||||
if obs.ObserverID != "" {
|
||||
regionNodePKs[obs.ObserverID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Filter nodes to only those seen in the region
|
||||
filtered := make([]nodeInfo, 0, len(regionNodePKs))
|
||||
for _, n := range nodes {
|
||||
if regionNodePKs[n.PublicKey] {
|
||||
filtered = append(filtered, n)
|
||||
}
|
||||
}
|
||||
nodes = filtered
|
||||
}
|
||||
}
|
||||
|
||||
// Build collision nodes with hash info
|
||||
var allCNodes []collisionNode
|
||||
for _, n := range nodes {
|
||||
|
||||
+1
-1
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
||||
STAGING_COMPOSE_FILE="docker-compose.staging.yml"
|
||||
|
||||
# Build metadata — exported so docker compose build picks them up via args
|
||||
export APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown")
|
||||
export APP_VERSION=$(git describe --tags --match "v*" 2>/dev/null || echo "unknown")
|
||||
export GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
@@ -512,7 +512,7 @@ cmd_setup() {
|
||||
|
||||
# Default to latest release tag (instead of staying on master)
|
||||
if ! is_done "version_pin"; then
|
||||
git fetch origin --tags 2>/dev/null || true
|
||||
git fetch origin --tags --force 2>/dev/null || true
|
||||
local latest_tag
|
||||
latest_tag=$(git tag -l 'v*' --sort=-v:refname | head -1)
|
||||
if [ -n "$latest_tag" ]; then
|
||||
@@ -1317,7 +1317,7 @@ cmd_update() {
|
||||
local version="${1:-}"
|
||||
|
||||
info "Fetching latest changes and tags..."
|
||||
git fetch origin --tags
|
||||
git fetch origin --tags --force
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
# No arg: checkout latest release tag
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meshcore-analyzer",
|
||||
"version": "3.2.0",
|
||||
"version": "0.0.0-use-git-tags",
|
||||
"description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
+4
-4
@@ -148,7 +148,7 @@
|
||||
api('/analytics/rf' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/topology' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/channels' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/hash-collisions', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/hash-collisions' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
]);
|
||||
_analyticsData = { hashData, rfData, topoData, chanData, collisionData };
|
||||
renderTab(_currentTab);
|
||||
@@ -1488,9 +1488,9 @@
|
||||
for (let i = 0; i < data.nodes.length - 1; i++) {
|
||||
const a = data.nodes[i], b = data.nodes[i+1];
|
||||
if (a.lat && a.lon && b.lat && b.lon && !(a.lat===0&&a.lon===0) && !(b.lat===0&&b.lon===0)) {
|
||||
const dLat = (a.lat - b.lat) * 111;
|
||||
const dLon = (a.lon - b.lon) * 85;
|
||||
const km = Math.sqrt(dLat*dLat + dLon*dLon);
|
||||
const km = window.HopResolver && window.HopResolver.haversineKm
|
||||
? window.HopResolver.haversineKm(a.lat, a.lon, b.lat, b.lon)
|
||||
: (() => { const R=6371, dLat=(b.lat-a.lat)*Math.PI/180, dLon=(b.lon-a.lon)*Math.PI/180, h=Math.sin(dLat/2)**2+Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dLon/2)**2; return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h)); })();
|
||||
total += km;
|
||||
const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)';
|
||||
dists.push(`<div style="padding:2px 0"><span style="${cls}">${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'}</span> <span class="text-muted">${esc(a.name)} → ${esc(b.name)}</span></div>`);
|
||||
|
||||
@@ -807,6 +807,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// User's localStorage preferences take priority over server config
|
||||
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
|
||||
window._SITE_CONFIG_ORIGINAL_HOME = JSON.parse(JSON.stringify(window.SITE_CONFIG.home || {}));
|
||||
mergeUserHomeConfig(window.SITE_CONFIG, userTheme);
|
||||
|
||||
// Apply CSS variable overrides from theme config (skipped if user has local overrides)
|
||||
|
||||
+5
-2
@@ -274,6 +274,9 @@
|
||||
for (let i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
function formatHashHex(hash) {
|
||||
return typeof hash === 'number' ? '0x' + hash.toString(16).toUpperCase().padStart(2, '0') : hash;
|
||||
}
|
||||
function getChannelColor(hash) { return CHANNEL_COLORS[hashCode(String(hash)) % CHANNEL_COLORS.length]; }
|
||||
function getSenderColor(name) {
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
@@ -659,7 +662,7 @@
|
||||
});
|
||||
|
||||
el.innerHTML = sorted.map(ch => {
|
||||
const name = ch.name || `Channel ${ch.hash}`;
|
||||
const name = ch.name || `Channel ${formatHashHex(ch.hash)}`;
|
||||
const color = getChannelColor(ch.hash);
|
||||
const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : '';
|
||||
const preview = ch.lastSender && ch.lastMessage
|
||||
@@ -688,7 +691,7 @@
|
||||
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
|
||||
renderChannelList();
|
||||
const ch = channels.find(c => c.hash === hash);
|
||||
const name = ch?.name || `Channel ${hash}`;
|
||||
const name = ch?.name || `Channel ${formatHashHex(hash)}`;
|
||||
const header = document.getElementById('chHeader');
|
||||
header.querySelector('.ch-header-text').textContent = `${name} — ${ch?.messageCount || 0} messages`;
|
||||
|
||||
|
||||
+14
-8
@@ -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 ── */
|
||||
@@ -450,7 +455,8 @@
|
||||
function mergeSection(key) {
|
||||
return Object.assign({}, DEFAULTS[key], cfg[key] || {}, local[key] || {});
|
||||
}
|
||||
var mergedHome = mergeSection('home');
|
||||
var serverHome = window._SITE_CONFIG_ORIGINAL_HOME || cfg.home || {};
|
||||
var mergedHome = Object.assign({}, DEFAULTS.home, serverHome, local.home || {});
|
||||
var localTsMode = localStorage.getItem('meshcore-timestamp-mode');
|
||||
var localTsTimezone = localStorage.getItem('meshcore-timestamp-timezone');
|
||||
var localTsFormat = localStorage.getItem('meshcore-timestamp-format');
|
||||
@@ -1202,19 +1208,19 @@
|
||||
var tmp = state.home.steps[i];
|
||||
state.home.steps[i] = state.home.steps[j];
|
||||
state.home.steps[j] = tmp;
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('[data-rm-step]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
state.home.steps.splice(parseInt(btn.dataset.rmStep), 1);
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
});
|
||||
var addStepBtn = document.getElementById('addStep');
|
||||
if (addStepBtn) addStepBtn.addEventListener('click', function () {
|
||||
state.home.steps.push({ emoji: '📌', title: '', description: '' });
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
|
||||
// Checklist
|
||||
@@ -1227,13 +1233,13 @@
|
||||
container.querySelectorAll('[data-rm-check]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
state.home.checklist.splice(parseInt(btn.dataset.rmCheck), 1);
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
});
|
||||
var addCheckBtn = document.getElementById('addCheck');
|
||||
if (addCheckBtn) addCheckBtn.addEventListener('click', function () {
|
||||
state.home.checklist.push({ question: '', answer: '' });
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
|
||||
// Footer links
|
||||
@@ -1246,13 +1252,13 @@
|
||||
container.querySelectorAll('[data-rm-link]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
state.home.footerLinks.splice(parseInt(btn.dataset.rmLink), 1);
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
});
|
||||
var addLinkBtn = document.getElementById('addLink');
|
||||
if (addLinkBtn) addLinkBtn.addEventListener('click', function () {
|
||||
state.home.footerLinks.push({ label: '', url: '' });
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
|
||||
// Export copy
|
||||
|
||||
@@ -203,5 +203,5 @@ window.HopResolver = (function() {
|
||||
return nodesList.length > 0;
|
||||
}
|
||||
|
||||
return { init: init, resolve: resolve, ready: ready };
|
||||
return { init: init, resolve: resolve, ready: ready, haversineKm: haversineKm };
|
||||
})();
|
||||
|
||||
+28
-28
@@ -22,9 +22,9 @@
|
||||
<meta name="twitter:title" content="CoreScope">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1775062162">
|
||||
<link rel="stylesheet" href="home.css?v=1775062162">
|
||||
<link rel="stylesheet" href="live.css?v=1775062162">
|
||||
<link rel="stylesheet" href="style.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="home.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="live.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -85,30 +85,30 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1775062162"></script>
|
||||
<script src="customize.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1775062162"></script>
|
||||
<script src="hop-resolver.js?v=1775062162"></script>
|
||||
<script src="hop-display.js?v=1775062162"></script>
|
||||
<script src="app.js?v=1775062162"></script>
|
||||
<script src="home.js?v=1775062162"></script>
|
||||
<script src="packet-filter.js?v=1775062162"></script>
|
||||
<script src="packets.js?v=1775062162"></script>
|
||||
<script src="geo-filter-overlay.js?v=1775062162"></script>
|
||||
<script src="map.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=__BUST__"></script>
|
||||
<script src="customize.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=__BUST__"></script>
|
||||
<script src="hop-resolver.js?v=__BUST__"></script>
|
||||
<script src="hop-display.js?v=__BUST__"></script>
|
||||
<script src="app.js?v=__BUST__"></script>
|
||||
<script src="home.js?v=__BUST__"></script>
|
||||
<script src="packet-filter.js?v=__BUST__"></script>
|
||||
<script src="packets.js?v=__BUST__"></script>
|
||||
<script src="geo-filter-overlay.js?v=__BUST__"></script>
|
||||
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+105
-59
@@ -10,6 +10,7 @@
|
||||
let nodeData = {};
|
||||
let packetCount = 0;
|
||||
let activeAnims = 0;
|
||||
const MAX_CONCURRENT_ANIMS = 20;
|
||||
let nodeActivity = {};
|
||||
let recentPaths = [];
|
||||
let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false';
|
||||
@@ -368,12 +369,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateVCRClock(tsMs) {
|
||||
function vcrFormatTime(tsMs) {
|
||||
const d = new Date(tsMs);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
drawLcdText(`${hh}:${mm}:${ss}`, statusGreen());
|
||||
const utc = typeof getTimestampTimezone === 'function' && getTimestampTimezone() === 'utc';
|
||||
const hh = String(utc ? d.getUTCHours() : d.getHours()).padStart(2, '0');
|
||||
const mm = String(utc ? d.getUTCMinutes() : d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(utc ? d.getUTCSeconds() : d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function updateVCRClock(tsMs) {
|
||||
drawLcdText(vcrFormatTime(tsMs), statusGreen());
|
||||
}
|
||||
|
||||
function updateVCRLcd() {
|
||||
@@ -1060,8 +1066,7 @@
|
||||
const rect = timelineEl.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
const ts = Date.now() - VCR.timelineScope + pct * VCR.timelineScope;
|
||||
const d = new Date(ts);
|
||||
timeTooltip.textContent = d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
timeTooltip.textContent = vcrFormatTime(ts);
|
||||
timeTooltip.style.left = (e.clientX - rect.left) + 'px';
|
||||
timeTooltip.classList.remove('hidden');
|
||||
});
|
||||
@@ -1074,8 +1079,7 @@
|
||||
const rect = timelineEl.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
||||
const ts = Date.now() - VCR.timelineScope + pct * VCR.timelineScope;
|
||||
const d = new Date(ts);
|
||||
timeTooltip.textContent = d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
timeTooltip.textContent = vcrFormatTime(ts);
|
||||
timeTooltip.style.left = (touch.clientX - rect.left) + 'px';
|
||||
timeTooltip.classList.remove('hidden');
|
||||
});
|
||||
@@ -1581,6 +1585,7 @@
|
||||
window._livePruneStaleNodes = pruneStaleNodes;
|
||||
window._liveNodeMarkers = function() { return nodeMarkers; };
|
||||
window._liveNodeData = function() { return nodeData; };
|
||||
window._vcrFormatTime = vcrFormatTime;
|
||||
|
||||
async function replayRecent() {
|
||||
try {
|
||||
@@ -1843,6 +1848,7 @@
|
||||
|
||||
function animatePath(hopPositions, typeName, color, rawHex, onHop) {
|
||||
if (!animLayer || !pathsLayer) return;
|
||||
if (activeAnims >= MAX_CONCURRENT_ANIMS) return;
|
||||
activeAnims++;
|
||||
document.getElementById('liveAnimCount').textContent = activeAnims;
|
||||
let hopIndex = 0;
|
||||
@@ -1850,9 +1856,11 @@
|
||||
function nextHop() {
|
||||
if (hopIndex >= hopPositions.length) {
|
||||
activeAnims = Math.max(0, activeAnims - 1);
|
||||
document.getElementById('liveAnimCount').textContent = activeAnims;
|
||||
const countEl = document.getElementById('liveAnimCount');
|
||||
if (countEl) countEl.textContent = activeAnims;
|
||||
return;
|
||||
}
|
||||
if (!animLayer) return;
|
||||
// Audio hook: notify per-hop callback
|
||||
if (onHop) try { onHop(hopIndex, hopPositions.length, hopPositions[hopIndex]); } catch (e) {}
|
||||
const hp = hopPositions[hopIndex];
|
||||
@@ -1864,12 +1872,22 @@
|
||||
radius: 3, fillColor: '#94a3b8', fillOpacity: 0.35, color: '#94a3b8', weight: 1, opacity: 0.5
|
||||
}).addTo(animLayer);
|
||||
let pulseUp = true;
|
||||
const pulseTimer = setInterval(() => {
|
||||
if (!animLayer.hasLayer(ghost)) { clearInterval(pulseTimer); return; }
|
||||
ghost.setStyle({ fillOpacity: pulseUp ? 0.6 : 0.25, opacity: pulseUp ? 0.7 : 0.4 });
|
||||
pulseUp = !pulseUp;
|
||||
}, 600);
|
||||
setTimeout(() => { clearInterval(pulseTimer); if (animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost); }, 3000);
|
||||
let lastPulseTime = performance.now();
|
||||
const pulseExpiry = lastPulseTime + 3000;
|
||||
function ghostPulse(now) {
|
||||
if (!animLayer || !animLayer.hasLayer(ghost)) return;
|
||||
if (now >= pulseExpiry) {
|
||||
if (animLayer && animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost);
|
||||
return;
|
||||
}
|
||||
if (now - lastPulseTime >= 600) {
|
||||
lastPulseTime = now;
|
||||
ghost.setStyle({ fillOpacity: pulseUp ? 0.6 : 0.25, opacity: pulseUp ? 0.7 : 0.4 });
|
||||
pulseUp = !pulseUp;
|
||||
}
|
||||
requestAnimationFrame(ghostPulse);
|
||||
}
|
||||
requestAnimationFrame(ghostPulse);
|
||||
}
|
||||
} else {
|
||||
pulseNode(hp.key, hp.pos, typeName);
|
||||
@@ -1913,20 +1931,30 @@
|
||||
}).addTo(animLayer);
|
||||
|
||||
let r = 2, op = 0.9;
|
||||
const iv = setInterval(() => {
|
||||
r += 1.5; op -= 0.03;
|
||||
if (op <= 0) {
|
||||
clearInterval(iv);
|
||||
let lastPulse = performance.now();
|
||||
const pulseStart = lastPulse;
|
||||
function animatePulse(now) {
|
||||
if (now - pulseStart > 2000) {
|
||||
try { animLayer.removeLayer(ring); } catch {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ring.setRadius(r);
|
||||
ring.setStyle({ opacity: op, weight: Math.max(0.3, 3 - r * 0.04) });
|
||||
} catch { clearInterval(iv); }
|
||||
}, 26);
|
||||
// Safety cleanup — never let a ring live longer than 2s
|
||||
setTimeout(() => { clearInterval(iv); try { animLayer.removeLayer(ring); } catch {} }, 2000);
|
||||
const elapsed = now - lastPulse;
|
||||
if (elapsed >= 26) {
|
||||
const ticks = Math.min(Math.floor(elapsed / 26), 4);
|
||||
r += 1.5 * ticks; op -= 0.03 * ticks;
|
||||
lastPulse = now;
|
||||
if (op <= 0) {
|
||||
try { animLayer.removeLayer(ring); } catch {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ring.setRadius(r);
|
||||
ring.setStyle({ opacity: op, weight: Math.max(0.3, 3 - r * 0.04) });
|
||||
} catch { return; }
|
||||
}
|
||||
requestAnimationFrame(animatePulse);
|
||||
}
|
||||
requestAnimationFrame(animatePulse);
|
||||
|
||||
const baseColor = marker._baseColor || '#6b7280';
|
||||
const baseSize = marker._baseSize || 6;
|
||||
@@ -2239,43 +2267,61 @@
|
||||
radius: 3.5, fillColor: '#fff', fillOpacity: 1, color: color, weight: 1.5
|
||||
}).addTo(animLayer);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
step++;
|
||||
const lat = from[0] + latStep * step;
|
||||
const lon = from[1] + lonStep * step;
|
||||
currentCoords.push([lat, lon]);
|
||||
line.setLatLngs(currentCoords);
|
||||
contrail.setLatLngs(currentCoords);
|
||||
dot.setLatLng([lat, lon]);
|
||||
|
||||
if (step >= steps) {
|
||||
clearInterval(interval);
|
||||
if (animLayer) animLayer.removeLayer(dot);
|
||||
|
||||
recentPaths.push({ line, glowLine: contrail, time: Date.now() });
|
||||
while (recentPaths.length > 5) {
|
||||
const old = recentPaths.shift();
|
||||
if (pathsLayer) { pathsLayer.removeLayer(old.line); pathsLayer.removeLayer(old.glowLine); }
|
||||
let lastStep = performance.now();
|
||||
function animateLine(now) {
|
||||
const elapsed = now - lastStep;
|
||||
if (elapsed >= 33) {
|
||||
const ticks = Math.min(Math.floor(elapsed / 33), 4);
|
||||
lastStep = now;
|
||||
for (let t = 0; t < ticks && step < steps; t++) {
|
||||
step++;
|
||||
const lat = from[0] + latStep * step;
|
||||
const lon = from[1] + lonStep * step;
|
||||
currentCoords.push([lat, lon]);
|
||||
}
|
||||
const lastPt = currentCoords[currentCoords.length - 1];
|
||||
line.setLatLngs(currentCoords);
|
||||
contrail.setLatLngs(currentCoords);
|
||||
dot.setLatLng(lastPt);
|
||||
|
||||
setTimeout(() => {
|
||||
let fadeOp = mainOpacity;
|
||||
const fi = setInterval(() => {
|
||||
fadeOp -= 0.1;
|
||||
if (fadeOp <= 0) {
|
||||
clearInterval(fi);
|
||||
if (pathsLayer) { pathsLayer.removeLayer(line); pathsLayer.removeLayer(contrail); }
|
||||
recentPaths = recentPaths.filter(p => p.line !== line);
|
||||
} else {
|
||||
line.setStyle({ opacity: fadeOp });
|
||||
contrail.setStyle({ opacity: fadeOp * 0.15 });
|
||||
if (step >= steps) {
|
||||
if (animLayer) animLayer.removeLayer(dot);
|
||||
|
||||
recentPaths.push({ line, glowLine: contrail, time: Date.now() });
|
||||
while (recentPaths.length > 5) {
|
||||
const old = recentPaths.shift();
|
||||
if (pathsLayer) { pathsLayer.removeLayer(old.line); pathsLayer.removeLayer(old.glowLine); }
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
let fadeOp = mainOpacity;
|
||||
let lastFade = performance.now();
|
||||
function animateFade(now) {
|
||||
const fadeElapsed = now - lastFade;
|
||||
if (fadeElapsed >= 52) {
|
||||
const fadeTicks = Math.min(Math.floor(fadeElapsed / 52), 4);
|
||||
lastFade = now;
|
||||
fadeOp -= 0.1 * fadeTicks;
|
||||
if (fadeOp <= 0) {
|
||||
if (pathsLayer) { pathsLayer.removeLayer(line); pathsLayer.removeLayer(contrail); }
|
||||
recentPaths = recentPaths.filter(p => p.line !== line);
|
||||
return;
|
||||
}
|
||||
line.setStyle({ opacity: fadeOp });
|
||||
contrail.setStyle({ opacity: fadeOp * 0.15 });
|
||||
}
|
||||
requestAnimationFrame(animateFade);
|
||||
}
|
||||
}, 52);
|
||||
}, 800);
|
||||
requestAnimationFrame(animateFade);
|
||||
}, 800);
|
||||
|
||||
if (onComplete) onComplete();
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, 33);
|
||||
requestAnimationFrame(animateLine);
|
||||
}
|
||||
requestAnimationFrame(animateLine);
|
||||
}
|
||||
|
||||
function showHeatMap() {
|
||||
|
||||
+66
-2
@@ -10,6 +10,8 @@
|
||||
let targetNodeKey = null;
|
||||
let observers = [];
|
||||
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all' };
|
||||
let selectedReferenceNode = null; // pubkey of the reference node for neighbor filtering
|
||||
let neighborPubkeys = null; // Set of pubkeys that are direct neighbors of selected node
|
||||
let wsHandler = null;
|
||||
let heatLayer = null;
|
||||
let geoFilterLayer = null;
|
||||
@@ -108,6 +110,8 @@
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Filters</legend>
|
||||
<label for="mcNeighbors"><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
|
||||
<div id="mcNeighborRef" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Ref: <span id="mcNeighborRefName">—</span></div>
|
||||
<div id="mcNeighborHint" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Click a node marker to set the reference node</div>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Last Heard</legend>
|
||||
@@ -207,7 +211,19 @@
|
||||
const heatEl = document.getElementById('mcHeatmap');
|
||||
if (localStorage.getItem('meshcore-map-heatmap') === 'true') { heatEl.checked = true; }
|
||||
heatEl.addEventListener('change', e => { localStorage.setItem('meshcore-map-heatmap', e.target.checked); toggleHeatmap(e.target.checked); });
|
||||
document.getElementById('mcNeighbors').addEventListener('change', e => { filters.neighbors = e.target.checked; renderMarkers(); });
|
||||
document.getElementById('mcNeighbors').addEventListener('change', e => {
|
||||
filters.neighbors = e.target.checked;
|
||||
const hintEl = document.getElementById('mcNeighborHint');
|
||||
const refEl = document.getElementById('mcNeighborRef');
|
||||
if (e.target.checked && !selectedReferenceNode) {
|
||||
hintEl.style.display = 'block';
|
||||
refEl.style.display = 'none';
|
||||
} else {
|
||||
hintEl.style.display = 'none';
|
||||
refEl.style.display = selectedReferenceNode ? 'block' : 'none';
|
||||
}
|
||||
renderMarkers();
|
||||
});
|
||||
|
||||
// Hash Labels toggle
|
||||
const hashLabelEl = document.getElementById('mcHashLabels');
|
||||
@@ -646,6 +662,11 @@
|
||||
const status = getNodeStatus(role, lastMs);
|
||||
if (status !== filters.statusFilter) return false;
|
||||
}
|
||||
// Neighbor filter: show only the reference node and its direct neighbors
|
||||
if (filters.neighbors && selectedReferenceNode && neighborPubkeys) {
|
||||
const pk = n.public_key;
|
||||
if (pk !== selectedReferenceNode && !neighborPubkeys.has(pk)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -724,6 +745,43 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function selectReferenceNode(pubkey, name) {
|
||||
selectedReferenceNode = pubkey;
|
||||
neighborPubkeys = new Set();
|
||||
try {
|
||||
const data = await api('/nodes/' + pubkey + '/paths');
|
||||
const paths = data.paths || [];
|
||||
for (const p of paths) {
|
||||
const hops = p.hops || [];
|
||||
// Find the reference node in the path; direct neighbors are adjacent hops
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
if (hops[i].pubkey === pubkey) {
|
||||
if (i > 0 && hops[i - 1].pubkey) neighborPubkeys.add(hops[i - 1].pubkey);
|
||||
if (i < hops.length - 1 && hops[i + 1].pubkey) neighborPubkeys.add(hops[i + 1].pubkey);
|
||||
}
|
||||
}
|
||||
// (Redundant block removed — the main loop above already handles first/last hops)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch neighbor paths for', pubkey, '— neighbor filter may be incomplete:', e);
|
||||
neighborPubkeys = new Set();
|
||||
}
|
||||
// Update sidebar UI
|
||||
const refEl = document.getElementById('mcNeighborRef');
|
||||
const refNameEl = document.getElementById('mcNeighborRefName');
|
||||
const hintEl = document.getElementById('mcNeighborHint');
|
||||
if (refEl) { refEl.style.display = 'block'; }
|
||||
if (refNameEl) { refNameEl.textContent = name || pubkey.slice(0, 8); }
|
||||
if (hintEl) { hintEl.style.display = 'none'; }
|
||||
// Auto-enable the neighbors filter
|
||||
filters.neighbors = true;
|
||||
const cb = document.getElementById('mcNeighbors');
|
||||
if (cb) cb.checked = true;
|
||||
renderMarkers();
|
||||
}
|
||||
// Expose for popup onclick
|
||||
window._mapSelectRefNode = selectReferenceNode;
|
||||
|
||||
function buildPopup(node) {
|
||||
const key = node.public_key ? truncate(node.public_key, 16) : '—';
|
||||
const loc = (node.lat && node.lon) ? `${node.lat.toFixed(5)}, ${node.lon.toFixed(5)}` : '—';
|
||||
@@ -749,7 +807,10 @@
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Adverts</dt>
|
||||
<dd style="margin-left:88px;padding:2px 0;">${node.advert_count || 0}</dd>
|
||||
</dl>
|
||||
<div style="margin-top:8px;clear:both;"><a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node →</a></div>
|
||||
<div style="margin-top:8px;clear:both;">
|
||||
<a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node →</a>
|
||||
${node.public_key ? ` · <a href="#" onclick="event.preventDefault();window._mapSelectRefNode('${safeEsc(node.public_key.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/</g, '\\x3c'))}','${safeEsc((node.name || 'Unknown').replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/</g, '\\x3c'))}')" style="color:var(--accent);font-size:12px;">Show Neighbors</a>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -775,6 +836,9 @@
|
||||
routeLayer = null;
|
||||
if (heatLayer) { heatLayer = null; }
|
||||
geoFilterLayer = null;
|
||||
selectedReferenceNode = null;
|
||||
neighborPubkeys = null;
|
||||
delete window._mapSelectRefNode;
|
||||
}
|
||||
|
||||
function toggleHeatmap(on) {
|
||||
|
||||
+42
-2
@@ -228,11 +228,39 @@
|
||||
loadNodes();
|
||||
// Auto-refresh when ADVERT packets arrive via WebSocket (fixes #131)
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
if (msgs.some(isAdvertMessage)) {
|
||||
_allNodes = null;
|
||||
const advertMsgs = msgs.filter(isAdvertMessage);
|
||||
if (!advertMsgs.length) return;
|
||||
|
||||
if (!_allNodes) {
|
||||
invalidateApiCache('/nodes');
|
||||
loadNodes(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let needReload = false;
|
||||
for (const m of advertMsgs) {
|
||||
const payload = m.data && m.data.decoded && m.data.decoded.payload;
|
||||
const pubKey = payload && (payload.pubKey || payload.public_key);
|
||||
if (!pubKey) { needReload = true; break; }
|
||||
|
||||
const existing = _allNodes.find(n => n.public_key === pubKey);
|
||||
if (existing) {
|
||||
if (payload.name) existing.name = payload.name;
|
||||
if (payload.lat != null) existing.lat = payload.lat;
|
||||
if (payload.lon != null) existing.lon = payload.lon;
|
||||
const ts = m.data.packet && (m.data.packet.timestamp || m.data.packet.first_seen);
|
||||
if (ts) existing.last_seen = ts;
|
||||
} else {
|
||||
needReload = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (needReload) {
|
||||
_allNodes = null;
|
||||
invalidateApiCache('/nodes');
|
||||
}
|
||||
loadNodes(true);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
@@ -929,4 +957,16 @@
|
||||
|
||||
// Test hooks
|
||||
window._nodesIsAdvertMessage = isAdvertMessage;
|
||||
window._nodesGetAllNodes = function() { return _allNodes; };
|
||||
window._nodesSetAllNodes = function(n) { _allNodes = n; };
|
||||
window._nodesToggleSort = toggleSort;
|
||||
window._nodesSortNodes = sortNodes;
|
||||
window._nodesSortArrow = sortArrow;
|
||||
window._nodesGetSortState = function() { return sortState; };
|
||||
window._nodesSetSortState = function(s) { sortState = s; };
|
||||
window._nodesSyncClaimedToFavorites = syncClaimedToFavorites;
|
||||
window._nodesRenderNodeTimestampHtml = renderNodeTimestampHtml;
|
||||
window._nodesRenderNodeTimestampText = renderNodeTimestampText;
|
||||
window._nodesGetStatusInfo = getStatusInfo;
|
||||
window._nodesGetStatusTooltip = getStatusTooltip;
|
||||
})();
|
||||
|
||||
+52
-15
@@ -8,7 +8,7 @@
|
||||
// Resolve observer_id to friendly name from loaded observers list
|
||||
function obsName(id) {
|
||||
if (!id) return '—';
|
||||
const o = observers.find(ob => ob.id === id);
|
||||
const o = observerMap.get(id);
|
||||
if (!o) return id;
|
||||
return o.iata ? `${o.name} (${o.iata})` : o.name;
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
let packetsPaused = false;
|
||||
let pauseBuffer = [];
|
||||
let observers = [];
|
||||
let observerMap = new Map(); // id → observer for O(1) lookups (#383)
|
||||
let regionMap = {};
|
||||
const TYPE_NAMES = { 0:'Request', 1:'Response', 2:'Direct Msg', 3:'ACK', 4:'Advert', 5:'Channel Msg', 7:'Anon Req', 8:'Path', 9:'Trace', 11:'Control' };
|
||||
function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; }
|
||||
@@ -34,6 +35,11 @@
|
||||
let hopNameCache = {};
|
||||
let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true';
|
||||
let filtersBuilt = false;
|
||||
let _renderTimer = null;
|
||||
function scheduleRender() {
|
||||
clearTimeout(_renderTimer);
|
||||
_renderTimer = setTimeout(() => renderTableRows(), 200);
|
||||
}
|
||||
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
|
||||
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)">✕</button>';
|
||||
|
||||
@@ -326,6 +332,7 @@
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
if (packetsPaused) {
|
||||
pauseBuffer.push(...msgs);
|
||||
if (pauseBuffer.length > 2000) pauseBuffer = pauseBuffer.slice(-2000);
|
||||
const btn = document.getElementById('pktPauseBtn');
|
||||
if (btn) btn.textContent = '▶ ' + pauseBuffer.length;
|
||||
return;
|
||||
@@ -349,7 +356,7 @@
|
||||
if (filters.hash && p.hash !== filters.hash) return false;
|
||||
if (RegionFilter.getRegionParam()) {
|
||||
const selectedRegions = RegionFilter.getRegionParam().split(',');
|
||||
const obs = observers.find(o => o.id === p.observer_id);
|
||||
const obs = observerMap.get(p.observer_id);
|
||||
if (!obs || !selectedRegions.includes(obs.iata)) return false;
|
||||
}
|
||||
if (filters.node && !(p.decoded_json || '').includes(filters.node)) return false;
|
||||
@@ -382,6 +389,7 @@
|
||||
// Update expanded children if this group is expanded
|
||||
if (expandedHashes.has(h) && existing._children) {
|
||||
existing._children.unshift(p);
|
||||
if (existing._children.length > 200) existing._children.length = 200;
|
||||
sortGroupChildren(existing);
|
||||
}
|
||||
} else {
|
||||
@@ -402,11 +410,16 @@
|
||||
if (h) hashIndex.set(h, newGroup);
|
||||
}
|
||||
}
|
||||
// Re-sort by latest DESC
|
||||
// Re-sort by latest DESC, then evict oldest beyond the limit
|
||||
packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || ''));
|
||||
if (packets.length > PACKET_LIMIT) {
|
||||
const evicted = packets.splice(PACKET_LIMIT);
|
||||
for (const p of evicted) { if (p.hash) hashIndex.delete(p.hash); }
|
||||
}
|
||||
} else {
|
||||
// Flat mode: prepend
|
||||
// Flat mode: prepend, then evict oldest beyond the limit
|
||||
packets = filtered.concat(packets);
|
||||
if (packets.length > PACKET_LIMIT) packets.length = PACKET_LIMIT;
|
||||
}
|
||||
totalCount += filtered.length;
|
||||
// Debounce WS-triggered renders to avoid rapid full rebuilds
|
||||
@@ -417,6 +430,7 @@
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
clearTimeout(_renderTimer);
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
detachVScrollListener();
|
||||
@@ -439,6 +453,7 @@
|
||||
hopNameCache = {};
|
||||
totalCount = 0;
|
||||
observers = [];
|
||||
observerMap = new Map();
|
||||
directPacketId = null;
|
||||
directPacketHash = null;
|
||||
groupByHash = true;
|
||||
@@ -450,6 +465,7 @@
|
||||
try {
|
||||
const data = await api('/observers', { ttl: CLIENT_TTL.observers });
|
||||
observers = data.observers || [];
|
||||
observerMap = new Map(observers.map(o => [o.id, o]));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -696,7 +712,7 @@
|
||||
obsTrigger.textContent = 'All Observers ▾';
|
||||
} else if (selectedObservers.size === 1) {
|
||||
const id = [...selectedObservers][0];
|
||||
const o = observers.find(x => String(x.id) === id);
|
||||
const o = observerMap.get(id) || observerMap.get(Number(id));
|
||||
obsTrigger.textContent = (o ? (o.name || o.id) : id) + ' ▾';
|
||||
} else {
|
||||
obsTrigger.textContent = selectedObservers.size + ' Observers ▾';
|
||||
@@ -1023,7 +1039,7 @@
|
||||
headerPathJson = match.path_json;
|
||||
}
|
||||
}
|
||||
const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : '';
|
||||
const groupRegion = headerObserverId ? (observerMap.get(headerObserverId)?.iata || '') : '';
|
||||
let groupPath = [];
|
||||
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
|
||||
const groupPathStr = renderPath(groupPath, headerObserverId);
|
||||
@@ -1055,7 +1071,7 @@
|
||||
const typeClass = payloadTypeColor(c.payload_type);
|
||||
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
|
||||
const childHashBytes = ((parseInt(c.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : '';
|
||||
const childRegion = c.observer_id ? (observerMap.get(c.observer_id)?.iata || '') : '';
|
||||
let childPath = [];
|
||||
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
|
||||
const childPathStr = renderPath(childPath, c.observer_id);
|
||||
@@ -1081,7 +1097,7 @@
|
||||
let decoded, pathHops = [];
|
||||
try { decoded = JSON.parse(p.decoded_json || '{}'); } catch {}
|
||||
try { pathHops = JSON.parse(p.path_json || '[]') || []; } catch {}
|
||||
const region = p.observer_id ? (observers.find(o => o.id === p.observer_id)?.iata || '') : '';
|
||||
const region = p.observer_id ? (observerMap.get(p.observer_id)?.iata || '') : '';
|
||||
const typeName = payloadTypeName(p.payload_type);
|
||||
const typeClass = payloadTypeColor(p.payload_type);
|
||||
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
@@ -1131,7 +1147,6 @@
|
||||
}
|
||||
_cumulativeOffsetsCache = offsets;
|
||||
return offsets;
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function renderVisibleRows() {
|
||||
@@ -1674,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);
|
||||
@@ -1684,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;
|
||||
@@ -1692,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++) {
|
||||
@@ -1704,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));
|
||||
@@ -1754,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>`;
|
||||
@@ -2007,6 +2022,28 @@
|
||||
});
|
||||
|
||||
// Standalone packet detail page: #/packet/123 or #/packet/HASH
|
||||
// Expose pure functions for unit testing (vm.createContext pattern)
|
||||
if (typeof window !== 'undefined') {
|
||||
window._packetsTestAPI = {
|
||||
typeName,
|
||||
obsName,
|
||||
getDetailPreview,
|
||||
sortGroupChildren,
|
||||
getPathHopCount,
|
||||
renderDecodedPacket,
|
||||
kv,
|
||||
buildFieldTable,
|
||||
sectionRow,
|
||||
fieldRow,
|
||||
renderTimestampCell,
|
||||
renderPath,
|
||||
_getRowCount,
|
||||
_cumulativeRowOffsets,
|
||||
buildGroupRowHtml,
|
||||
buildFlatRowHtml,
|
||||
};
|
||||
}
|
||||
|
||||
registerPage('packet-detail', {
|
||||
init: async (app, routeParam) => {
|
||||
const param = routeParam;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* test-anim-perf.js — Performance benchmark for animation timer management
|
||||
*
|
||||
* Demonstrates that the rAF + concurrency-cap approach keeps active animation
|
||||
* count bounded, whereas the old setInterval approach accumulated without limit.
|
||||
*
|
||||
* Run: node test-anim-perf.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { console.log(` ✅ ${msg}`); passed++; }
|
||||
else { console.log(` ❌ ${msg}`); failed++; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulate OLD behaviour: setInterval-based, no concurrency cap
|
||||
// ---------------------------------------------------------------------------
|
||||
function simulateOldModel(packetsPerSec, hopsPerPacket, durationSec) {
|
||||
// Each hop spawns 3 intervals (pulse 26ms, line 33ms, fade 52ms).
|
||||
// Pulse lasts ~2s, line ~0.66s, fade ~0.8s+0.4s ≈ 1.2s
|
||||
// At any moment, timers from the last ~2s of packets are still alive.
|
||||
const intervalLifetimes = [2.0, 0.66, 1.2]; // seconds each interval lives
|
||||
let maxConcurrent = 0;
|
||||
// Walk through time in 0.1s steps
|
||||
const dt = 0.1;
|
||||
const spawns = []; // {time, lifetime}
|
||||
for (let t = 0; t < durationSec; t += dt) {
|
||||
// Spawn timers for packets arriving in this window
|
||||
const pktsInWindow = packetsPerSec * dt;
|
||||
for (let p = 0; p < pktsInWindow; p++) {
|
||||
for (let h = 0; h < hopsPerPacket; h++) {
|
||||
for (const lt of intervalLifetimes) {
|
||||
spawns.push({ time: t, lifetime: lt });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Count alive timers
|
||||
const alive = spawns.filter(s => t < s.time + s.lifetime).length;
|
||||
if (alive > maxConcurrent) maxConcurrent = alive;
|
||||
}
|
||||
return maxConcurrent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulate NEW behaviour: rAF + MAX_CONCURRENT_ANIMS cap
|
||||
// ---------------------------------------------------------------------------
|
||||
function simulateNewModel(packetsPerSec, hopsPerPacket, durationSec) {
|
||||
const MAX_CONCURRENT_ANIMS = 20;
|
||||
let activeAnims = 0;
|
||||
let maxConcurrent = 0;
|
||||
const anims = []; // {endTime}
|
||||
const dt = 0.1;
|
||||
for (let t = 0; t < durationSec; t += dt) {
|
||||
// Expire finished animations
|
||||
while (anims.length && anims[0].endTime <= t) {
|
||||
anims.shift();
|
||||
activeAnims--;
|
||||
}
|
||||
// Try to start new animations
|
||||
const pktsInWindow = packetsPerSec * dt;
|
||||
for (let p = 0; p < pktsInWindow; p++) {
|
||||
if (activeAnims >= MAX_CONCURRENT_ANIMS) break; // cap reached — drop
|
||||
activeAnims++;
|
||||
// rAF animation lifetime: longest is pulse ~2s
|
||||
anims.push({ endTime: t + 2.0 });
|
||||
}
|
||||
// Sort by endTime so expiry works
|
||||
anims.sort((a, b) => a.endTime - b.endTime);
|
||||
if (activeAnims > maxConcurrent) maxConcurrent = activeAnims;
|
||||
}
|
||||
return maxConcurrent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log('\n=== Animation timer accumulation: old vs new ===');
|
||||
|
||||
// Scenario: 5 pkts/sec, 3 hops each, 30 seconds
|
||||
const oldPeak30s = simulateOldModel(5, 3, 30);
|
||||
const newPeak30s = simulateNewModel(5, 3, 30);
|
||||
console.log(` Old model (30s @ 5pkt/s×3hops): peak ${oldPeak30s} concurrent timers`);
|
||||
console.log(` New model (30s @ 5pkt/s×3hops): peak ${newPeak30s} concurrent animations`);
|
||||
assert(oldPeak30s > 100, `old model accumulates >100 timers (got ${oldPeak30s})`);
|
||||
assert(newPeak30s <= 20, `new model stays ≤20 (got ${newPeak30s})`);
|
||||
|
||||
// Scenario: 5 minutes sustained
|
||||
const oldPeak5m = simulateOldModel(5, 3, 300);
|
||||
const newPeak5m = simulateNewModel(5, 3, 300);
|
||||
console.log(` Old model (5min @ 5pkt/s×3hops): peak ${oldPeak5m} concurrent timers`);
|
||||
console.log(` New model (5min @ 5pkt/s×3hops): peak ${newPeak5m} concurrent animations`);
|
||||
assert(oldPeak5m > 100, `old model at 5min still unbounded (got ${oldPeak5m})`);
|
||||
assert(newPeak5m <= 20, `new model at 5min still ≤20 (got ${newPeak5m})`);
|
||||
|
||||
// Scenario: burst — 20 pkts/sec for 10s
|
||||
const oldBurst = simulateOldModel(20, 3, 10);
|
||||
const newBurst = simulateNewModel(20, 3, 10);
|
||||
console.log(` Old model (burst 20pkt/s×3hops, 10s): peak ${oldBurst} concurrent timers`);
|
||||
console.log(` New model (burst 20pkt/s×3hops, 10s): peak ${newBurst} concurrent animations`);
|
||||
assert(oldBurst > 200, `old model under burst >200 timers (got ${oldBurst})`);
|
||||
assert(newBurst <= 20, `new model under burst stays ≤20 (got ${newBurst})`);
|
||||
|
||||
console.log('\n=== drawAnimatedLine frame-drop catch-up ===');
|
||||
|
||||
// Read the source and verify catch-up logic exists
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(__dirname + '/public/live.js', 'utf8');
|
||||
|
||||
// Extract the animateLine function body
|
||||
const lineMatch = src.match(/function animateLine\(now\)\s*\{[\s\S]*?requestAnimationFrame\(animateLine\)/);
|
||||
assert(lineMatch && /Math\.min\(Math\.floor\(elapsed\s*\/\s*33\)/.test(lineMatch[0]),
|
||||
'drawAnimatedLine catches up on frame drops (multi-tick per frame)');
|
||||
|
||||
const fadeMatch = src.match(/function animateFade\(now\)\s*\{[\s\S]*?requestAnimationFrame\(animateFade\)/);
|
||||
assert(fadeMatch && /Math\.min\(Math\.floor\(fadeElapsed\s*\/\s*52\)/.test(fadeMatch[0]),
|
||||
'animateFade catches up on frame drops (multi-tick per frame)');
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -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
|
||||
|
||||
@@ -564,6 +564,40 @@ console.log('\n=== hop-resolver.js ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== haversineKm exposed from HopResolver (issue #433) =====
|
||||
console.log('\n=== haversineKm (hop-resolver.js) ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
ctx.IATA_COORDS_GEO = {};
|
||||
loadInCtx(ctx, 'public/hop-resolver.js');
|
||||
const HR = ctx.window.HopResolver;
|
||||
|
||||
test('haversineKm is exported', () => {
|
||||
assert.strictEqual(typeof HR.haversineKm, 'function');
|
||||
});
|
||||
|
||||
test('haversineKm same point = 0', () => {
|
||||
assert.strictEqual(HR.haversineKm(37.0, -122.0, 37.0, -122.0), 0);
|
||||
});
|
||||
|
||||
test('haversineKm SF to LA ~559km', () => {
|
||||
// San Francisco (37.7749, -122.4194) to Los Angeles (34.0522, -118.2437)
|
||||
const d = HR.haversineKm(37.7749, -122.4194, 34.0522, -118.2437);
|
||||
assert.ok(d > 550 && d < 570, `Expected ~559km, got ${d}`);
|
||||
});
|
||||
|
||||
test('haversineKm differs from old Euclidean approximation', () => {
|
||||
// The old code used dLat*111, dLon*85 which is inaccurate at high latitudes
|
||||
// Oslo (59.9, 10.7) to Stockholm (59.3, 18.0)
|
||||
const haversine = HR.haversineKm(59.9, 10.7, 59.3, 18.0);
|
||||
const dLat = (59.9 - 59.3) * 111;
|
||||
const dLon = (10.7 - 18.0) * 85;
|
||||
const euclidean = Math.sqrt(dLat*dLat + dLon*dLon);
|
||||
// Haversine should give ~415km, Euclidean ~627km (wrong because dLon*85 is wrong at 60° latitude)
|
||||
assert.ok(Math.abs(haversine - euclidean) > 50, `Expected significant difference, haversine=${haversine.toFixed(1)}, euclidean=${euclidean.toFixed(1)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SNR/RSSI Number casting =====
|
||||
{
|
||||
// These test the pattern used in observer-detail.js, home.js, traces.js, live.js
|
||||
@@ -966,6 +1000,66 @@ console.log('\n=== live.js: pruneStaleNodes ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== live.js: vcrFormatTime respects UTC/local setting =====
|
||||
console.log('\n=== live.js: vcrFormatTime UTC/local ===');
|
||||
{
|
||||
function makeLiveSandboxForVcr() {
|
||||
const ctx = makeSandbox();
|
||||
ctx.L = { map: () => ({ on: () => {}, setView: () => {}, addLayer: () => {}, remove: () => {} }), tileLayer: () => ({ addTo: () => {} }), layerGroup: () => ({ addTo: () => {}, clearLayers: () => {}, addLayer: () => {} }), circleMarker: () => ({ addTo: () => {}, remove: () => {}, setStyle: () => {}, getLatLng: () => ({}), on: () => {} }), Polyline: function() { return { addTo: () => {}, remove: () => {} }; }, Control: { extend: () => function() { return { addTo: () => {} }; } } };
|
||||
ctx.Chart = function() { return { destroy: () => {}, update: () => {} }; };
|
||||
ctx.navigator = {};
|
||||
ctx.visualViewport = null;
|
||||
ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} };
|
||||
ctx.document.body = { appendChild: () => {}, removeChild: () => {}, contains: () => false };
|
||||
ctx.document.querySelector = () => null;
|
||||
ctx.document.querySelectorAll = () => [];
|
||||
ctx.document.createElementNS = () => ctx.document.createElement();
|
||||
ctx.cancelAnimationFrame = () => {};
|
||||
ctx.IATA_COORDS_GEO = {};
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
try { loadInCtx(ctx, 'public/live.js'); } catch (e) {
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test('vcrFormatTime is exposed as window._vcrFormatTime', () => {
|
||||
const ctx = makeLiveSandboxForVcr();
|
||||
assert.strictEqual(typeof ctx.window._vcrFormatTime, 'function', '_vcrFormatTime must be exposed');
|
||||
});
|
||||
|
||||
test('vcrFormatTime uses UTC hours when timezone is utc', () => {
|
||||
const ctx = makeLiveSandboxForVcr();
|
||||
const fn = ctx.window._vcrFormatTime;
|
||||
assert.ok(fn, '_vcrFormatTime must be exposed');
|
||||
// Force UTC mode
|
||||
ctx.getTimestampTimezone = () => 'utc';
|
||||
// Use a known timestamp: 2024-01-15 14:30:45 UTC = different local time in most zones
|
||||
const tsMs = Date.UTC(2024, 0, 15, 14, 30, 45);
|
||||
const result = fn(tsMs);
|
||||
assert.strictEqual(result, '14:30:45', 'UTC mode must show UTC hours 14:30:45');
|
||||
});
|
||||
|
||||
test('vcrFormatTime uses local hours when timezone is local', () => {
|
||||
const ctx = makeLiveSandboxForVcr();
|
||||
const fn = ctx.window._vcrFormatTime;
|
||||
assert.ok(fn, '_vcrFormatTime must be exposed');
|
||||
ctx.getTimestampTimezone = () => 'local';
|
||||
const d = new Date(2024, 0, 15, 9, 5, 3); // local time
|
||||
const expected = String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0') + ':' + String(d.getSeconds()).padStart(2,'0');
|
||||
assert.strictEqual(fn(d.getTime()), expected, 'local mode must use local hours');
|
||||
});
|
||||
|
||||
test('vcrFormatTime zero-pads single-digit hours, minutes, seconds', () => {
|
||||
const ctx = makeLiveSandboxForVcr();
|
||||
const fn = ctx.window._vcrFormatTime;
|
||||
assert.ok(fn, '_vcrFormatTime must be exposed');
|
||||
ctx.getTimestampTimezone = () => 'utc';
|
||||
const tsMs = Date.UTC(2024, 0, 15, 3, 5, 7); // 03:05:07 UTC
|
||||
assert.strictEqual(fn(tsMs), '03:05:07');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== NODES.JS: isAdvertMessage + auto-update logic =====
|
||||
console.log('\n=== nodes.js: isAdvertMessage ===');
|
||||
{
|
||||
@@ -1181,6 +1275,61 @@ console.log('\n=== nodes.js: WS handler runtime behavior ===');
|
||||
assert.ok(env.getApiCalls() > 0, 'api called because _allNodes was reset to null');
|
||||
});
|
||||
|
||||
test('ADVERT for known node upserts in-place without API fetch', () => {
|
||||
const env = makeNodesWsSandbox();
|
||||
// Pre-populate _allNodes with a known node
|
||||
assert.ok(typeof env.ctx.window._nodesSetAllNodes === 'function', '_nodesSetAllNodes must be exposed');
|
||||
env.ctx.window._nodesSetAllNodes([
|
||||
{ public_key: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', name: 'OldName', role: 'repeater', lat: null, lon: null, last_seen: '2024-01-01T00:00:00Z' }
|
||||
]);
|
||||
env.resetCounters();
|
||||
|
||||
env.sendWS({
|
||||
type: 'packet',
|
||||
data: {
|
||||
packet: { payload_type: 4, timestamp: '2024-06-01T12:00:00Z' },
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'ADVERT' },
|
||||
payload: { type: 'ADVERT', pubKey: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', name: 'NewName', lat: 50.85, lon: 4.35 }
|
||||
}
|
||||
}
|
||||
});
|
||||
env.fireTimers();
|
||||
|
||||
assert.strictEqual(env.getApiCalls(), 0, 'known node upsert must NOT trigger API fetch');
|
||||
assert.strictEqual(env.getInvalidated().length, 0, 'no cache invalidation for known node upsert');
|
||||
const nodes = env.ctx.window._nodesGetAllNodes();
|
||||
assert.ok(nodes, '_nodesGetAllNodes must be exposed');
|
||||
assert.strictEqual(nodes[0].name, 'NewName', 'name must be updated in place');
|
||||
assert.strictEqual(nodes[0].lat, 50.85, 'lat must be updated in place');
|
||||
assert.strictEqual(nodes[0].lon, 4.35, 'lon must be updated in place');
|
||||
assert.strictEqual(nodes[0].last_seen, '2024-06-01T12:00:00Z', 'last_seen must be updated from packet timestamp');
|
||||
});
|
||||
|
||||
test('ADVERT for unknown node falls back to full reload', () => {
|
||||
const env = makeNodesWsSandbox();
|
||||
env.ctx.window._nodesSetAllNodes([
|
||||
{ public_key: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', name: 'ExistingNode', role: 'repeater' }
|
||||
]);
|
||||
env.resetCounters();
|
||||
|
||||
// Send ADVERT from a pubKey NOT in _allNodes
|
||||
env.sendWS({
|
||||
type: 'packet',
|
||||
data: {
|
||||
packet: { payload_type: 4 },
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'ADVERT' },
|
||||
payload: { type: 'ADVERT', pubKey: 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', name: 'BrandNewNode' }
|
||||
}
|
||||
}
|
||||
});
|
||||
env.fireTimers();
|
||||
|
||||
assert.ok(env.getApiCalls() > 0, 'unknown node must trigger full reload');
|
||||
assert.ok(env.getInvalidated().includes('/nodes'), 'cache must be invalidated for unknown node');
|
||||
});
|
||||
|
||||
test('scroll position and selection preserved during WS-triggered refresh', () => {
|
||||
const env = makeNodesWsSandbox();
|
||||
// Simulate scrolled panel state — WS handler should not touch scroll or rebuild panel
|
||||
@@ -1960,6 +2109,43 @@ console.log('\n=== customize.js: initState merge behavior ===');
|
||||
assert.strictEqual(state.theme.accent, '#abcdef');
|
||||
assert.strictEqual(state.theme.navBg, '#fedcba');
|
||||
});
|
||||
|
||||
test('initState uses _SITE_CONFIG_ORIGINAL_HOME to bypass contaminated SITE_CONFIG.home', () => {
|
||||
// Simulates: app.js called mergeUserHomeConfig which mutated SITE_CONFIG.home.steps = []
|
||||
// The original server steps must still be recoverable via _SITE_CONFIG_ORIGINAL_HOME
|
||||
const ctx = makeSandbox();
|
||||
ctx.setTimeout = function (fn) { fn(); return 1; };
|
||||
ctx.clearTimeout = function () {};
|
||||
// SITE_CONFIG.home is contaminated — steps wiped by mergeUserHomeConfig at page load
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
steps: [] // contaminated — user had steps:[] in localStorage at page load
|
||||
}
|
||||
};
|
||||
// app.js snapshots original before mutation
|
||||
ctx.window._SITE_CONFIG_ORIGINAL_HOME = {
|
||||
heroTitle: 'Server Hero',
|
||||
steps: [{ emoji: '🧪', title: 'Original Step', description: 'from server' }]
|
||||
};
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
const state = ex.getState();
|
||||
assert.strictEqual(state.home.steps.length, 1, 'should restore from snapshot, not contaminated SITE_CONFIG');
|
||||
assert.strictEqual(state.home.steps[0].title, 'Original Step');
|
||||
});
|
||||
|
||||
test('initState uses DEFAULTS.home when no SITE_CONFIG and no snapshot', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.setTimeout = function (fn) { fn(); return 1; };
|
||||
ctx.clearTimeout = function () {};
|
||||
// No SITE_CONFIG at all — pure DEFAULTS
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
const state = ex.getState();
|
||||
assert.ok(state.home.steps.length > 0, 'should use DEFAULTS.home.steps when no server config');
|
||||
assert.strictEqual(state.home.steps[0].title, 'Join the Bay Area MeshCore Discord');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== APP.JS: home rehydration merge =====
|
||||
@@ -2696,6 +2882,662 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== live.js: nextHop null guards =====
|
||||
console.log('\n=== live.js: nextHop null guards ===');
|
||||
{
|
||||
const liveSource = fs.readFileSync('public/live.js', 'utf8');
|
||||
|
||||
test('nextHop guards animLayer null before use', () => {
|
||||
assert.ok(liveSource.includes('if (!animLayer) return;'),
|
||||
'nextHop must return early when animLayer is null (post-destroy)');
|
||||
});
|
||||
|
||||
test('nextHop setInterval guards animLayer null', () => {
|
||||
assert.ok(liveSource.includes('if (!animLayer || !animLayer.hasLayer(ghost))'),
|
||||
'setInterval in nextHop must guard animLayer null');
|
||||
});
|
||||
|
||||
test('nextHop setTimeout guards animLayer null', () => {
|
||||
assert.ok(liveSource.includes('if (animLayer && animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost)'),
|
||||
'setTimeout in nextHop must guard animLayer null');
|
||||
});
|
||||
|
||||
test('nextHop guards liveAnimCount element null', () => {
|
||||
assert.ok(liveSource.includes('const countEl = document.getElementById(\'liveAnimCount\')'),
|
||||
'nextHop must null-check liveAnimCount element');
|
||||
assert.ok(liveSource.includes('if (countEl) countEl.textContent = activeAnims'),
|
||||
'nextHop must conditionally update liveAnimCount');
|
||||
});
|
||||
}
|
||||
|
||||
// === channels.js: formatHashHex (#465) ===
|
||||
console.log('\n=== channels.js: formatHashHex (issue #465) ===');
|
||||
{
|
||||
const chSource = fs.readFileSync('public/channels.js', 'utf8');
|
||||
|
||||
test('formatHashHex exists in channels.js', () => {
|
||||
assert.ok(chSource.includes('function formatHashHex('), 'formatHashHex function must exist');
|
||||
});
|
||||
|
||||
test('channel fallback name uses formatHashHex', () => {
|
||||
assert.ok(chSource.includes('formatHashHex(ch.hash)'), 'renderChannelList must format hash as hex');
|
||||
assert.ok(chSource.includes('formatHashHex(hash)'), 'selectChannel must format hash as hex');
|
||||
});
|
||||
|
||||
test('formatHashHex produces correct hex output', () => {
|
||||
// Extract and evaluate the function
|
||||
const match = chSource.match(/function formatHashHex\(hash\)\s*\{[^}]+\}/);
|
||||
assert.ok(match, 'should extract formatHashHex');
|
||||
const ctx = vm.createContext({});
|
||||
vm.runInContext(match[0], ctx);
|
||||
const fmt = vm.runInContext('formatHashHex', ctx);
|
||||
assert.strictEqual(fmt(10), '0x0A');
|
||||
assert.strictEqual(fmt(255), '0xFF');
|
||||
assert.strictEqual(fmt(0), '0x00');
|
||||
assert.strictEqual(fmt(1), '0x01');
|
||||
assert.strictEqual(fmt('LongFast'), 'LongFast'); // string hash passes through
|
||||
});
|
||||
}
|
||||
|
||||
// ===== MAP NEIGHBOR FILTER LOGIC =====
|
||||
{
|
||||
console.log('\n--- Map neighbor filter logic ---');
|
||||
|
||||
// NOTE: applyNeighborFilter is a hand-written copy of the filter logic from
|
||||
// public/map.js _renderMarkersInner. The real code is browser-only (depends on
|
||||
// Leaflet, DOM, closure state) and cannot be imported directly in Node.
|
||||
// If the filter logic in map.js changes, update this copy to match.
|
||||
function applyNeighborFilter(nodes, filters, selectedReferenceNode, neighborPubkeys) {
|
||||
return nodes.filter(n => {
|
||||
if (!n.lat || !n.lon) return false;
|
||||
if (!filters[n.role || 'companion']) return false;
|
||||
if (filters.neighbors && selectedReferenceNode && neighborPubkeys) {
|
||||
const pk = n.public_key;
|
||||
if (pk !== selectedReferenceNode && !neighborPubkeys.has(pk)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const testNodes = [
|
||||
{ public_key: 'aaa', lat: 1, lon: 1, role: 'repeater', name: 'NodeA' },
|
||||
{ public_key: 'bbb', lat: 2, lon: 2, role: 'repeater', name: 'NodeB' },
|
||||
{ public_key: 'ccc', lat: 3, lon: 3, role: 'companion', name: 'NodeC' },
|
||||
{ public_key: 'ddd', lat: 4, lon: 4, role: 'repeater', name: 'NodeD' },
|
||||
];
|
||||
const baseFilters = { repeater: true, companion: true, room: true, sensor: true, neighbors: false };
|
||||
|
||||
test('neighbor filter off shows all nodes', () => {
|
||||
const result = applyNeighborFilter(testNodes, baseFilters, null, null);
|
||||
assert.strictEqual(result.length, 4);
|
||||
});
|
||||
|
||||
test('neighbor filter on with no reference shows all nodes', () => {
|
||||
const f = { ...baseFilters, neighbors: true };
|
||||
const result = applyNeighborFilter(testNodes, f, null, null);
|
||||
assert.strictEqual(result.length, 4);
|
||||
});
|
||||
|
||||
test('neighbor filter on with reference and neighbors filters correctly', () => {
|
||||
const f = { ...baseFilters, neighbors: true };
|
||||
const neighborSet = new Set(['bbb', 'ccc']);
|
||||
const result = applyNeighborFilter(testNodes, f, 'aaa', neighborSet);
|
||||
assert.strictEqual(result.length, 3); // aaa (ref) + bbb + ccc (neighbors)
|
||||
const pks = result.map(n => n.public_key);
|
||||
assert.ok(pks.includes('aaa'), 'reference node should be included');
|
||||
assert.ok(pks.includes('bbb'), 'neighbor bbb should be included');
|
||||
assert.ok(pks.includes('ccc'), 'neighbor ccc should be included');
|
||||
assert.ok(!pks.includes('ddd'), 'non-neighbor ddd should be excluded');
|
||||
});
|
||||
|
||||
test('neighbor filter on with reference and empty neighbors shows only reference', () => {
|
||||
const f = { ...baseFilters, neighbors: true };
|
||||
const neighborSet = new Set();
|
||||
const result = applyNeighborFilter(testNodes, f, 'aaa', neighborSet);
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].public_key, 'aaa');
|
||||
});
|
||||
|
||||
test('neighbor filter respects role filter', () => {
|
||||
const f = { ...baseFilters, neighbors: true, companion: false };
|
||||
const neighborSet = new Set(['bbb', 'ccc']);
|
||||
const result = applyNeighborFilter(testNodes, f, 'aaa', neighborSet);
|
||||
assert.strictEqual(result.length, 2); // aaa + bbb (ccc is companion, filtered out)
|
||||
const pks = result.map(n => n.public_key);
|
||||
assert.ok(!pks.includes('ccc'), 'companion ccc should be filtered by role');
|
||||
});
|
||||
|
||||
// Test path parsing for neighbor extraction
|
||||
test('neighbor extraction from paths data', () => {
|
||||
const refPubkey = 'aaa';
|
||||
const paths = [
|
||||
{ hops: [{ pubkey: 'bbb' }, { pubkey: 'aaa' }, { pubkey: 'ccc' }] },
|
||||
{ hops: [{ pubkey: 'aaa' }, { pubkey: 'ddd' }] },
|
||||
{ hops: [{ pubkey: 'eee' }, { pubkey: 'aaa' }] },
|
||||
];
|
||||
const neighborSet = new Set();
|
||||
for (const p of paths) {
|
||||
const hops = p.hops || [];
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
if (hops[i].pubkey === refPubkey) {
|
||||
if (i > 0 && hops[i - 1].pubkey) neighborSet.add(hops[i - 1].pubkey);
|
||||
if (i < hops.length - 1 && hops[i + 1].pubkey) neighborSet.add(hops[i + 1].pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(neighborSet.has('bbb'), 'bbb is adjacent in path 1');
|
||||
assert.ok(neighborSet.has('ccc'), 'ccc is adjacent in path 1');
|
||||
assert.ok(neighborSet.has('ddd'), 'ddd is adjacent in path 2');
|
||||
assert.ok(neighborSet.has('eee'), 'eee is adjacent in path 3');
|
||||
assert.strictEqual(neighborSet.size, 4);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ===== packets.js: memory bounds =====
|
||||
{
|
||||
console.log('\nPackets page — memory bounds:');
|
||||
const src = fs.readFileSync('public/packets.js', 'utf8');
|
||||
|
||||
test('pauseBuffer is capped at 2000 entries', () => {
|
||||
assert.ok(src.includes('pauseBuffer.length > 2000'),
|
||||
'pauseBuffer cap check must be present');
|
||||
assert.ok(src.includes('pauseBuffer = pauseBuffer.slice(-2000)'),
|
||||
'pauseBuffer must be trimmed to last 2000 entries');
|
||||
});
|
||||
|
||||
test('packets array is trimmed to PACKET_LIMIT after WS update in grouped mode', () => {
|
||||
assert.ok(src.includes('packets.length > PACKET_LIMIT'),
|
||||
'grouped mode must check packets length against PACKET_LIMIT');
|
||||
assert.ok(src.includes('packets.splice(PACKET_LIMIT)'),
|
||||
'grouped mode must splice packets to PACKET_LIMIT');
|
||||
});
|
||||
|
||||
test('evicted packets are removed from hashIndex', () => {
|
||||
assert.ok(/const evicted = packets\.splice\(PACKET_LIMIT\)[\s\S]{0,200}hashIndex\.delete\(p\.hash\)/.test(src),
|
||||
'after splice, evicted entries must be deleted from hashIndex');
|
||||
});
|
||||
|
||||
test('packets array is trimmed to PACKET_LIMIT after WS update in flat mode', () => {
|
||||
assert.ok(/packets = filtered\.concat\(packets\)[\s\S]{0,100}packets\.length = PACKET_LIMIT/.test(src),
|
||||
'flat mode must truncate packets to PACKET_LIMIT after prepend');
|
||||
});
|
||||
|
||||
test('_children is capped at 200 on WebSocket prepend', () => {
|
||||
assert.ok(src.includes('existing._children.length > 200'),
|
||||
'_children cap check must be present');
|
||||
assert.ok(src.includes('existing._children.length = 200'),
|
||||
'_children must be truncated to 200');
|
||||
});
|
||||
|
||||
test('observerMap is built from observers array in loadObservers', () => {
|
||||
assert.ok(src.includes('observerMap = new Map(observers.map(o => [o.id, o]))'),
|
||||
'observerMap must be built as id→observer Map in loadObservers');
|
||||
});
|
||||
|
||||
test('observerMap is reset in destroy', () => {
|
||||
assert.ok(src.includes('observerMap = new Map()'),
|
||||
'destroy must reset observerMap to empty Map');
|
||||
});
|
||||
|
||||
test('WS handler debounces render via _wsRenderTimer', () => {
|
||||
const wsBlock = src.slice(src.indexOf('wsHandler = debouncedOnWS'), src.indexOf('function destroy()'));
|
||||
assert.ok(wsBlock.includes('_wsRenderTimer'),
|
||||
'WS handler must debounce renders via _wsRenderTimer');
|
||||
assert.ok(wsBlock.includes('clearTimeout(_wsRenderTimer)'),
|
||||
'WS handler must clear pending timer before scheduling new render');
|
||||
assert.ok(/setTimeout\(function \(\) \{ renderTableRows\(\); \}/.test(wsBlock),
|
||||
'WS handler must schedule renderTableRows via setTimeout');
|
||||
});
|
||||
|
||||
test('destroy clears _wsRenderTimer', () => {
|
||||
const destroyBlock = src.slice(src.indexOf('function destroy()'), src.indexOf('function destroy()') + 500);
|
||||
assert.ok(destroyBlock.includes('clearTimeout(_wsRenderTimer)'),
|
||||
'destroy must clear _wsRenderTimer to prevent stale renders after navigation');
|
||||
});
|
||||
}
|
||||
// ===== NODES.JS: shared sandbox factory =====
|
||||
function makeNodesSandbox(opts) {
|
||||
opts = opts || {};
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, getRegionParam: () => '', offChange: () => {} };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = (fn) => fn;
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.favStar = () => '';
|
||||
ctx.bindFavStars = () => {};
|
||||
if (opts.liveGetFavorites) {
|
||||
ctx.getFavorites = () => {
|
||||
try { return JSON.parse(ctx.localStorage.getItem('meshcore-favorites') || '[]'); } catch(e) { return []; }
|
||||
};
|
||||
} else {
|
||||
ctx.getFavorites = () => [];
|
||||
}
|
||||
ctx.isFavorite = () => false;
|
||||
ctx.connectWS = () => {};
|
||||
ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
|
||||
ctx.api = () => Promise.resolve({ nodes: [], counts: {} });
|
||||
ctx.CLIENT_TTL = { nodeList: 90000, nodeDetail: 240000, nodeHealth: 240000 };
|
||||
ctx.initTabBar = () => {};
|
||||
ctx.makeColumnsResizable = () => {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.Set = Set;
|
||||
loadInCtx(ctx, 'public/nodes.js');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ===== NODES.JS: toggleSort / sortNodes / sortArrow (P0 coverage) =====
|
||||
console.log('\n=== nodes.js: toggleSort / sortNodes / sortArrow ===');
|
||||
{
|
||||
// --- toggleSort ---
|
||||
test('toggleSort switches direction on same column', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
ctx.window._nodesToggleSort('name');
|
||||
assert.strictEqual(ctx.window._nodesGetSortState().direction, 'desc');
|
||||
});
|
||||
|
||||
test('toggleSort to different column sets default direction', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
ctx.window._nodesToggleSort('last_seen');
|
||||
const s = ctx.window._nodesGetSortState();
|
||||
assert.strictEqual(s.column, 'last_seen');
|
||||
assert.strictEqual(s.direction, 'desc'); // last_seen defaults desc
|
||||
});
|
||||
|
||||
test('toggleSort to name column defaults asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'desc' });
|
||||
ctx.window._nodesToggleSort('name');
|
||||
const s = ctx.window._nodesGetSortState();
|
||||
assert.strictEqual(s.column, 'name');
|
||||
assert.strictEqual(s.direction, 'asc');
|
||||
});
|
||||
|
||||
test('toggleSort to advert_count defaults desc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
ctx.window._nodesToggleSort('advert_count');
|
||||
assert.strictEqual(ctx.window._nodesGetSortState().direction, 'desc');
|
||||
});
|
||||
|
||||
test('toggleSort to role defaults asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'desc' });
|
||||
ctx.window._nodesToggleSort('role');
|
||||
assert.strictEqual(ctx.window._nodesGetSortState().direction, 'asc');
|
||||
});
|
||||
|
||||
test('toggleSort persists to localStorage', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesToggleSort('name');
|
||||
const stored = JSON.parse(ctx.localStorage.getItem('meshcore-nodes-sort'));
|
||||
assert.strictEqual(stored.column, 'name');
|
||||
});
|
||||
|
||||
// --- sortNodes ---
|
||||
test('sortNodes by name asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: 'Charlie', public_key: 'c' },
|
||||
{ name: 'Alpha', public_key: 'a' },
|
||||
{ name: 'Bravo', public_key: 'b' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Alpha');
|
||||
assert.strictEqual(result[1].name, 'Bravo');
|
||||
assert.strictEqual(result[2].name, 'Charlie');
|
||||
});
|
||||
|
||||
test('sortNodes by name desc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'desc' });
|
||||
const arr = [
|
||||
{ name: 'Alpha', public_key: 'a' },
|
||||
{ name: 'Charlie', public_key: 'c' },
|
||||
{ name: 'Bravo', public_key: 'b' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Charlie');
|
||||
assert.strictEqual(result[2].name, 'Alpha');
|
||||
});
|
||||
|
||||
test('sortNodes by name puts unnamed last (asc)', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: null, public_key: 'x' },
|
||||
{ name: 'Alpha', public_key: 'a' },
|
||||
{ name: '', public_key: 'y' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Alpha');
|
||||
});
|
||||
|
||||
test('sortNodes by last_seen desc (most recent first)', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'desc' });
|
||||
const now = Date.now();
|
||||
const arr = [
|
||||
{ name: 'Old', last_heard: new Date(now - 100000).toISOString() },
|
||||
{ name: 'New', last_heard: new Date(now).toISOString() },
|
||||
{ name: 'Mid', last_heard: new Date(now - 50000).toISOString() },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'New');
|
||||
assert.strictEqual(result[2].name, 'Old');
|
||||
});
|
||||
|
||||
test('sortNodes by last_seen asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'asc' });
|
||||
const now = Date.now();
|
||||
const arr = [
|
||||
{ name: 'New', last_heard: new Date(now).toISOString() },
|
||||
{ name: 'Old', last_heard: new Date(now - 100000).toISOString() },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Old');
|
||||
assert.strictEqual(result[1].name, 'New');
|
||||
});
|
||||
|
||||
test('sortNodes by last_seen falls back to last_seen when last_heard missing', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'desc' });
|
||||
const now = Date.now();
|
||||
const arr = [
|
||||
{ name: 'A', last_seen: new Date(now - 100000).toISOString() },
|
||||
{ name: 'B', last_heard: new Date(now).toISOString() },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'B');
|
||||
});
|
||||
|
||||
test('sortNodes by last_seen handles missing timestamps', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'desc' });
|
||||
const arr = [
|
||||
{ name: 'NoTime' },
|
||||
{ name: 'HasTime', last_heard: new Date().toISOString() },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'HasTime');
|
||||
});
|
||||
|
||||
test('sortNodes by advert_count desc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'advert_count', direction: 'desc' });
|
||||
const arr = [
|
||||
{ name: 'Low', advert_count: 5 },
|
||||
{ name: 'High', advert_count: 100 },
|
||||
{ name: 'Mid', advert_count: 50 },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'High');
|
||||
assert.strictEqual(result[2].name, 'Low');
|
||||
});
|
||||
|
||||
test('sortNodes by advert_count asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'advert_count', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: 'High', advert_count: 100 },
|
||||
{ name: 'Low', advert_count: 5 },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Low');
|
||||
});
|
||||
|
||||
test('sortNodes by role asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'role', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: 'A', role: 'sensor' },
|
||||
{ name: 'B', role: 'companion' },
|
||||
{ name: 'C', role: 'repeater' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].role, 'companion');
|
||||
assert.strictEqual(result[1].role, 'repeater');
|
||||
assert.strictEqual(result[2].role, 'sensor');
|
||||
});
|
||||
|
||||
test('sortNodes by public_key asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'public_key', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: 'C', public_key: 'ccc' },
|
||||
{ name: 'A', public_key: 'aaa' },
|
||||
{ name: 'B', public_key: 'bbb' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].public_key, 'aaa');
|
||||
assert.strictEqual(result[2].public_key, 'ccc');
|
||||
});
|
||||
|
||||
test('sortNodes handles unknown column gracefully', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'nonexistent', direction: 'asc' });
|
||||
const arr = [{ name: 'A' }, { name: 'B' }];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result.length, 2); // no crash
|
||||
});
|
||||
|
||||
test('sortNodes with empty array', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
const result = ctx.window._nodesSortNodes([]);
|
||||
assert.deepStrictEqual(result, []);
|
||||
});
|
||||
|
||||
test('sortNodes name case-insensitive', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: 'bravo' },
|
||||
{ name: 'Alpha' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Alpha');
|
||||
assert.strictEqual(result[1].name, 'bravo');
|
||||
});
|
||||
|
||||
// --- sortArrow ---
|
||||
test('sortArrow returns arrow for active column', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
const html = ctx.window._nodesSortArrow('name');
|
||||
assert.ok(html.includes('▲'));
|
||||
assert.ok(html.includes('sort-arrow'));
|
||||
});
|
||||
|
||||
test('sortArrow returns down arrow for desc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'desc' });
|
||||
const html = ctx.window._nodesSortArrow('name');
|
||||
assert.ok(html.includes('▼'));
|
||||
});
|
||||
|
||||
test('sortArrow returns empty for inactive column', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
assert.strictEqual(ctx.window._nodesSortArrow('role'), '');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== NODES.JS: syncClaimedToFavorites =====
|
||||
console.log('\n=== nodes.js: syncClaimedToFavorites ===');
|
||||
{
|
||||
|
||||
test('syncClaimedToFavorites adds claimed pubkeys to favorites', () => {
|
||||
const ctx = makeNodesSandbox({ liveGetFavorites: true });
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', JSON.stringify([
|
||||
{ pubkey: 'key1' }, { pubkey: 'key2' }
|
||||
]));
|
||||
ctx.localStorage.setItem('meshcore-favorites', JSON.stringify(['key1']));
|
||||
ctx.window._nodesSyncClaimedToFavorites();
|
||||
const favs = JSON.parse(ctx.localStorage.getItem('meshcore-favorites'));
|
||||
assert.ok(favs.includes('key1'));
|
||||
assert.ok(favs.includes('key2'));
|
||||
assert.strictEqual(favs.length, 2);
|
||||
});
|
||||
|
||||
test('syncClaimedToFavorites no-ops when all claimed already favorited', () => {
|
||||
const ctx = makeNodesSandbox({ liveGetFavorites: true });
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', JSON.stringify([{ pubkey: 'key1' }]));
|
||||
ctx.localStorage.setItem('meshcore-favorites', JSON.stringify(['key1', 'key2']));
|
||||
ctx.window._nodesSyncClaimedToFavorites();
|
||||
const favs = JSON.parse(ctx.localStorage.getItem('meshcore-favorites'));
|
||||
assert.deepStrictEqual(favs, ['key1', 'key2']); // unchanged
|
||||
});
|
||||
|
||||
test('syncClaimedToFavorites handles empty my-nodes', () => {
|
||||
const ctx = makeNodesSandbox({ liveGetFavorites: true });
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', '[]');
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["key1"]');
|
||||
ctx.window._nodesSyncClaimedToFavorites();
|
||||
const favs = JSON.parse(ctx.localStorage.getItem('meshcore-favorites'));
|
||||
assert.deepStrictEqual(favs, ['key1']); // unchanged
|
||||
});
|
||||
|
||||
test('syncClaimedToFavorites handles missing localStorage keys', () => {
|
||||
const ctx = makeNodesSandbox({ liveGetFavorites: true });
|
||||
// No meshcore-my-nodes or meshcore-favorites set
|
||||
ctx.window._nodesSyncClaimedToFavorites(); // should not crash
|
||||
});
|
||||
}
|
||||
|
||||
// ===== NODES.JS: renderNodeTimestampHtml / renderNodeTimestampText =====
|
||||
console.log('\n=== nodes.js: renderNodeTimestampHtml / renderNodeTimestampText ===');
|
||||
{
|
||||
|
||||
test('renderNodeTimestampHtml returns HTML with tooltip', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
const d = new Date(Date.now() - 300000).toISOString();
|
||||
const html = ctx.window._nodesRenderNodeTimestampHtml(d);
|
||||
assert.ok(html.includes('timestamp-text'), 'should have timestamp-text class');
|
||||
assert.ok(html.includes('title='), 'should have tooltip');
|
||||
});
|
||||
|
||||
test('renderNodeTimestampHtml marks future timestamps', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
const d = new Date(Date.now() + 120000).toISOString();
|
||||
const html = ctx.window._nodesRenderNodeTimestampHtml(d);
|
||||
assert.ok(html.includes('timestamp-future-icon'), 'future timestamp should show warning');
|
||||
});
|
||||
|
||||
test('renderNodeTimestampHtml handles null', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
const html = ctx.window._nodesRenderNodeTimestampHtml(null);
|
||||
assert.ok(html.includes('—'), 'null should produce dash');
|
||||
});
|
||||
|
||||
test('renderNodeTimestampText returns plain text', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
const d = new Date(Date.now() - 300000).toISOString();
|
||||
const text = ctx.window._nodesRenderNodeTimestampText(d);
|
||||
assert.ok(!text.includes('<'), 'should be plain text, not HTML');
|
||||
assert.ok(text.includes('5m ago') || text.includes('ago') || /^\d{4}/.test(text), 'should be a readable timestamp');
|
||||
});
|
||||
|
||||
test('renderNodeTimestampText handles null', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
const text = ctx.window._nodesRenderNodeTimestampText(null);
|
||||
assert.strictEqual(text, '—');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== NODES.JS: getStatusInfo edge cases (P0 coverage expansion) =====
|
||||
console.log('\n=== nodes.js: getStatusInfo edge cases ===');
|
||||
{
|
||||
|
||||
const ctx = makeNodesSandbox();
|
||||
const gsi = ctx.window._nodesGetStatusInfo;
|
||||
const gst = ctx.window._nodesGetStatusTooltip;
|
||||
|
||||
test('getStatusInfo with _lastHeard prefers it over last_heard', () => {
|
||||
const recent = new Date().toISOString();
|
||||
const old = new Date(Date.now() - 96 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'repeater', last_heard: old, _lastHeard: recent });
|
||||
assert.strictEqual(info.status, 'active');
|
||||
});
|
||||
|
||||
test('getStatusInfo with no timestamps returns stale', () => {
|
||||
const info = gsi({ role: 'companion' });
|
||||
assert.strictEqual(info.status, 'stale');
|
||||
assert.strictEqual(info.lastHeardMs, 0);
|
||||
});
|
||||
|
||||
test('getStatusInfo uses last_seen as fallback', () => {
|
||||
const recent = new Date().toISOString();
|
||||
const info = gsi({ role: 'repeater', last_seen: recent });
|
||||
assert.strictEqual(info.status, 'active');
|
||||
});
|
||||
|
||||
test('getStatusInfo room uses infrastructure threshold (72h)', () => {
|
||||
const d48h = new Date(Date.now() - 48 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'room', last_heard: d48h });
|
||||
assert.strictEqual(info.status, 'active'); // 48h < 72h threshold
|
||||
});
|
||||
|
||||
test('getStatusInfo room stale at 96h', () => {
|
||||
const d96h = new Date(Date.now() - 96 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'room', last_heard: d96h });
|
||||
assert.strictEqual(info.status, 'stale');
|
||||
});
|
||||
|
||||
test('getStatusInfo sensor stale at 25h', () => {
|
||||
const d25h = new Date(Date.now() - 25 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'sensor', last_heard: d25h });
|
||||
assert.strictEqual(info.status, 'stale');
|
||||
});
|
||||
|
||||
test('getStatusInfo returns explanation for active node', () => {
|
||||
const info = gsi({ role: 'repeater', last_heard: new Date().toISOString() });
|
||||
assert.ok(info.explanation.includes('Last heard'));
|
||||
});
|
||||
|
||||
test('getStatusInfo returns explanation for stale companion', () => {
|
||||
const d48h = new Date(Date.now() - 48 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'companion', last_heard: d48h });
|
||||
assert.ok(info.explanation.includes('companions'));
|
||||
});
|
||||
|
||||
test('getStatusInfo returns explanation for stale repeater', () => {
|
||||
const d96h = new Date(Date.now() - 96 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'repeater', last_heard: d96h });
|
||||
assert.ok(info.explanation.includes('repeaters'));
|
||||
});
|
||||
|
||||
test('getStatusInfo roleColor defaults to gray for unknown role', () => {
|
||||
const info = gsi({ role: 'unknown_role', last_heard: new Date().toISOString() });
|
||||
assert.strictEqual(info.roleColor, '#6b7280');
|
||||
});
|
||||
|
||||
// --- getStatusTooltip edge cases ---
|
||||
test('getStatusTooltip active room mentions 72h', () => {
|
||||
assert.ok(gst('room', 'active').includes('72h'));
|
||||
});
|
||||
|
||||
test('getStatusTooltip stale room mentions offline', () => {
|
||||
assert.ok(gst('room', 'stale').includes('offline'));
|
||||
});
|
||||
|
||||
test('getStatusTooltip active sensor mentions 24h', () => {
|
||||
assert.ok(gst('sensor', 'active').includes('24h'));
|
||||
});
|
||||
|
||||
test('getStatusTooltip stale repeater mentions offline', () => {
|
||||
assert.ok(gst('repeater', 'stale').includes('offline'));
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* Unit tests for live.js animation system — verifies rAF migration and concurrency cap */
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
const src = fs.readFileSync('public/live.js', 'utf8');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
console.log('\n=== Animation interval elimination ===');
|
||||
|
||||
test('pulseNode does not use setInterval', () => {
|
||||
// Extract pulseNode function body
|
||||
const pulseStart = src.indexOf('function pulseNode(');
|
||||
const nextFn = src.indexOf('\n function ', pulseStart + 1);
|
||||
const body = src.substring(pulseStart, nextFn);
|
||||
assert.ok(!body.includes('setInterval'), 'pulseNode still uses setInterval');
|
||||
assert.ok(body.includes('requestAnimationFrame'), 'pulseNode should use requestAnimationFrame');
|
||||
});
|
||||
|
||||
test('drawAnimatedLine does not use setInterval', () => {
|
||||
const drawStart = src.indexOf('function drawAnimatedLine(');
|
||||
const nextFn = src.indexOf('\n function ', drawStart + 1);
|
||||
const body = src.substring(drawStart, nextFn);
|
||||
assert.ok(!body.includes('setInterval'), 'drawAnimatedLine still uses setInterval');
|
||||
assert.ok(body.includes('requestAnimationFrame'), 'drawAnimatedLine should use requestAnimationFrame');
|
||||
});
|
||||
|
||||
test('ghost hop pulse does not use setInterval', () => {
|
||||
// Ghost pulse is inside animatePath
|
||||
const animStart = src.indexOf('function animatePath(');
|
||||
const animEnd = src.indexOf('\n function ', animStart + 1);
|
||||
const body = src.substring(animStart, animEnd);
|
||||
assert.ok(!body.includes('setInterval'), 'animatePath still uses setInterval');
|
||||
});
|
||||
|
||||
console.log('\n=== Concurrency cap ===');
|
||||
|
||||
test('MAX_CONCURRENT_ANIMS is defined', () => {
|
||||
assert.ok(src.includes('MAX_CONCURRENT_ANIMS'), 'MAX_CONCURRENT_ANIMS constant not found');
|
||||
});
|
||||
|
||||
test('MAX_CONCURRENT_ANIMS is set to 20', () => {
|
||||
const match = src.match(/MAX_CONCURRENT_ANIMS\s*=\s*(\d+)/);
|
||||
assert.ok(match, 'Could not parse MAX_CONCURRENT_ANIMS value');
|
||||
assert.strictEqual(parseInt(match[1]), 20);
|
||||
});
|
||||
|
||||
test('animatePath checks MAX_CONCURRENT_ANIMS before proceeding', () => {
|
||||
const animStart = src.indexOf('function animatePath(');
|
||||
// Check that within the first 200 chars of the function, we check the cap
|
||||
const snippet = src.substring(animStart, animStart + 300);
|
||||
assert.ok(snippet.includes('activeAnims >= MAX_CONCURRENT_ANIMS'), 'animatePath should check activeAnims against cap');
|
||||
});
|
||||
|
||||
console.log('\n=== Safety: no stale setInterval in animation functions ===');
|
||||
|
||||
test('no setInterval remains in animation hot path', () => {
|
||||
// The only acceptable setIntervals are the UI ones (timeline, clock, prune, rate counter)
|
||||
// Count total setInterval occurrences
|
||||
const matches = src.match(/setInterval\(/g) || [];
|
||||
// Count known OK ones: _timelineRefreshInterval, _lcdClockInterval, _pruneInterval, _rateCounterInterval
|
||||
const okPatterns = ['_timelineRefreshInterval', '_lcdClockInterval', '_pruneInterval', '_rateCounterInterval'];
|
||||
let okCount = 0;
|
||||
for (const p of okPatterns) {
|
||||
if (src.includes(p + ' = setInterval') || src.includes(p + '= setInterval')) okCount++;
|
||||
}
|
||||
// Allow some non-animation setIntervals (the 4 UI ones above)
|
||||
assert.ok(matches.length <= okCount + 1,
|
||||
`Found ${matches.length} setInterval calls, expected at most ${okCount + 1} (non-animation). Some animation setIntervals may remain.`);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
+763
@@ -0,0 +1,763 @@
|
||||
/* Unit tests for packets.js functions (tested via VM sandbox) */
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ✅ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(` ❌ ${name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a browser-like sandbox with all deps packets.js needs
|
||||
function makeSandbox() {
|
||||
const registeredPages = {};
|
||||
const ctx = {
|
||||
window: {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
innerWidth: 1200,
|
||||
PacketFilter: null,
|
||||
},
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: (tag) => ({
|
||||
tagName: tag.toUpperCase(), id: '', textContent: '', innerHTML: '',
|
||||
className: '', style: {}, appendChild: () => {}, setAttribute: () => {},
|
||||
addEventListener: () => {}, querySelectorAll: () => [], querySelector: () => null,
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false },
|
||||
}),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
body: { appendChild: () => {} },
|
||||
},
|
||||
console,
|
||||
Date,
|
||||
Infinity,
|
||||
Math,
|
||||
Array,
|
||||
Object,
|
||||
String,
|
||||
Number,
|
||||
JSON,
|
||||
RegExp,
|
||||
Error,
|
||||
TypeError,
|
||||
RangeError,
|
||||
parseInt,
|
||||
parseFloat,
|
||||
isNaN,
|
||||
isFinite,
|
||||
encodeURIComponent,
|
||||
decodeURIComponent,
|
||||
setTimeout: () => {},
|
||||
clearTimeout: () => {},
|
||||
setInterval: () => {},
|
||||
clearInterval: () => {},
|
||||
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
|
||||
performance: { now: () => Date.now() },
|
||||
localStorage: (() => {
|
||||
const store = {};
|
||||
return {
|
||||
getItem: k => store[k] || null,
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: k => { delete store[k]; },
|
||||
};
|
||||
})(),
|
||||
location: { hash: '' },
|
||||
history: { replaceState: () => {} },
|
||||
CustomEvent: class CustomEvent {},
|
||||
Map,
|
||||
Set,
|
||||
Promise,
|
||||
URLSearchParams,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
||||
_registeredPages: registeredPages,
|
||||
// Stub global functions packets.js depends on
|
||||
registerPage: (name, handler) => { registeredPages[name] = handler; },
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadInCtx(ctx, file) {
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx, { filename: file });
|
||||
for (const k of Object.keys(ctx.window)) {
|
||||
ctx[k] = ctx.window[k];
|
||||
}
|
||||
}
|
||||
|
||||
function loadPacketsSandbox() {
|
||||
const ctx = makeSandbox();
|
||||
// Load dependencies first
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
// HopDisplay stub (simpler than loading real file which may have DOM deps)
|
||||
vm.runInContext(`
|
||||
window.HopDisplay = {
|
||||
renderHop: function(h, entry, opts) {
|
||||
if (entry && entry.name) return '<span class="hop-named">' + entry.name + '</span>';
|
||||
return '<span class="hop-hex">' + h + '</span>';
|
||||
},
|
||||
_showFromBtn: function() {}
|
||||
};
|
||||
`, ctx);
|
||||
loadInCtx(ctx, 'public/packets.js');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ===== TESTS =====
|
||||
|
||||
console.log('\n=== packets.js: typeName ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('typeName returns known type', () => {
|
||||
assert.strictEqual(api.typeName(0), 'Request');
|
||||
assert.strictEqual(api.typeName(4), 'Advert');
|
||||
assert.strictEqual(api.typeName(5), 'Channel Msg');
|
||||
});
|
||||
|
||||
test('typeName returns fallback for unknown', () => {
|
||||
assert.strictEqual(api.typeName(99), 'Type 99');
|
||||
assert.strictEqual(api.typeName(undefined), 'Type undefined');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: obsName ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('obsName returns dash for falsy id', () => {
|
||||
assert.strictEqual(api.obsName(null), '—');
|
||||
assert.strictEqual(api.obsName(''), '—');
|
||||
assert.strictEqual(api.obsName(undefined), '—');
|
||||
});
|
||||
|
||||
test('obsName returns id when not in observerMap', () => {
|
||||
assert.strictEqual(api.obsName('unknown-id'), 'unknown-id');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: kv ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('kv produces correct HTML', () => {
|
||||
const result = api.kv('Route', 'Direct');
|
||||
assert(result.includes('byop-key'));
|
||||
assert(result.includes('Route'));
|
||||
assert(result.includes('Direct'));
|
||||
assert(result.includes('byop-val'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: sectionRow / fieldRow ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('sectionRow produces section HTML', () => {
|
||||
const result = api.sectionRow('Header');
|
||||
assert(result.includes('section-row'));
|
||||
assert(result.includes('Header'));
|
||||
assert(result.includes('colspan="4"'));
|
||||
});
|
||||
|
||||
test('fieldRow produces field HTML', () => {
|
||||
const result = api.fieldRow(0, 'Header Byte', '0xFF', 'some desc');
|
||||
assert(result.includes('0'));
|
||||
assert(result.includes('Header Byte'));
|
||||
assert(result.includes('0xFF'));
|
||||
assert(result.includes('some desc'));
|
||||
assert(result.includes('mono'));
|
||||
});
|
||||
|
||||
test('fieldRow handles empty description', () => {
|
||||
const result = api.fieldRow(5, 'Test', 'val', '');
|
||||
assert(result.includes('text-muted'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: getDetailPreview ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('getDetailPreview returns empty for null/undefined', () => {
|
||||
assert.strictEqual(api.getDetailPreview(null), '');
|
||||
assert.strictEqual(api.getDetailPreview(undefined), '');
|
||||
});
|
||||
|
||||
test('getDetailPreview handles CHAN type', () => {
|
||||
const result = api.getDetailPreview({ type: 'CHAN', text: 'hello world', channel: 'general' });
|
||||
assert(result.includes('💬'));
|
||||
assert(result.includes('hello world'));
|
||||
assert(result.includes('chan-tag'));
|
||||
assert(result.includes('general'));
|
||||
});
|
||||
|
||||
test('getDetailPreview truncates long CHAN text', () => {
|
||||
const longText = 'x'.repeat(100);
|
||||
const result = api.getDetailPreview({ type: 'CHAN', text: longText });
|
||||
assert(result.includes('…'));
|
||||
assert(!result.includes('x'.repeat(100)));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT type', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'TestNode', pubKey: 'abc123',
|
||||
flags: { repeater: true }
|
||||
});
|
||||
assert(result.includes('📡'));
|
||||
assert(result.includes('TestNode'));
|
||||
assert(result.includes('hop-link'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT room', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'RoomNode', pubKey: 'abc',
|
||||
flags: { room: true }
|
||||
});
|
||||
assert(result.includes('🏠'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT sensor', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'Sensor1', pubKey: 'abc',
|
||||
flags: { sensor: true }
|
||||
});
|
||||
assert(result.includes('🌡'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT companion (default)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'Comp', pubKey: 'abc',
|
||||
flags: {}
|
||||
});
|
||||
assert(result.includes('📻'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_TXT with channelHash (no_key)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_TXT', channelHash: 0xAB, decryptionStatus: 'no_key'
|
||||
});
|
||||
assert(result.includes('🔒'));
|
||||
assert(result.includes('0xAB'));
|
||||
assert(result.includes('no key'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_TXT decryption_failed', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_TXT', channelHash: 5, decryptionStatus: 'decryption_failed'
|
||||
});
|
||||
assert(result.includes('decryption failed'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_TXT with channelHashHex', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_TXT', channelHash: 0xFF, channelHashHex: 'FF'
|
||||
});
|
||||
assert(result.includes('0xFF'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles TXT_MSG', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'TXT_MSG', srcHash: 'abcdef01', destHash: '12345678'
|
||||
});
|
||||
assert(result.includes('✉️'));
|
||||
assert(result.includes('abcdef01'));
|
||||
assert(result.includes('12345678'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles PATH', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'PATH', srcHash: 'aabb', destHash: 'ccdd'
|
||||
});
|
||||
assert(result.includes('🔀'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles REQ', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'REQ', srcHash: 'aa', destHash: 'bb'
|
||||
});
|
||||
assert(result.includes('🔒'));
|
||||
assert(result.includes('aa'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles RESPONSE', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'RESPONSE', srcHash: 'aa', destHash: 'bb'
|
||||
});
|
||||
assert(result.includes('🔒'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ANON_REQ', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ANON_REQ', destHash: 'dd'
|
||||
});
|
||||
assert(result.includes('anon'));
|
||||
assert(result.includes('dd'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles text fallback', () => {
|
||||
const result = api.getDetailPreview({ text: 'some message' });
|
||||
assert(result.includes('some message'));
|
||||
});
|
||||
|
||||
test('getDetailPreview truncates long text fallback', () => {
|
||||
const result = api.getDetailPreview({ text: 'z'.repeat(100) });
|
||||
assert(result.includes('…'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles public_key fallback', () => {
|
||||
const result = api.getDetailPreview({ public_key: 'abcdef1234567890abcdef' });
|
||||
assert(result.includes('📡'));
|
||||
assert(result.includes('abcdef1234567890'));
|
||||
});
|
||||
|
||||
test('getDetailPreview returns empty for empty decoded', () => {
|
||||
assert.strictEqual(api.getDetailPreview({}), '');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: getPathHopCount ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('getPathHopCount with valid path', () => {
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: '["a","b","c"]' }), 3);
|
||||
});
|
||||
|
||||
test('getPathHopCount with empty path', () => {
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: '[]' }), 0);
|
||||
});
|
||||
|
||||
test('getPathHopCount with null/missing', () => {
|
||||
assert.strictEqual(api.getPathHopCount({}), 0);
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: null }), 0);
|
||||
});
|
||||
|
||||
test('getPathHopCount with invalid JSON', () => {
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: 'not json' }), 0);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: sortGroupChildren ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('sortGroupChildren handles null/empty gracefully', () => {
|
||||
api.sortGroupChildren(null);
|
||||
api.sortGroupChildren({});
|
||||
api.sortGroupChildren({ _children: [] });
|
||||
// No throw
|
||||
});
|
||||
|
||||
test('sortGroupChildren default sort groups by observer earliest-first', () => {
|
||||
// Need to set obsSortMode — it reads from closure. Default is 'observer'.
|
||||
const group = {
|
||||
_children: [
|
||||
{ observer_name: 'B', timestamp: '2024-01-01T02:00:00Z' },
|
||||
{ observer_name: 'A', timestamp: '2024-01-01T01:00:00Z' },
|
||||
{ observer_name: 'B', timestamp: '2024-01-01T01:30:00Z' },
|
||||
]
|
||||
};
|
||||
api.sortGroupChildren(group);
|
||||
// A has earliest timestamp, should be first
|
||||
assert.strictEqual(group._children[0].observer_name, 'A');
|
||||
// Then B entries
|
||||
assert.strictEqual(group._children[1].observer_name, 'B');
|
||||
assert.strictEqual(group._children[2].observer_name, 'B');
|
||||
// B entries should be time-ascending within group
|
||||
assert(group._children[1].timestamp < group._children[2].timestamp);
|
||||
});
|
||||
|
||||
test('sortGroupChildren updates header from first child', () => {
|
||||
const group = {
|
||||
observer_id: 'old',
|
||||
_children: [
|
||||
{ observer_name: 'A', observer_id: 'new-id', timestamp: '2024-01-01T01:00:00Z', snr: 10, rssi: -50, path_json: '["x"]', direction: 'rx' },
|
||||
]
|
||||
};
|
||||
api.sortGroupChildren(group);
|
||||
assert.strictEqual(group.observer_id, 'new-id');
|
||||
assert.strictEqual(group.snr, 10);
|
||||
assert.strictEqual(group.rssi, -50);
|
||||
assert.strictEqual(group.path_json, '["x"]');
|
||||
assert.strictEqual(group.direction, 'rx');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: renderTimestampCell ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('renderTimestampCell produces HTML with timestamp-text', () => {
|
||||
const result = api.renderTimestampCell('2024-01-15T10:30:00Z');
|
||||
assert(result.includes('timestamp-text'));
|
||||
});
|
||||
|
||||
test('renderTimestampCell handles null gracefully', () => {
|
||||
const result = api.renderTimestampCell(null);
|
||||
// Should not throw, produces some output
|
||||
assert(typeof result === 'string');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: renderPath ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('renderPath returns dash for empty/null', () => {
|
||||
assert.strictEqual(api.renderPath(null, null), '—');
|
||||
assert.strictEqual(api.renderPath([], null), '—');
|
||||
});
|
||||
|
||||
test('renderPath renders hops with arrows', () => {
|
||||
const result = api.renderPath(['aa', 'bb'], null);
|
||||
assert(result.includes('arrow'));
|
||||
assert(result.includes('aa'));
|
||||
assert(result.includes('bb'));
|
||||
});
|
||||
|
||||
test('renderPath renders single hop without arrow', () => {
|
||||
const result = api.renderPath(['cc'], null);
|
||||
assert(result.includes('cc'));
|
||||
assert(!result.includes('arrow'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: renderDecodedPacket ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('renderDecodedPacket produces header section', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 4, payloadVersion: 1 },
|
||||
payload: { name: 'TestNode' },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aabbccdd';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('byop-decoded'));
|
||||
assert(result.includes('Header'));
|
||||
assert(result.includes('4 bytes'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders path hops', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 4 },
|
||||
payload: {},
|
||||
path: { hops: ['aa', 'bb'] }
|
||||
};
|
||||
const hex = 'aabbccdd';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('Path (2 hops)'));
|
||||
assert(result.includes('aa'));
|
||||
assert(result.includes('bb'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders payload fields', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 5 },
|
||||
payload: { channel: 'general', text: 'hello' },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aabb';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('channel'));
|
||||
assert(result.includes('general'));
|
||||
assert(result.includes('hello'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders nested objects as JSON', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 0 },
|
||||
payload: { flags: { repeater: true } },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aa';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('byop-pre'));
|
||||
assert(result.includes('repeater'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket skips null payload values', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 0 },
|
||||
payload: { a: null, b: undefined, c: 'visible' },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aa';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('visible'));
|
||||
// null/undefined values should be skipped
|
||||
const kvCount = (result.match(/byop-row/g) || []).length;
|
||||
// Only 'c' should appear in payload (a and b are null/undefined), plus header fields
|
||||
assert(kvCount >= 1);
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders raw hex', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 0 },
|
||||
payload: {},
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aabbcc';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('AA BB CC'));
|
||||
assert(result.includes('byop-hex'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: buildFieldTable ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('buildFieldTable produces table HTML', () => {
|
||||
const pkt = { raw_hex: 'c0400102', route_type: 1, payload_type: 4 };
|
||||
const decoded = { type: 'ADVERT', name: 'Node', pubKey: 'abc', flags: { type: 2, hasLocation: false, hasName: true, raw: 0x22 } };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('field-table'));
|
||||
assert(result.includes('Header'));
|
||||
assert(result.includes('Header Byte'));
|
||||
assert(result.includes('Path Length'));
|
||||
});
|
||||
|
||||
test('buildFieldTable handles transport codes (route_type 0)', () => {
|
||||
const pkt = { raw_hex: 'c0400102030405060708', route_type: 0, payload_type: 0 };
|
||||
const decoded = { destHash: 'aa', srcHash: 'bb', mac: 'cc', encryptedData: 'dd' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Transport Codes'));
|
||||
assert(result.includes('Next Hop'));
|
||||
assert(result.includes('Last Hop'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders path hops', () => {
|
||||
const pkt = { raw_hex: 'c042aabb', route_type: 1, payload_type: 0 };
|
||||
const decoded = { destHash: 'xx' };
|
||||
const result = api.buildFieldTable(pkt, decoded, ['aa', 'bb'], []);
|
||||
assert(result.includes('Path (2 hops)'));
|
||||
assert(result.includes('Hop 0'));
|
||||
assert(result.includes('Hop 1'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders ADVERT payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 4 };
|
||||
const decoded = {
|
||||
type: 'ADVERT', pubKey: 'abc123', timestamp: 1234567890,
|
||||
timestampISO: '2009-02-13T23:31:30Z', signature: 'sig',
|
||||
name: 'TestNode',
|
||||
flags: { type: 1, hasLocation: true, hasName: true, raw: 0x55 }
|
||||
};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Public Key'));
|
||||
assert(result.includes('Timestamp'));
|
||||
assert(result.includes('Signature'));
|
||||
assert(result.includes('App Flags'));
|
||||
assert(result.includes('Companion'));
|
||||
assert(result.includes('Latitude'));
|
||||
assert(result.includes('Node Name'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders GRP_TXT payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 5 };
|
||||
const decoded = { type: 'GRP_TXT', channelHash: 0xAB, mac: 'AABB', encryptedData: 'data', decryptionStatus: 'no_key' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Channel Hash'));
|
||||
assert(result.includes('MAC'));
|
||||
assert(result.includes('Encrypted Data'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders CHAN payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 5 };
|
||||
const decoded = { type: 'CHAN', channel: 'general', sender: 'Alice', sender_timestamp: '12:00' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Channel'));
|
||||
assert(result.includes('general'));
|
||||
assert(result.includes('Sender'));
|
||||
assert(result.includes('Sender Time'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders ACK payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 3 };
|
||||
const decoded = { type: 'ACK', ackChecksum: 'DEADBEEF' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Checksum'));
|
||||
assert(result.includes('DEADBEEF'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders destHash-based payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 2 };
|
||||
const decoded = { destHash: 'DD', srcHash: 'SS', mac: 'MM', encryptedData: 'EE' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Dest Hash'));
|
||||
assert(result.includes('Src Hash'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders raw fallback for unknown payload', () => {
|
||||
const pkt = { raw_hex: 'c040aabbccdd', route_type: 1, payload_type: 99 };
|
||||
const decoded = {};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Raw'));
|
||||
});
|
||||
|
||||
test('buildFieldTable hash_size calculation', () => {
|
||||
// Path byte 0xC0 → bits 7-6 = 3 → hash_size = 4
|
||||
const pkt = { raw_hex: '00C0', route_type: 1, payload_type: 0 };
|
||||
const decoded = {};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('hash_size=4'));
|
||||
});
|
||||
|
||||
test('buildFieldTable handles empty raw_hex', () => {
|
||||
const pkt = { raw_hex: '', route_type: 1, payload_type: 0 };
|
||||
const decoded = {};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('field-table'));
|
||||
assert(result.includes('0B') || result.includes('0 bytes') || result.includes('??'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: _getRowCount ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('_getRowCount returns 1 for ungrouped', () => {
|
||||
// _displayGrouped is internal, but when not grouped, should return 1
|
||||
// Since we can't easily control _displayGrouped, test the function behavior
|
||||
const result = api._getRowCount({ hash: 'abc', _children: [{ observer_id: '1' }] });
|
||||
// Default _displayGrouped depends on initialization, but the function should not throw
|
||||
assert(typeof result === 'number');
|
||||
assert(result >= 1);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: buildFlatRowHtml ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('buildFlatRowHtml produces table row', () => {
|
||||
const p = {
|
||||
id: 1, hash: 'abc123', timestamp: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aabb', payload_type: 4,
|
||||
route_type: 1, decoded_json: '{}', path_json: '[]'
|
||||
};
|
||||
const result = api.buildFlatRowHtml(p);
|
||||
assert(result.includes('<tr'));
|
||||
assert(result.includes('data-id="1"'));
|
||||
assert(result.includes('data-hash="abc123"'));
|
||||
});
|
||||
|
||||
test('buildFlatRowHtml calculates size from hex', () => {
|
||||
const p = {
|
||||
id: 2, hash: 'x', timestamp: '', observer_id: null,
|
||||
raw_hex: 'aabbccdd', payload_type: 0, route_type: 0,
|
||||
decoded_json: '{}', path_json: '[]'
|
||||
};
|
||||
const result = api.buildFlatRowHtml(p);
|
||||
assert(result.includes('4B')); // 8 hex chars = 4 bytes
|
||||
});
|
||||
|
||||
test('buildFlatRowHtml handles missing raw_hex', () => {
|
||||
const p = {
|
||||
id: 3, hash: 'y', timestamp: '', observer_id: null,
|
||||
raw_hex: null, payload_type: 0, route_type: 0,
|
||||
decoded_json: '{}', path_json: '[]'
|
||||
};
|
||||
const result = api.buildFlatRowHtml(p);
|
||||
assert(result.includes('0B'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: buildGroupRowHtml ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('buildGroupRowHtml renders single-count group', () => {
|
||||
const p = {
|
||||
hash: 'abc', count: 1, latest: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aabb', payload_type: 4,
|
||||
route_type: 1, decoded_json: '{}', path_json: '[]',
|
||||
observation_count: 1, observer_count: 1
|
||||
};
|
||||
const result = api.buildGroupRowHtml(p);
|
||||
assert(result.includes('<tr'));
|
||||
assert(result.includes('data-hash="abc"'));
|
||||
// Single count: no expand arrow, no group-header class
|
||||
assert(!result.includes('group-header'));
|
||||
});
|
||||
|
||||
test('buildGroupRowHtml renders multi-count group with expand arrow', () => {
|
||||
const p = {
|
||||
hash: 'xyz', count: 3, latest: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aabbcc', payload_type: 0,
|
||||
route_type: 0, decoded_json: '{}', path_json: '[]',
|
||||
observation_count: 3, observer_count: 2
|
||||
};
|
||||
const result = api.buildGroupRowHtml(p);
|
||||
assert(result.includes('group-header'));
|
||||
assert(result.includes('▶')); // collapsed arrow
|
||||
});
|
||||
|
||||
test('buildGroupRowHtml shows observation count badge', () => {
|
||||
const p = {
|
||||
hash: 'obs', count: 1, latest: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aa', payload_type: 0,
|
||||
route_type: 0, decoded_json: '{}', path_json: '[]',
|
||||
observation_count: 5, observer_count: 1
|
||||
};
|
||||
const result = api.buildGroupRowHtml(p);
|
||||
assert(result.includes('badge-obs'));
|
||||
assert(result.includes('👁'));
|
||||
assert(result.includes('5'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: page registration ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
// registerPage is defined in app.js and stores in its own `pages` closure.
|
||||
// We verify via the navigateTo mechanism or by checking the pages object isn't empty.
|
||||
// Since we can't easily access the closure, just verify the test API is exposed.
|
||||
test('_packetsTestAPI is exposed on window', () => {
|
||||
assert(ctx._packetsTestAPI);
|
||||
assert(typeof ctx._packetsTestAPI.typeName === 'function');
|
||||
assert(typeof ctx._packetsTestAPI.getDetailPreview === 'function');
|
||||
assert(typeof ctx._packetsTestAPI.sortGroupChildren === 'function');
|
||||
assert(typeof ctx._packetsTestAPI.buildFieldTable === 'function');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
console.log(`\n${'='.repeat(40)}`);
|
||||
console.log(`packets.js tests: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user