Compare commits

...

23 Commits

Author SHA1 Message Date
Kpa-clawbot 096887228f release: v3.6.0 'Forensics' notes 2026-05-01 09:24:42 +00:00
Kpa-clawbot 4c39f041ba ci: update go-server-coverage.json [skip ci] 2026-05-01 09:10:25 +00:00
Kpa-clawbot 1c755ed525 ci: update go-ingestor-coverage.json [skip ci] 2026-05-01 09:10:24 +00:00
Kpa-clawbot c78606a416 ci: update frontend-tests.json [skip ci] 2026-05-01 09:10:24 +00:00
Kpa-clawbot 718d2e201a ci: update frontend-coverage.json [skip ci] 2026-05-01 09:10:23 +00:00
Kpa-clawbot d3d41f3bf2 ci: update e2e-tests.json [skip ci] 2026-05-01 09:10:22 +00:00
Kpa-clawbot 7bb5ff9a7f fix(e2e): tag flying-packet polyline so test selector doesn't pick up geofilter polygons (#953)
## Bug

Master CI failing on `Map trace polyline uses hash-derived color when
toggle ON`. The test selector `path.leaflet-interactive` was too broad —
it matched **geofilter region polygons** (`L.polygon` calls in
`live.js:1052`/`map.js:327`), which are styled with theme variables, not
`hsl()`. None of those polygons have an `hsl(` stroke, so the assertion
failed even though the actual flying-packet polylines DO use hash colors
correctly.

## Fix

1. Tag flying-packet polylines with a dedicated class
`live-packet-trace` (`public/live.js:2728`).
2. Update the test selector to target that class specifically.
3. Treat "no flying-packet polylines drawn in the test window" as SKIP
(not fail) — animation may not trigger in 3s.

## Verification (rule 18)

- Read implementation at `live.js:2724-2729`: polyline color IS set from
`hashFill` when toggle is ON. The implementation is correct.
- Read polygon callers at `live.js:1052` (geofilter regions) — confirmed
they share the same `path.leaflet-interactive` class.
- The test was selecting wrong DOM nodes; fix narrows to dedicated
class.

No code logic changed — only DOM tagging + test selector.

Co-authored-by: Kpa-clawbot <bot@example.invalid>
2026-05-01 09:00:49 +00:00
Kpa-clawbot b9758111b0 feat(hash-color): bright vivid fill + dark outline + live feed/polyline surfaces (#951)
## Hash-Color: Bright Vivid Fill + Dark Outline + Extended Surfaces

Follow-up to #948 (merged). Revises the hash-color algorithm for better
perceptual discrimination and extends hash coloring to additional Live
page surfaces.

### Algorithm Changes (`public/hash-color.js`)
- **Hue**: bytes 0-1 (16-bit → 0-360°) — unchanged
- **Saturation**: byte 2 (55-95%) — NEW, was fixed 70%
- **Lightness**: byte 3 (light 50-65%, dark 55-72%) — NEW, was fixed
L=30/38/65
- **Outline** (`hashToOutline`): same-hue dark color (L=25% light, L=15%
dark) — NEW
- Sentinel threshold raised to 8 hex chars (need 4 bytes of entropy)
- Drops WCAG fill-darkening approach — outline carries contrast instead

### Live Page Updates (`public/live.js`)
- **Dot marker**: uses `hashToOutline()` for stroke (was TYPE_COLOR)
- **Polyline trace**: uses hash fill color (unified dot + trace by hash)
- **Feed items**: 4px `border-left` stripe matching packets table

### Test Updates
- `test-hash-color.js`: 32 tests (S variability, L variability, outline
< fill, same hue, pairwise distance)
- `test-e2e-playwright.js`: 2 new assertions (feed stripe, polyline hsl
stroke)

### Verification
- 20 real advert hashes from fixture DB: all produce unique hues (20/20)
- Pairwise HSL distance: avg=0.51, min=0.04
- Go server built and run against fixture DB — HTML serves updated
module
- VM sandbox render-check confirms distinct vivid fills with darker
outlines

Closes #946 §2.10/§2.11 scope extension.

---------

Co-authored-by: you <you@example.com>
Co-authored-by: Kpa-clawbot <bot@example.invalid>
2026-05-01 08:53:04 +00:00
Kpa-clawbot 3bd354338e ci: update go-server-coverage.json [skip ci] 2026-05-01 08:20:25 +00:00
Kpa-clawbot 81ae2689f0 ci: update go-ingestor-coverage.json [skip ci] 2026-05-01 08:20:25 +00:00
Kpa-clawbot f428064efe ci: update frontend-tests.json [skip ci] 2026-05-01 08:20:24 +00:00
Kpa-clawbot c024a55328 ci: update frontend-coverage.json [skip ci] 2026-05-01 08:20:23 +00:00
Kpa-clawbot 7034fe74b5 ci: update e2e-tests.json [skip ci] 2026-05-01 08:20:22 +00:00
Kpa-clawbot 0a9a4c4223 feat(live + packets): color packet markers by hash (#946) (#948)
## Summary

Implements #946 — deterministic HSL coloring of packet markers by hash
for visual propagation tracing.

### What's new

1. **`public/hash-color.js`** — Pure IIFE
(`window.HashColor.hashToHsl(hashHex, theme)`) deriving hue from first 2
bytes of packet hash. Theme-aware lightness with WCAG ≥3.0 contrast
against `--content-bg` (`#f4f5f7` light / `#0f0f23` dark,
`style.css:32,55`). Green/yellow zone (hue 45°-195°) uses L=30% in light
theme to maintain contrast.

2. **Live page dots + contrails** — `drawAnimatedLine` fills the flying
dot and tints the contrail polyline with the hash-derived HSL when
toggle is ON. Ghost-hop dots remain grey (`#94a3b8`). Matrix mode path
(`drawMatrixLine`) is untouched.

3. **Packets table stripe** — `border-left: 4px solid <hsl>` on `<tr>`
in both `buildGroupRowHtml` (group + child rows) and `buildFlatRowHtml`.
Absent when toggle OFF.

4. **Toggle UI** — "Color by hash" checkbox in `#liveControls` between
Realistic and Favorites. Default ON. Persisted to
`localStorage('meshcore-color-packets-by-hash')`. Dispatches `storage`
event for cross-tab sync. Packets page listens and re-renders.

### Performance

- `hashToHsl` is O(1) — two `parseInt` calls + arithmetic. No allocation
beyond the result string.
- Called once per `drawAnimatedLine` invocation (not per animation
frame).
- Packets table: called once per visible row during render (existing
virtualization applies).

### Tests

- `test-hash-color.js`: 16 unit tests — purity, theme split, yellow-zone
clamp, sentinel, variability (anti-tautology gate), WCAG sweep (step 15°
both themes).
- `test-packets.js`: 82 tests still passing (no regression).
- `test-e2e-playwright.js`: 4 new E2E tests — toggle presence/default,
persistence across reload, table stripe present when ON, absent when
OFF.

### Acceptance criteria addressed

All items from spec §6 implemented. TYPE_COLORS retained on
borders/lines. Ghost hops stay grey. Matrix mode suppressed. Cross-tab
storage event dispatched.

Closes #946

---------

Co-authored-by: you <you@example.com>
Co-authored-by: Kpa-clawbot <bot@example.invalid>
2026-05-01 01:10:11 -07:00
Kpa-clawbot 994544604f fix(path-inspector): Show on Map missed origin and stripped first hop (#950)
## Bug

Path Inspector "Show on Map" only rendered the first node of a candidate
path. Multi-hop candidates appeared as a single dot instead of a
polyline.

## Root cause

`public/path-inspector.js:186-188` and `public/map.js:574` (cross-page
handler) called:

```js
drawPacketRoute(candidate.path.slice(1), candidate.path[0]);
```

But `drawPacketRoute(hopKeys, origin)` (`public/map.js:390`) expects
`origin` to be an **object** with `pubkey`/`lat`/`lon`/`name` properties
— not a bare string. The code at lines 451-460 does `origin.lat` /
`origin.pubkey` lookups; with a string, both branches fail, `originPos`
stays null, and the originating node never gets prepended to
`positions`.

Combined with `slice(1)` stripping the head, the resulting polyline was
missing the first hop AND the origin marker — and short paths could
collapse to a single resolved node.

## Fix

Pass the full path as `hopKeys` and `null` as origin. `drawPacketRoute`
already iterates `hopKeys`, resolves each against `nodes[]`, and draws a
marker for every resolved hop. The "origin" arg was meant for cases
where the originator is a separate object (e.g., from packet detail with
sender metadata), not for paths where the origin IS the first hop.

```js
drawPacketRoute(candidate.path, null);
```

Two call sites fixed: in-page direct call (`path-inspector.js:188`) and
cross-page handler (`map.js:574`).

## Verification

**Code reading only.** I did NOT manually load the page or visually
verify the polyline renders. Reviewer should:
1. Open Path Inspector, query a multi-prefix path with ≥3 known hops
2. Click "Show on Map"
3. Confirm polyline draws through every resolved node, not just the
first

`drawPacketRoute` is hard to unit-test without a real Leaflet map, so no
automated test added.

---------

Co-authored-by: Kpa-clawbot <bot@example.invalid>
Co-authored-by: you <you@example.com>
2026-05-01 08:01:37 +00:00
Kpa-clawbot 405094f7eb ci: update go-server-coverage.json [skip ci] 2026-05-01 07:24:51 +00:00
Kpa-clawbot 89b63dc38a ci: update go-ingestor-coverage.json [skip ci] 2026-05-01 07:24:50 +00:00
Kpa-clawbot 8194801b94 ci: update frontend-tests.json [skip ci] 2026-05-01 07:24:49 +00:00
Kpa-clawbot 4427c92c32 ci: update frontend-coverage.json [skip ci] 2026-05-01 07:24:48 +00:00
Kpa-clawbot c5799f868e ci: update e2e-tests.json [skip ci] 2026-05-01 07:24:47 +00:00
Kpa-clawbot 6345c6fb05 fix(ingestor): observability + bounded backoff for MQTT reconnect (#947) (#949)
## Summary

Fixes #947 — MQTT ingestor silently stalls after `pingresp not received`
disconnect due to paho's default 10-minute reconnect backoff and zero
observability of reconnect attempts.

## Changes

### `cmd/ingestor/main.go`
- **Extract `buildMQTTOpts()`** — encapsulates MQTT client option
construction for testability
- **`SetMaxReconnectInterval(30s)`** — bounds paho's default 10-minute
exponential backoff (source: `options.go:137` in
`paho.mqtt.golang@v1.5.0`)
- **`SetConnectTimeout(10s)`** — prevents stuck connect attempts from
blocking reconnect cycle
- **`SetWriteTimeout(10s)`** — prevents stuck publish writes
- **`SetReconnectingHandler`** — logs `MQTT [<tag>] reconnecting to
<broker>` on every reconnect attempt, giving operators visibility into
retry behavior
- **Enhanced `SetConnectionLostHandler`** — now includes broker address
in log line for multi-source disambiguation

### `cmd/ingestor/mqtt_opts_test.go` (new)
- Tests verify `MaxReconnectInterval`, `ConnectTimeout`, `WriteTimeout`
are set correctly
- Tests verify credential and TLS configuration
- Anti-tautology: tests fail if timing settings are removed from
`buildMQTTOpts()`

## Operator impact

After this change, a pingresp disconnect produces:
```
MQTT [staging] disconnected from tcp://broker:1883: pingresp not received, disconnecting
MQTT [staging] reconnecting to tcp://broker:1883
MQTT [staging] reconnecting to tcp://broker:1883
MQTT [staging] connected to tcp://broker:1883
MQTT [staging] subscribed to meshcore/#
```

Max gap between disconnect and first reconnect attempt: ~30s (was up to
10 minutes).

---------

Co-authored-by: you <you@example.com>
2026-05-01 00:01:07 -07:00
efiten f99c9c21d9 feat(live): node filter — show only traffic through a specific node (closes #771 M3) (#924)
Adds a node filter input to the live controls bar. When active, only
packets whose hop chain passes through the selected node(s) are
animated. A counter shows "Showing X of Y" so operators know traffic is
filtered, not absent.

## Changes
- `packetInvolvesFilterNode(pkt, filterKeys)`: pure filter function
using same prefix-matching logic as the favorites filter
- `setNodeFilter(keys)`: sets filter state, resets counters, persists to
localStorage
- `updateNodeFilterUI()`: updates counter + clear button visibility +
datalist autocomplete
- Filter input in controls bar (after Favorites toggle): text input +
datalist autocomplete + × clear button + counter div
- Filter wired into `renderPacketTree`: increments total/shown counters,
returns early when packet doesn't match
- URL hash sync: `?node=ABCD1234` — read on init, written on filter
change

## Tests
8 new unit tests covering filter logic, localStorage persistence, and
edge cases; 78 pass, 0 regressions.

Closes #771 (M3 of 3)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 23:53:08 -07:00
efiten 69080a852f feat(geofilter-docs): app-served docs page (#820) (#900)
## Summary

- Adds `public/geofilter-docs.html` — a self-contained, app-served
documentation page for the geofilter feature, matching the builder's
dark theme
- Updates the GeoFilter Builder's help-bar "Documentation" link from
GitHub markdown URL to the local `/geofilter-docs.html`

## Docs coverage

Polygon syntax, coordinate ordering (`[lat, lon]` — not GeoJSON `[lon,
lat]`), multi-polygon clarification (single polygon only), examples
(Belgium rectangle + irregular shape), legacy bounding box format, prune
script usage.

## Test plan

- [x] Open `/geofilter-docs.html` — dark theme renders, all sections
visible
- [x] Open `/geofilter-builder.html` → click "Documentation" → navigates
to `/geofilter-docs.html` in same tab
- [x] Click "← GeoFilter Builder" on docs page → navigates back to
`/geofilter-builder.html`

Closes #820

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 23:50:54 -07:00
17 changed files with 1214 additions and 38 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"e2e tests","message":"83 passed","color":"brightgreen"}
{"schemaVersion":1,"label":"e2e tests","message":"89 passed","color":"brightgreen"}
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"frontend coverage","message":"37.74%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"36.12%","color":"red"}
+207
View File
@@ -0,0 +1,207 @@
# v3.6.0 - The Forensics
CoreScope just got eyes everywhere. This release drops **path inspection**, **color-by-hash markers**, **clock skew detection**, **full channel encryption**, an **observer graph**, and a pile of robustness fixes that make your mesh network feel like it's being watched by someone who actually cares.
134 commits, 105 PRs merged, 18K+ lines added. Here's what shipped.
---
## 🚀 New Features
### Path-Prefix Candidate Inspector (#944, #945)
The marquee feature. Click any path segment and CoreScope opens an interactive inspector showing every candidate node that could match that hop prefix - plotted on a map with scoring by neighbor-graph affinity and geographic centroid. Ambiguous hops? Now you can see *why* they're ambiguous and pick the right one.
**Why you'll love it:** No more guessing which `0xA3` is the real repeater. The inspector lays out every candidate, scores them, and lets you drill in visually.
### Color-by-Hash Packet Markers (#948, #951)
Every packet type gets a vivid, hash-derived color - on the live feed, map polylines, and flying-packet animations. Bright fill with dark outline for contrast. No more monochrome blobs - you can visually track packet flows by color at a glance.
### Node Filter on Live Page (#924, #771)
Filter the live packet stream to show only traffic flowing through a specific node. Pick a repeater, see exactly what it's carrying. That simple.
### Clock Skew Detection (#746, #752, #828, #850)
Full pipeline: backend computes drift using Theil-Sen regression with outlier rejection (#828), the UI shows per-node badges, detail sparklines, and fleet-wide analytics (#752). Bimodal clock severity (#850) surfaces flaky-RTC nodes that toggle between accurate and drifted - instead of hiding them as "No Clock."
**Why you'll love it:** Nodes with bad clocks silently corrupt your timeline. Now they glow red before they ruin your analysis.
### Observer Graph (M1+M2) (#774)
Observers are now first-class graph citizens. CoreScope builds a neighbor graph from observation overlaps, scores hop-resolver candidates by graph edges (#876), and uses geographic centroid for tiebreaking. The observer topology is visible and queryable.
### Channel Encryption - Full Stack (#726, #733, #750, #760)
Three milestones landed as one: DB-backed channel message history (#726), client-side PSK decryption in the browser (#733), and PSK channel management with add/remove UX and message caching (#750). Add a channel key in the UI, and CoreScope decrypts messages client-side - no server-side key storage. The add-channel button (#760) makes it dead simple.
**Why you'll love it:** Encrypted channels are no longer black boxes. Add your PSK, see the messages, search history - all without exposing keys to the server.
### Hash Collision Inspector (#758)
The Hash Usage Matrix now shows collision details for all hash sizes. When two nodes share a prefix, you see exactly who collides and at what size.
### Geofilter Builder - In-App (#735, #900)
The geofilter polygon builder is now served directly from CoreScope with a full docs page (#900). No more hunting for external tools. Link from the customizer, draw your polygon, done.
### Node Blacklist (#742)
`nodeBlacklist` in config hides abusive or troll nodes from all views. They're gone.
### Observer Retention (#764)
Stale observers are automatically pruned after a configurable number of days. Your observer list stays clean without manual intervention.
### Advert Signature Validation (#794)
Corrupt packets with invalid advert signatures are now rejected at ingest. Bad data never hits your store.
### Bounded Cold Load (#790)
`Load()` now respects a memory budget - no more OOM on cold start with a fat database. Combined with retention-hours cutoff (#917), cold start is safe on constrained hardware.
### Multi-Arch Docker Images (#869)
Official images now publish `amd64` + `arm64` in a single multi-arch manifest. Raspberry Pi operators: pull and run. No special tags needed.
### /nodes Detail Panel + Search (#868)
The nodes detail panel ships with search improvements (#862) - find nodes fast, see their full detail in a slide-out panel.
### Deduplicated Top Longest Hops (#848)
Longest hops are now deduplicated by pair with observation count and SNR cues. No more seeing the same link 47 times.
---
## 🔥 Performance Wins
### StoreTx ResolvedPath Elimination (#806)
The per-transaction `ResolvedPath` computation is gone - replaced by a membership index with on-demand decode. This was one of the hottest paths in the ingestor.
### Node Packet Queries (#803)
Raw JSON text search for node packets replaced with a proper `byNode` index (#673). Night and day.
### Channel Query Performance (#762, #763)
New `channel_hash` column enables SQL-level channel filtering. No more full-table scan to find messages in a channel.
### SQLite Auto-Vacuum (#919, #920)
Incremental auto-vacuum enabled - the database file actually shrinks after retention pruning. No more 2GB database holding 200MB of live data.
### Retention-Hours Cutoff on Load (#917)
`Load()` now applies `retentionHours` at read time, preventing OOM when the DB has more history than memory allows.
---
## 🛡️ Security & Robustness
### MQTT Reconnect with Bounded Backoff (#947, #949)
The ingestor now reconnects to MQTT brokers with exponential backoff, observability logging, and bounded retry. No more silent disconnects that kill your data stream.
---
## 🐛 Bugs Squashed
This release exterminates **40+ bugs** — from protocol-level hash mismatches to pixel-level CSS breakage. Operators told us what hurt; we listened.
- **Path inspector "Show on Map" missed origin and first hop** (#950) - map view now includes all hops
- **Content hash used full header byte** (#787) - content hashing now uses payload type bits only, fixing hash collisions between packets that differ only in header flags
- **Encrypted channel deep links showed broken UI** (#825, #826, #815) - deep links to encrypted channels now show a lock message instead of broken UI when you don't have the key
- **Geofilter longitude wrapping** (#925) - geofilter builder wraps longitude to [-180, 180]; southern hemisphere polygons no longer invert
- **Hash filter bypasses saved region filter** (#939) - hash lookups now skip the geo filter as intended
- **Companion-as-repeater excluded from path hops** (#935, #936) - non-repeater nodes no longer pollute hop resolution
- **Customize panel re-renders while typing** (#927) - text fields keep focus during config changes
- **Per-observation raw_hex** (#881, #882) - each observer's hex dump now shows what *that observer* actually received
- **Per-observation children in packet groups** (#866, #880) - expanded groups show per-obs data, not cross-observer aggregates
- **Full-page obs-switch** (#866, #870) - switching observers updates hex, path, and direction correctly
- **Packet detail shows wrong observation** (#849, #851) - clicking a specific observation opens *that* observation
- **Byte breakdown hop count** (#844, #846) - derived from `path_len`, not aggregated `_parsedPath`
- **Transport-route path_len offset** (#852, #853) - correct offset calculation + CSS variable fix
- **Packets/hour chart bars + x-axis** (#858, #865) - bars render correctly, x-axis labels properly decimated
- **Channel timeline capped to top 8** (#860, #864) - no more 47-channel chart spaghetti
- **Reachability row opacity removed** (#859, #863) - clean rows without misleading gradient
- **Sticky table headers on mobile** (#861, #867) - restored after regression
- **Map popup 'Show Neighbors' on iOS Safari** (#840, #841) - link actually works now
- **Node detail Recent Packets invisible text** (#829, #830) - CSS fix
- **/api/packets/{hash} falls back to DB** (#827, #831) - when in-memory store misses, DB catches it
- **IATA filter bypass for status messages** (#694, #802) - status packets no longer filtered out by airport codes
- **Desktop node click URL hash** (#676, #739) - clicking a node updates the URL for deep linking
- **Filter params in URL hash** (#682, #740) - all filter state serialized for shareable links
- **Hide undecryptable channel messages** (#727, #728) - clean default view
- **TRACE path_json uses path_sz** (#732) - correct field from flags byte, not header hash_size
- **Multi-byte adopters** (#754, #767) - all node types, role column, advert precedence
- **Channel key case sensitivity** (#761) - Public decode works correctly
- **Transport route field offsets** (#766) - correct offsets in field table
- **Clock skew sanity checks** (#769) - filter epoch-0, cap drift, require minimum samples
- **Neighbor graph slider persistence** (#776) - default 0.7, persisted to localStorage
- **Node detail panel navigation** (#779, #785) - Details/Analytics links actually navigate
- **Channel key removal** (#898) - user-added keys for server-known channels can be removed
- **Side-panel Details on desktop** (#892) - opens full-screen correctly
- **Hex-dump byte ranges client-side** (#891) - computed from per-obs raw_hex
- **path_json derived from raw_hex at ingest** (#886, #887) - single source of truth
- **Path pill and byte breakdown hop agreement** (#885) - they match now
- **Mobile close button + toolbar scroll** (#797, #805) - accessible and scrollable
- **/health.recentPackets resolved_path fallback** (#810, #821) - falls back to longest sibling observation
- **Channel filter on Packets page** (#812, #816) - UI and API both fixed
- **Clock-skew section in side panel** (#813, #814) - renders correctly
- **Real RSS in /api/stats** (#832, #835) - surface actual RSS alongside tracked store bytes
- **Hash size detection for transport routes + zero-hop adverts** (#747) - correct detection
- **Repeater+observer merged map marker** (#745) - single marker, not two overlapping
---
## 🎨 UI Polish
- QA findings applied across the board (#832, #833, #836, #837, #838) - dozens of small UX fixes from systematic QA pass
---
## 📦 Upgrading
```bash
git pull
docker compose down
docker compose build prod
docker compose up -d prod
```
Your existing `config.json` works as-is. New optional config keys:
- `nodeBlacklist` - array of node hashes to hide
- `observerRetentionDays` - days before stale observers are pruned
- `memoryBudgetMB` - cap on in-memory packet store
### Verify
```bash
curl -s http://localhost/api/health | jq .version
# "3.6.0"
```
---
## 🙏 External Contributors
- **#735** ([@efiten](https://github.com/efiten)) - Serve geofilter builder from app, link from customizer
- **#739** ([@efiten](https://github.com/efiten)) - Desktop node click updates URL hash for deep linking
- **#740** ([@efiten](https://github.com/efiten)) - Serialize filter params in URL hash for shareable links
- **#742** ([@Joel-Claw](https://github.com/Joel-Claw)) - Add nodeBlacklist config to hide abusive/troll nodes
- **#761** ([@copelaje](https://github.com/copelaje)) - Fix channel key case sensitivity for Public decode
- **#764** ([@Joel-Claw](https://github.com/Joel-Claw)) - Add observer retention - prune stale observers after configurable days
- **#802** ([@efiten](https://github.com/efiten)) - Bypass IATA filter for status messages, fill SNR on duplicate observations
- **#803** ([@efiten](https://github.com/efiten)) - Replace raw JSON text search with byNode index for node packet queries
- **#805** ([@efiten](https://github.com/efiten)) - Mobile close button accessible + toolbar scrollable
- **#900** ([@efiten](https://github.com/efiten)) - App-served geofilter docs page
- **#917** ([@efiten](https://github.com/efiten)) - Apply retentionHours cutoff in Load() to prevent OOM on cold start
- **#924** ([@efiten](https://github.com/efiten)) - Node filter on live page - show only traffic through a specific node
- **#925** ([@efiten](https://github.com/efiten)) - Fix geobuilder longitude wrapping for southern hemisphere polygons
- **#927** ([@efiten](https://github.com/efiten)) - Skip customize panel re-render while text field has focus
---
## ⚠️ Breaking Changes
**None.** All API endpoints remain backwards-compatible. New fields are additive only.
---
## 📊 By the Numbers
| Stat | Count |
|------|-------|
| Commits | 134 |
| PRs merged | 105 |
| Lines added | 18,480 |
| Lines removed | 1,632 |
| Files changed | 110 |
| Contributors | 4 |
---
*Previous release: [v3.5.2](https://github.com/Kpa-clawbot/CoreScope/releases/tag/v3.5.2)*
+32 -18
View File
@@ -129,23 +129,7 @@ func main() {
tag = source.Broker
}
opts := mqtt.NewClientOptions().
AddBroker(source.Broker).
SetAutoReconnect(true).
SetConnectRetry(true).
SetOrderMatters(true)
if source.Username != "" {
opts.SetUsername(source.Username)
}
if source.Password != "" {
opts.SetPassword(source.Password)
}
if source.RejectUnauthorized != nil && !*source.RejectUnauthorized {
opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
} else if strings.HasPrefix(source.Broker, "ssl://") {
opts.SetTLSConfig(&tls.Config{})
}
opts := buildMQTTOpts(source)
opts.SetOnConnectHandler(func(c mqtt.Client) {
log.Printf("MQTT [%s] connected to %s", tag, source.Broker)
@@ -165,7 +149,11 @@ func main() {
})
opts.SetConnectionLostHandler(func(c mqtt.Client, err error) {
log.Printf("MQTT [%s] disconnected: %v", tag, err)
log.Printf("MQTT [%s] disconnected from %s: %v", tag, source.Broker, err)
})
opts.SetReconnectingHandler(func(c mqtt.Client, options *mqtt.ClientOptions) {
log.Printf("MQTT [%s] reconnecting to %s", tag, source.Broker)
})
// Capture source for closure
@@ -206,6 +194,32 @@ func main() {
log.Println("Done.")
}
// buildMQTTOpts creates MQTT client options for a source with bounded reconnect
// backoff, connect timeout, and TLS/auth configuration.
func buildMQTTOpts(source MQTTSource) *mqtt.ClientOptions {
opts := mqtt.NewClientOptions().
AddBroker(source.Broker).
SetAutoReconnect(true).
SetConnectRetry(true).
SetOrderMatters(true).
SetMaxReconnectInterval(30 * time.Second).
SetConnectTimeout(10 * time.Second).
SetWriteTimeout(10 * time.Second)
if source.Username != "" {
opts.SetUsername(source.Username)
}
if source.Password != "" {
opts.SetPassword(source.Password)
}
if source.RejectUnauthorized != nil && !*source.RejectUnauthorized {
opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
} else if strings.HasPrefix(source.Broker, "ssl://") {
opts.SetTLSConfig(&tls.Config{})
}
return opts
}
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string, cfg *Config) {
defer func() {
if r := recover(); r != nil {
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"testing"
"time"
)
func TestBuildMQTTOpts_ReconnectSettings(t *testing.T) {
source := MQTTSource{
Broker: "tcp://localhost:1883",
Name: "test",
}
opts := buildMQTTOpts(source)
if opts.MaxReconnectInterval != 30*time.Second {
t.Errorf("MaxReconnectInterval = %v, want 30s", opts.MaxReconnectInterval)
}
if opts.ConnectTimeout != 10*time.Second {
t.Errorf("ConnectTimeout = %v, want 10s", opts.ConnectTimeout)
}
if opts.WriteTimeout != 10*time.Second {
t.Errorf("WriteTimeout = %v, want 10s", opts.WriteTimeout)
}
if !opts.AutoReconnect {
t.Error("AutoReconnect should be true")
}
if !opts.ConnectRetry {
t.Error("ConnectRetry should be true")
}
}
func TestBuildMQTTOpts_Credentials(t *testing.T) {
source := MQTTSource{
Broker: "tcp://broker:1883",
Username: "user1",
Password: "pass1",
}
opts := buildMQTTOpts(source)
if opts.Username != "user1" {
t.Errorf("Username = %q, want %q", opts.Username, "user1")
}
if opts.Password != "pass1" {
t.Errorf("Password = %q, want %q", opts.Password, "pass1")
}
}
func TestBuildMQTTOpts_TLS_InsecureSkipVerify(t *testing.T) {
f := false
source := MQTTSource{
Broker: "ssl://broker:8883",
RejectUnauthorized: &f,
}
opts := buildMQTTOpts(source)
if opts.TLSConfig == nil {
t.Fatal("TLSConfig should be set")
}
if !opts.TLSConfig.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be true when RejectUnauthorized=false")
}
}
func TestBuildMQTTOpts_TLS_SSL_Prefix(t *testing.T) {
source := MQTTSource{
Broker: "ssl://broker:8883",
}
opts := buildMQTTOpts(source)
if opts.TLSConfig == nil {
t.Fatal("TLSConfig should be set for ssl:// brokers")
}
if opts.TLSConfig.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be false by default")
}
}
@@ -0,0 +1,204 @@
# Scope Stats Page — Design Spec
**Issue**: Kpa-clawbot/CoreScope#899
**Date**: 2026-04-23
**Branch target**: `master`
---
## Overview
Add a dedicated **Scopes** page showing scope/region statistics for MeshCore transport-route packets. Scope filtering in MeshCore uses `TRANSPORT_FLOOD` (route_type 0) and `TRANSPORT_DIRECT` (route_type 3) packets that carry two 16-bit transport codes. Code1 ≠ `0000` means the packet is region-scoped.
Feature 3 from the issue (default scope per client via advert) is **not implemented** — the advert format has no scope field in the current firmware.
---
## How Scopes Work (Firmware)
Transport code derivation (authoritative source: `meshcore-dev/MeshCore`):
```
key = SHA256("#regionname")[:16] // TransportKeyStore::getAutoKeyFor
Code1 = HMAC-SHA256(key, type || payload) // TransportKey::calcTransportCode, 2-byte output
```
Code1 is a **per-message** HMAC — the same region produces a different Code1 for every message. Identifying a region from Code1 requires knowing the region name in advance and recomputing the HMAC.
`Code1 = 0000` is the "no scope" sentinel (also `FFFF` is reserved). Packets with route_type 1 or 2 (plain FLOOD/DIRECT) carry no transport codes.
---
## Config
Add `hashRegions` to the ingestor `Config` struct in `cmd/ingestor/config.go`, mirroring `hashChannels`:
```json
"hashRegions": ["#belgium", "#eu", "#brussels"]
```
Normalization (same rules as `hashChannels`):
- Trim whitespace
- Prepend `#` if missing
- Skip empty entries
---
## Ingestor Changes
### Key derivation (`loadRegionKeys`)
```go
func loadRegionKeys(cfg *Config) map[string][]byte {
// key = first 16 bytes of SHA256("#regionname")
}
```
Returns `map[string][]byte` (region name → 16-byte HMAC key). Called once at startup, stored on the `Store`.
### Decoder: expose raw payload bytes
Add `PayloadRaw []byte` to `DecodedPacket` in `cmd/ingestor/decoder.go`. Populated from the raw `buf` slice at the payload offset — zero-copy slice, no allocation. This is the **encrypted** payload bytes, matching what the firmware feeds into `calcTransportCode`.
### At-ingest region matching
In `BuildPacketData`:
- Skip if `route_type` not in `{0, 3}``scope_name` stays `nil`
- If `Code1 == "0000"``scope_name = nil` (unscoped transport, no scope involvement)
- If `Code1 != "0000"` → try each region key:
```
HMAC-SHA256(key, payloadType_byte || PayloadRaw) → first 2 bytes as uint16
```
First match → `scope_name = "#regionname"`. No match → `scope_name = ""` (unknown scope).
Add `ScopeName *string` to `PacketData`.
### MQTT-sourced packets (DM / CHAN paths in main.go)
These are injected directly without going through `BuildPacketData`. They use `route_type = 1` (FLOOD), so they are never transport-route packets. No scope matching needed for these paths.
---
## Database
### Migration
```sql
ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL;
CREATE INDEX idx_tx_scope_name ON transmissions(scope_name) WHERE scope_name IS NOT NULL;
```
### Column semantics
| Value | Meaning |
|-------|---------|
| `NULL` | Either: non-transport-route packet (route_type 1/2), or transport-route with Code1=0000 |
| `""` (empty string) | Transport-route, Code1 ≠ 0000, but no configured region matched |
| `"#belgium"` | Matched named region |
The API stats queries resolve the NULL ambiguity by always filtering `route_type IN (0, 3)` first:
- `unscoped` count = `route_type IN (0,3) AND scope_name IS NULL`
- `scoped` count = `route_type IN (0,3) AND scope_name IS NOT NULL`
### Backfill
On migration, re-decode `raw_hex` for all rows where `route_type IN (0, 3)` and `scope_name IS NULL`. Run the same HMAC matching logic. Rows with `Code1 = 0000` remain `NULL`.
The backfill runs in the existing migration framework in `cmd/ingestor/db.go`. If no regions are configured, backfill is skipped.
---
## API
### `GET /api/scope-stats`
**Query param**: `window` — one of `1h`, `24h` (default), `7d`
**Time-series bucket sizes**:
| Window | Bucket |
|--------|--------|
| `1h` | 5 min |
| `24h` | 1 hour |
| `7d` | 6 hours|
**Response**:
```json
{
"window": "24h",
"summary": {
"transportTotal": 1240,
"scoped": 890,
"unscoped": 350,
"unknownScope": 42
},
"byRegion": [
{ "name": "#belgium", "count": 612 },
{ "name": "#eu", "count": 236 }
],
"timeSeries": [
{ "t": "2026-04-23T10:00:00Z", "scoped": 45, "unscoped": 18 },
{ "t": "2026-04-23T11:00:00Z", "scoped": 51, "unscoped": 22 }
]
}
```
- `transportTotal` = `scoped + unscoped` (transport-route packets only)
- `scoped` = Code1 ≠ 0000 (named + unknown)
- `unscoped` = transport-route with Code1 = 0000
- `unknownScope` = scoped but no region name matched (subset of `scoped`)
- `byRegion` sorted by count descending, excludes unknown
- `timeSeries` covers the full window at the bucket granularity
Route: `GET /api/scope-stats` registered in `cmd/server/routes.go`.
No auth required (same as other read endpoints).
TTL cache: 30 seconds (heavier query than `/api/stats`).
---
## Frontend
### Navigation
Add nav link between Channels and Nodes in `public/index.html`:
```html
<a href="#/scopes" class="nav-link" data-route="scopes">Scopes</a>
```
### `public/scopes.js`
Three sections on the page:
**1. Summary cards** (reuse existing card CSS pattern from home/analytics pages)
- Transport total, Scoped, Unscoped, Unknown scope
- Each card shows count + percentage of transport total
**2. Per-region table**
Columns: Region, Messages, % of Scoped
Sorted by count descending. Last row: "Unknown scope" (italic) if unknownScope > 0.
Shows "No regions configured" message if `byRegion` is empty and `unknownScope = 0`.
**3. Time-series chart**
- Window selector: `1h / 24h / 7d` (default 24h)
- Two lines: **Scoped** (blue) and **Unscoped** (grey)
- Uses the same lightweight canvas chart pattern as other pages (no external chart lib)
### Cache buster
`scopes.js` added to the `__BUST__` entries in `index.html` in the same commit.
---
## Testing
- Unit tests for `loadRegionKeys`: normalization, key bytes match firmware SHA256 derivation
- Unit tests for HMAC matching: known Code1 value computed from firmware logic, verified against Go implementation
- Integration test: ingest a synthetic transport-route packet with a known region, assert `scope_name` column is set correctly
- API test: `GET /api/scope-stats` returns correct summary counts against fixture DB
---
## Out of Scope
- Feature 3 (default scope per client via advert) — firmware has no advert scope field
- Drill-down from region row to filtered packet list (deferred)
- Private regions (`$`-prefixed) — use secret keys not publicly derivable
+1 -1
View File
@@ -70,7 +70,7 @@
<div id="help-bar">
Copy the JSON above → paste as a top-level key in <code>config.json</code> → restart the server.
Nodes with no GPS fix always pass through. Remove the <code>geo_filter</code> block to disable filtering.
&nbsp;·&nbsp; <a href="https://github.com/Kpa-clawbot/CoreScope/blob/master/docs/user-guide/geofilter.md" target="_blank">Documentation</a>
&nbsp;·&nbsp; <a href="/geofilter-docs.html">Documentation</a>
</div>
<script>
+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoFilter Docs — CoreScope</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; display: flex; flex-direction: column; }
header { padding: 12px 16px; background: #0f0f23; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 16px; }
header h1 { font-size: 1rem; font-weight: 600; color: #4a9eff; }
#back-link { font-size: 0.8rem; color: #4a9eff; text-decoration: none; white-space: nowrap; }
#back-link:hover { text-decoration: underline; }
main { flex: 1; max-width: 800px; margin: 0 auto; padding: 32px 24px; width: 100%; }
h2 { font-size: 1.1rem; font-weight: 600; color: #4a9eff; margin: 32px 0 12px; border-bottom: 1px solid #222; padding-bottom: 6px; }
h2:first-of-type { margin-top: 0; }
h3 { font-size: 0.95rem; font-weight: 600; color: #c0c0c0; margin: 20px 0 8px; }
p { font-size: 0.9rem; line-height: 1.6; color: #ccc; margin-bottom: 10px; }
ul { padding-left: 20px; margin-bottom: 10px; }
li { font-size: 0.9rem; line-height: 1.7; color: #ccc; }
code { font-family: monospace; font-size: 0.85rem; color: #7ec8e3; background: #111; border: 1px solid #333; border-radius: 3px; padding: 1px 5px; }
pre { background: #111; border: 1px solid #333; border-radius: 6px; padding: 14px 16px; overflow-x: auto; margin: 10px 0 16px; }
pre code { background: none; border: none; padding: 0; font-size: 0.82rem; color: #7ec8e3; }
.note { background: #1a2a1a; border: 1px solid #2a4a2a; border-radius: 6px; padding: 10px 14px; margin: 12px 0; }
.note p { color: #aaddaa; margin: 0; }
.warn { background: #2a1a0a; border: 1px solid #5a3a0a; border-radius: 6px; padding: 10px 14px; margin: 12px 0; }
.warn p { color: #ddbb88; margin: 0; }
table { width: 100%; border-collapse: collapse; margin: 10px 0 16px; font-size: 0.88rem; }
th { background: #0f0f23; color: #888; font-weight: 500; text-align: left; padding: 8px 12px; border: 1px solid #333; }
td { padding: 8px 12px; border: 1px solid #222; color: #ccc; vertical-align: top; }
td code { font-size: 0.82rem; }
</style>
</head>
<body>
<header>
<a href="/geofilter-builder.html" id="back-link">← GeoFilter Builder</a>
<h1>GeoFilter Docs</h1>
</header>
<main>
<h2>How it works</h2>
<p>Geographic filtering restricts which nodes are ingested and returned in API responses. It operates at two levels:</p>
<ul>
<li><strong>Ingest time</strong> — ADVERT packets carrying GPS coordinates are rejected by the ingestor if the node falls outside the configured area. The node never reaches the database.</li>
<li><strong>API responses</strong> — Nodes already in the database are filtered from the <code>/api/nodes</code> response if they fall outside the area. This covers nodes ingested before the filter was configured.</li>
</ul>
<div class="note"><p>Nodes with no GPS fix (<code>lat=0, lon=0</code> or missing coordinates) always pass the filter regardless of configuration.</p></div>
<h2>Configuration</h2>
<p>Add a <code>geo_filter</code> block to <code>config.json</code>:</p>
<pre><code>"geo_filter": {
"polygon": [
[51.55, 3.80],
[51.55, 5.90],
[50.65, 5.90],
[50.65, 3.80]
],
"bufferKm": 20
}</code></pre>
<table>
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>polygon</code></td><td><code>[[lat, lon], ...]</code></td><td>Array of at least 3 coordinate pairs defining the boundary</td></tr>
<tr><td><code>bufferKm</code></td><td>number</td><td>Extra distance (km) around the polygon edge that is also accepted. <code>0</code> = exact boundary</td></tr>
</tbody>
</table>
<p>Both the server and the ingestor read <code>geo_filter</code> from <code>config.json</code>. Restart both after changing this section.</p>
<p>To disable filtering entirely, remove the <code>geo_filter</code> block.</p>
<h2>Coordinate ordering</h2>
<div class="warn"><p><strong>Important:</strong> Coordinates are <code>[lat, lon]</code> — latitude first, longitude second. This is the opposite of GeoJSON, which uses <code>[lon, lat]</code>. Swapping them will place your polygon in the wrong location.</p></div>
<h2>Multi-polygon</h2>
<p>Only a single polygon is supported. If your deployment area consists of multiple disconnected regions, draw a single convex hull that covers all of them, or use the largest region with a generous <code>bufferKm</code> value.</p>
<h2>Examples</h2>
<h3>Belgium (bounding rectangle)</h3>
<pre><code>"geo_filter": {
"polygon": [
[51.55, 3.80],
[51.55, 5.90],
[50.65, 5.90],
[50.65, 3.80]
],
"bufferKm": 20
}</code></pre>
<h3>Irregular shape</h3>
<pre><code>"geo_filter": {
"polygon": [
[51.10, 3.70],
[51.55, 4.20],
[51.30, 5.10],
[50.80, 5.50],
[50.50, 4.80],
[50.70, 3.90]
],
"bufferKm": 10
}</code></pre>
<h2>Legacy bounding box</h2>
<p>An older bounding box format is also supported as a fallback when no <code>polygon</code> is present:</p>
<pre><code>"geo_filter": {
"latMin": 50.65,
"latMax": 51.55,
"lonMin": 3.80,
"lonMax": 5.90
}</code></pre>
<p>Prefer the polygon format — it supports irregular shapes and the <code>bufferKm</code> margin.</p>
<h2>Cleaning up historical nodes</h2>
<p>The ingestor prevents new out-of-bounds nodes from being ingested, but does not retroactively remove nodes stored before the filter was configured. Use the prune script for that:</p>
<pre><code># Dry run — shows what would be deleted without making any changes
python3 scripts/prune-nodes-outside-geo-filter.py --dry-run
# Default paths: /app/data/meshcore.db and /app/config.json
python3 scripts/prune-nodes-outside-geo-filter.py
# Custom paths
python3 scripts/prune-nodes-outside-geo-filter.py /path/to/meshcore.db \
--config /path/to/config.json
# In Docker — run inside the container
docker exec -it meshcore-analyzer \
python3 /app/scripts/prune-nodes-outside-geo-filter.py --dry-run</code></pre>
<p>The script reads <code>geo_filter.polygon</code> and <code>geo_filter.bufferKm</code> from config, lists nodes that fall outside, then asks for <code>yes</code> confirmation before deleting. Nodes without coordinates are always kept.</p>
<p>This is a one-time migration tool — run it once after first configuring <code>geo_filter</code> to clean up pre-filter data.</p>
</main>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
/* hash-color.js — Deterministic HSL color from packet hash
* IIFE attaching window.HashColor = { hashToHsl, hashToOutline }
* Pure function: no DOM access, no state, works in Node vm.createContext sandbox.
*/
(function() {
'use strict';
/**
* Derive a deterministic HSL color string from a hex hash.
* Uses bytes 0-1 for hue, byte 2 for saturation, byte 3 for lightness.
* Produces bright vivid fills; contrast is provided by a dark outline (hashToOutline).
* @param {string|null|undefined} hashHex - Hex string (e.g. "a1b2c3d4...")
* @param {string} theme - "light" or "dark"
* @returns {string} CSS hsl() string
*/
function hashToHsl(hashHex, theme) {
if (!hashHex || hashHex.length < 8) {
return 'hsl(0, 0%, 50%)';
}
var b0 = parseInt(hashHex.slice(0, 2), 16) || 0;
var b1 = parseInt(hashHex.slice(2, 4), 16) || 0;
var b2 = parseInt(hashHex.slice(4, 6), 16) || 0;
var b3 = parseInt(hashHex.slice(6, 8), 16) || 0;
// Hue: 0-360 from bytes 0-1 (16-bit)
var hue = Math.round(((b0 << 8) | b1) / 65535 * 360);
// Saturation: 55-95% from byte 2
var S = 55 + Math.round(b2 / 255 * 40);
// Lightness: vivid range per theme from byte 3
// Light: 50-65%, Dark: 55-72%
var L;
if (theme === 'dark') {
L = 55 + Math.round(b3 / 255 * 17);
} else {
L = 50 + Math.round(b3 / 255 * 15);
}
return 'hsl(' + hue + ', ' + S + '%, ' + L + '%)';
}
/**
* Derive a dark outline color (same hue) for contrast against backgrounds.
* @param {string|null|undefined} hashHex - Hex string
* @param {string} theme - "light" or "dark"
* @returns {string} CSS hsl() string
*/
function hashToOutline(hashHex, theme) {
if (!hashHex || hashHex.length < 8) {
return 'hsl(0, 0%, 30%)';
}
var b0 = parseInt(hashHex.slice(0, 2), 16) || 0;
var b1 = parseInt(hashHex.slice(2, 4), 16) || 0;
var hue = Math.round(((b0 << 8) | b1) / 65535 * 360);
// Dark outline: same hue, low lightness for contrast
if (theme === 'dark') {
return 'hsl(' + hue + ', 30%, 15%)';
}
return 'hsl(' + hue + ', 70%, 25%)';
}
// Export
if (typeof window !== 'undefined') {
window.HashColor = { hashToHsl: hashToHsl, hashToOutline: hashToOutline };
} else if (typeof module !== 'undefined') {
module.exports = { hashToHsl: hashToHsl, hashToOutline: hashToOutline };
}
})();
+1
View File
@@ -94,6 +94,7 @@
<script src="home.js?v=__BUST__"></script>
<script src="table-sort.js?v=__BUST__"></script>
<script src="packet-filter.js?v=__BUST__"></script>
<script src="hash-color.js?v=__BUST__"></script>
<script src="packet-helpers.js?v=__BUST__"></script>
<script src="channel-decrypt.js?v=__BUST__"></script>
<script src="channel-colors.js?v=__BUST__"></script>
+133 -9
View File
@@ -22,6 +22,12 @@
let showOnlyFavorites = localStorage.getItem('live-favorites-only') === 'true';
let matrixMode = localStorage.getItem('live-matrix-mode') === 'true';
let matrixRain = localStorage.getItem('live-matrix-rain') === 'true';
let colorByHash = localStorage.getItem('meshcore-color-packets-by-hash') !== 'false';
/** Current theme string for hash-color functions. */
function _liveTheme() { return document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); }
let nodeFilterKeys = (localStorage.getItem('live-node-filter') || '').split(',').map(s => s.trim()).filter(Boolean);
let nodeFilterTotal = 0;
let nodeFilterShown = 0;
let rainCanvas = null, rainCtx = null, rainDrops = [], rainRAF = null;
const propagationBuffer = new Map(); // hash -> {timer, packets[]}
let _onResize = null;
@@ -825,6 +831,8 @@
<span id="ghostDesc" class="sr-only">Show interpolated ghost markers for unknown hops</span>
<label><input type="checkbox" id="liveRealisticToggle" aria-describedby="realisticDesc"> Realistic</label>
<span id="realisticDesc" class="sr-only">Buffer packets by hash and animate all paths simultaneously</span>
<label><input type="checkbox" id="liveColorHashToggle" aria-describedby="colorHashDesc"> Color by hash</label>
<span id="colorHashDesc" class="sr-only">Color flying-packet dots and contrails by packet hash for propagation tracing</span>
<label><input type="checkbox" id="liveMatrixToggle" aria-describedby="matrixDesc"> Matrix</label>
<span id="matrixDesc" class="sr-only">Animate packet hex bytes flowing along paths like the Matrix</span>
<label><input type="checkbox" id="liveMatrixRainToggle" aria-describedby="rainDesc"> Rain</label>
@@ -833,6 +841,12 @@
<span id="audioDesc" class="sr-only">Sonify packets — turn raw bytes into generative music</span>
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
<div class="live-node-filter-wrap">
<input type="text" id="liveNodeFilterInput" list="liveNodeFilterList" placeholder="Filter by node…" autocomplete="off" class="live-node-filter-input">
<datalist id="liveNodeFilterList"></datalist>
<button id="liveNodeFilterClear" class="vcr-btn" title="Clear node filter" style="display:none">×</button>
</div>
<div id="liveNodeFilterCount" class="live-filter-count hidden"></div>
<label id="liveGeoFilterLabel" style="display:none"><input type="checkbox" id="liveGeoFilterToggle"> Mesh live area</label>
</div>
<div class="audio-controls hidden" id="audioControls">
@@ -983,6 +997,14 @@
localStorage.setItem('live-realistic-propagation', realisticPropagation);
});
const colorHashToggle = document.getElementById('liveColorHashToggle');
colorHashToggle.checked = colorByHash;
colorHashToggle.addEventListener('change', (e) => {
colorByHash = e.target.checked;
localStorage.setItem('meshcore-color-packets-by-hash', colorByHash);
window.dispatchEvent(new Event('storage'));
});
const favoritesToggle = document.getElementById('liveFavoritesToggle');
favoritesToggle.checked = showOnlyFavorites;
favoritesToggle.addEventListener('change', (e) => {
@@ -991,6 +1013,35 @@
applyFavoritesFilter();
});
// Node filter input
const nodeFilterInput = document.getElementById('liveNodeFilterInput');
const nodeFilterClear = document.getElementById('liveNodeFilterClear');
if (nodeFilterInput) {
// Restore from URL param or localStorage
const urlNode = getHashParams && getHashParams().get('node');
if (urlNode) setNodeFilter(urlNode.split(',').map(s => s.trim()).filter(Boolean));
else if (nodeFilterKeys.length) updateNodeFilterUI();
nodeFilterInput.addEventListener('change', (e) => {
const val = e.target.value.trim();
setNodeFilter(val ? val.split(',').map(s => s.trim()).filter(Boolean) : []);
const params = getHashParams ? getHashParams() : new URLSearchParams();
if (nodeFilterKeys.length) params.set('node', nodeFilterKeys.join(','));
else params.delete('node');
const base = location.hash.split('?')[0];
const qs = params.toString();
location.hash = base + (qs ? '?' + qs : '');
});
}
if (nodeFilterClear) {
nodeFilterClear.addEventListener('click', () => {
if (nodeFilterInput) nodeFilterInput.value = '';
setNodeFilter([]);
const base = location.hash.split('?')[0];
location.hash = base;
});
}
// Geo filter overlay
(async function () {
try {
@@ -1656,6 +1707,47 @@
return getFavoritePubkeys().some(f => f === pubkey);
}
function packetInvolvesFilterNode(pkt, filterKeys) {
if (!filterKeys.length) return true;
const hops = (pkt.decoded?.path?.hops) || [];
for (const hop of hops) {
const h = (hop.id || hop.public_key || hop).toString().toLowerCase();
if (filterKeys.some(f => f.toLowerCase().startsWith(h) || h.startsWith(f.toLowerCase()))) return true;
}
return false;
}
function setNodeFilter(keys) {
nodeFilterKeys = keys;
nodeFilterTotal = 0;
nodeFilterShown = 0;
localStorage.setItem('live-node-filter', keys.join(','));
updateNodeFilterUI();
}
function updateNodeFilterUI() {
const countEl = document.getElementById('liveNodeFilterCount');
const clearBtn = document.getElementById('liveNodeFilterClear');
const input = document.getElementById('liveNodeFilterInput');
if (nodeFilterKeys.length > 0) {
if (clearBtn) clearBtn.style.display = '';
if (countEl) { countEl.textContent = `Showing ${nodeFilterShown} of ${nodeFilterTotal}`; countEl.classList.remove('hidden'); }
if (input && input.value !== nodeFilterKeys.join(', ')) input.value = nodeFilterKeys.join(', ');
} else {
if (clearBtn) clearBtn.style.display = 'none';
if (countEl) countEl.classList.add('hidden');
}
updateNodeFilterDatalist();
}
function updateNodeFilterDatalist() {
const dl = document.getElementById('liveNodeFilterList');
if (!dl) return;
dl.innerHTML = Object.values(nodeData).map(n =>
`<option value="${n.public_key}">${n.name || n.public_key.slice(0, 8)}</option>`
).join('');
}
function rebuildFeedList() {
const feed = document.getElementById('liveFeed');
if (!feed) return;
@@ -1862,6 +1954,9 @@
window._liveGetFavoritePubkeys = getFavoritePubkeys;
window._livePacketInvolvesFavorite = packetInvolvesFavorite;
window._liveIsNodeFavorited = isNodeFavorited;
window._livePacketInvolvesFilterNode = packetInvolvesFilterNode;
window._liveGetNodeFilterKeys = function() { return nodeFilterKeys; };
window._liveSetNodeFilter = setNodeFilter;
window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml;
window._liveResolveHopPositions = resolveHopPositions;
window._liveVcrSpeedCycle = vcrSpeedCycle;
@@ -1952,6 +2047,14 @@
// --- Favorites filter ---
if (showOnlyFavorites && !packets.some(function(p) { return packetInvolvesFavorite(p); })) return;
// --- Node filter ---
if (nodeFilterKeys.length) {
nodeFilterTotal++;
if (!packets.some(function(p) { return packetInvolvesFilterNode(p, nodeFilterKeys); })) return;
nodeFilterShown++;
updateNodeFilterUI();
}
// --- Ensure ADVERT nodes appear on map ---
for (var pi = 0; pi < packets.length; pi++) {
var pkt = packets[pi];
@@ -2068,7 +2171,7 @@
var completedPositions = allPaths[ai].hopPositions.slice(0, hopsCompleted + 1);
var remainingPositions = allPaths[ai].hopPositions.slice(hopsCompleted);
if (completedPositions.length >= 2) {
animatePath(completedPositions, typeName, color, allPaths[ai].raw, onHop);
animatePath(completedPositions, typeName, color, allPaths[ai].raw, onHop, first.hash);
} else if (completedPositions.length === 1) {
pulseNode(completedPositions[0].key, completedPositions[0].pos, typeName);
}
@@ -2076,7 +2179,7 @@
drawDashedPath(remainingPositions, color);
}
} else {
animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop);
animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop, first.hash);
}
}
}
@@ -2185,7 +2288,7 @@
return raw.filter(h => h.pos != null);
}
function animatePath(hopPositions, typeName, color, rawHex, onHop) {
function animatePath(hopPositions, typeName, color, rawHex, onHop, hash) {
if (!animLayer || !pathsLayer) return;
if (activeAnims >= MAX_CONCURRENT_ANIMS) return;
activeAnims++;
@@ -2237,7 +2340,7 @@
const nextGhost = hopPositions[hopIndex + 1].ghost;
const lineColor = (isGhost || nextGhost) ? '#94a3b8' : color;
const lineOpacity = (isGhost || nextGhost) ? 0.3 : undefined;
drawAnimatedLine(hp.pos, nextPos, lineColor, () => { hopIndex++; nextHop(); }, lineOpacity, rawHex);
drawAnimatedLine(hp.pos, nextPos, lineColor, () => { hopIndex++; nextHop(); }, lineOpacity, rawHex, hash);
} else {
if (!isGhost) pulseNode(hp.key, hp.pos, typeName);
hopIndex++; nextHop();
@@ -2592,7 +2695,7 @@
requestAnimationFrame(tick);
}
function drawAnimatedLine(from, to, color, onComplete, overrideOpacity, rawHex) {
function drawAnimatedLine(from, to, color, onComplete, overrideOpacity, rawHex, hash) {
if (!animLayer || !pathsLayer) { if (onComplete) onComplete(); return; }
if (matrixMode) return drawMatrixLine(from, to, color, onComplete, rawHex);
const steps = 20;
@@ -2603,17 +2706,30 @@
const mainOpacity = overrideOpacity ?? 0.8;
const isDashed = overrideOpacity != null;
// Hash-derived color for fill + contrail + outline (when toggle ON and not ghost/dashed line)
var hashFill = '#fff';
var hashOutline = color;
var contrailColor = color;
if (colorByHash && hash && !isDashed && window.HashColor) {
var hsl = HashColor.hashToHsl(hash, _liveTheme());
hashFill = hsl;
hashOutline = HashColor.hashToOutline(hash, _liveTheme());
contrailColor = hsl;
}
const contrail = L.polyline([from], {
color: color, weight: 6, opacity: mainOpacity * 0.2, lineCap: 'round'
color: contrailColor, weight: 6, opacity: mainOpacity * 0.2, lineCap: 'round'
}).addTo(pathsLayer);
const line = L.polyline([from], {
color: color, weight: isDashed ? 1.5 : 2, opacity: mainOpacity, lineCap: 'round',
dashArray: isDashed ? '4 6' : null
color: (colorByHash && hash && !isDashed && window.HashColor) ? hashFill : color,
weight: isDashed ? 1.5 : 2, opacity: mainOpacity, lineCap: 'round',
dashArray: isDashed ? '4 6' : null,
className: 'live-packet-trace'
}).addTo(pathsLayer);
const dot = L.circleMarker(from, {
radius: 3.5, fillColor: '#fff', fillOpacity: 1, color: color, weight: 1.5
radius: 3.5, fillColor: hashFill, fillOpacity: 1, color: hashOutline, weight: 1.5
}).addTo(animLayer);
let lastStep = performance.now();
@@ -2745,6 +2861,10 @@
item.setAttribute('tabindex', '0');
item.setAttribute('role', 'button');
item.style.cursor = 'pointer';
// Hash-color stripe for feed items (mirrors packets table border-left)
if (colorByHash && pkt.hash && window.HashColor) {
item.style.borderLeft = '4px solid ' + HashColor.hashToHsl(pkt.hash, _liveTheme());
}
// Channel color highlighting for GRP_TXT packets (#271)
var _cs = _getChannelStyle(pkt);
if (_cs) item.style.cssText += _cs;
@@ -2828,6 +2948,10 @@
item.setAttribute('role', 'button');
if (hash) item.setAttribute('data-hash', hash);
item.style.cursor = 'pointer';
// Hash-color stripe for feed items (mirrors packets table border-left)
if (colorByHash && hash && window.HashColor) {
item.style.borderLeft = '4px solid ' + HashColor.hashToHsl(hash, _liveTheme());
}
// Channel color highlighting for GRP_TXT packets (#271)
var _chanStyle = _getChannelStyle(pkt);
if (_chanStyle) item.style.cssText += _chanStyle;
+14 -2
View File
@@ -388,6 +388,14 @@
}
function drawPacketRoute(hopKeys, origin) {
// Defensive: origin must be an object with pubkey/lat/lon/name. A bare
// string slips through both branches at lines below and silently no-ops
// the originator marker (caused PR #950's bug). Coerce string → object
// and warn so callers get a clear signal.
if (typeof origin === 'string') {
console.warn('drawPacketRoute: origin should be an object {pubkey,lat,lon,name}, got string. Coercing.');
origin = { pubkey: origin };
}
// Hide default markers so only the route is visible
if (markerLayer) map.removeLayer(markerLayer);
if (clusterGroup) map.removeLayer(clusterGroup);
@@ -572,7 +580,11 @@
delete window._pendingPathInspectorRoute;
if (pending.path && pending.path.length > 0) {
if (window.routeLayer) window.routeLayer.clearLayers();
drawPacketRoute(pending.path.slice(1), pending.path[0]);
// Pass full path as hopKeys; null origin (origin is already the first
// hop). slice(1) + path[0] string was wrong — drawPacketRoute expects
// origin to be an OBJECT with pubkey/lat/lon, and stripping the head
// hid the originating node from the route polyline.
drawPacketRoute(pending.path, null);
}
}
@@ -1107,7 +1119,7 @@
var idx = parseInt(btn.dataset.idx);
var cand = data.candidates[idx];
if (routeLayer) routeLayer.clearLayers();
drawPacketRoute(cand.path.slice(1), cand.path[0]);
drawPacketRoute(cand.path, null);
});
});
// Expand evidence on row click.
+22 -3
View File
@@ -13,6 +13,9 @@
return o.iata ? `${o.name} (${o.iata})` : o.name;
}
let selectedId = null;
function _isColorByHash() { return localStorage.getItem('meshcore-color-packets-by-hash') !== 'false'; }
function _currentTheme() { return document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); }
function _hashStripeStyle(hash) { return _isColorByHash() && hash && window.HashColor ? 'border-left:4px solid ' + HashColor.hashToHsl(hash, _currentTheme()) + ';' : ''; }
let groupByHash = true;
let filters = {};
{ const o = localStorage.getItem('meshcore-observer-filter'); if (o) filters.observer = o;
@@ -1359,7 +1362,9 @@
// Channel color highlighting (#271)
const _grpDecoded = getParsedDecoded(p) || {};
const _grpChanStyle = window.ChannelColors ? window.ChannelColors.getRowStyle(_grpDecoded.type || groupTypeName, _grpDecoded.channel) : '';
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_grpChanStyle ? ' style="' + _grpChanStyle + '"' : ''}>
const _grpHashStripe = _hashStripeStyle(p.hash);
const _grpStyle = _grpHashStripe + _grpChanStyle;
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_grpStyle ? ' style="' + _grpStyle + '"' : ''}>
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
<td class="col-time">${renderTimestampCell(p.latest)}</td>
@@ -1385,7 +1390,8 @@
const childRegion = c.observer_id ? (observerMap.get(c.observer_id)?.iata || '') : '';
const childPath = getParsedPath(c);
const childPathStr = renderPath(childPath, c.observer_id);
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row">
const _childHashStripe = _hashStripeStyle(c.hash || p.hash);
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_childHashStripe ? ' style="' + _childHashStripe + '"' : ''}>
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : ''}</td>
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
@@ -1415,7 +1421,9 @@
const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
const pathStr = renderPath(pathHops, p.observer_id);
const detail = getDetailPreview(decoded);
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" data-entry-idx="${entryIdx}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}"${_chanStyle ? ' style="' + _chanStyle + '"' : ''}>
const _flatHashStripe = _hashStripeStyle(p.hash);
const _flatStyle = _flatHashStripe + _chanStyle;
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" data-entry-idx="${entryIdx}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}"${_flatStyle ? ' style="' + _flatStyle + '"' : ''}>
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : ''}</td>
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
@@ -2556,12 +2564,22 @@
} catch {}
}
let _lastColorByHash = _isColorByHash();
function _onStorageChange() {
var current = _isColorByHash();
if (_lastColorByHash !== current) {
_lastColorByHash = current;
renderVisibleRows();
}
}
let _themeRefreshHandler = null;
registerPage('packets', {
init: function(app, routeParam) {
_themeRefreshHandler = () => { if (typeof renderTableRows === 'function') renderTableRows(); };
window.addEventListener('theme-refresh', _themeRefreshHandler);
window.addEventListener('storage', _onStorageChange);
var result = init(app, routeParam);
// Install channel color picker on packets table (M2, #271)
if (window.ChannelColorPicker) window.ChannelColorPicker.installPacketsTable();
@@ -2569,6 +2587,7 @@
},
destroy: function() {
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
window.removeEventListener('storage', _onStorageChange);
return destroy();
}
});
+6 -3
View File
@@ -183,9 +183,12 @@
// Already on map — draw directly.
delete window._pendingPathInspectorRoute;
if (window.routeLayer) window.routeLayer.clearLayers();
var hops = candidate.path.slice(1);
var origin = candidate.path[0] || null;
if (window.drawPacketRoute) window.drawPacketRoute(hops, origin);
// Pass FULL path as hopKeys (not slice(1)) — drawPacketRoute resolves
// each entry against nodes[] for plotting. The 2nd arg is the origin
// OBJECT (with pubkey/lat/lon/name); pass null since the origin is
// already the first hop in the path itself, and drawPacketRoute draws
// a marker for every resolved hop.
if (window.drawPacketRoute) window.drawPacketRoute(candidate.path, null);
}
}
+114
View File
@@ -2141,6 +2141,120 @@ async function run() {
assert(isFullScreen, 'Details button should open full-screen node view');
});
// === Hash color toggle E2E tests (#946) ===
await test('Color-by-hash toggle present on Live page, defaults ON', async () => {
await page.goto(BASE + '#/live', { waitUntil: 'domcontentloaded' });
// Wait until live.js has initialized the toggle (checked = true by default)
await page.waitForFunction(() => {
const el = document.getElementById('liveColorHashToggle');
return el && el.checked === true;
}, { timeout: 10000 });
const checked = await page.$eval('#liveColorHashToggle', el => el.checked);
assert(checked, 'Color by hash toggle should default to ON');
});
await test('Color-by-hash toggle persists across reload', async () => {
await page.goto(BASE + '#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveColorHashToggle', { timeout: 10000 });
// Uncheck toggle
await page.click('#liveColorHashToggle');
const unchecked = await page.$eval('#liveColorHashToggle', el => !el.checked);
assert(unchecked, 'Toggle should be OFF after click');
// Reload
await page.goto(BASE + '#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveColorHashToggle', { timeout: 10000 });
const afterReload = await page.$eval('#liveColorHashToggle', el => !el.checked);
assert(afterReload, 'Toggle OFF state should persist after reload');
// Reset to ON for other tests
await page.click('#liveColorHashToggle');
});
await test('Packets table rows have border-left stripe when toggle ON', async () => {
await page.evaluate(() => localStorage.setItem('meshcore-color-packets-by-hash', 'true'));
// Hard reload to re-init page handler with the new toggle state.
// page.goto with same hash URL is a no-op for re-rendering.
await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr[data-hash]', { timeout: 15000 });
// Wait for hash stripe to be applied (inline style set during render).
// Assert specifically 4px (per spec §2.10) so we don't false-pass on the
// 3px channel-color highlight which is independent of this toggle.
const hasStripe = await page.waitForFunction(() => {
const row = document.querySelector('table tbody tr[data-hash]');
return row && (row.getAttribute('style') || '').includes('border-left:4px');
}, { timeout: 5000 }).then(() => true).catch(() => false);
assert(hasStripe, 'At least one <tr> should have hash-color border-left:4px stripe when toggle ON');
});
await test('Packets table rows have NO border-left stripe when toggle OFF', async () => {
await page.evaluate(() => {
localStorage.setItem('meshcore-color-packets-by-hash', 'false');
});
// Hard reload (page.goto with same hash URL no-ops — must reload to re-init
// the page handler and re-render rows with the new toggle state).
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr[data-hash]', { timeout: 15000 });
await page.waitForTimeout(500);
const noStripe = await page.evaluate(() => {
const rows = document.querySelectorAll('table tbody tr[data-hash]');
for (const r of rows) {
// Hash stripe is 4px (per spec §2.10). Channel-color highlight uses
// 3px and is independent of the hash-color toggle. Only assert no
// 4px hash stripe is present.
if ((r.getAttribute('style') || '').includes('border-left:4px')) return false;
}
return true;
});
assert(noStripe, 'No <tr> should have hash-color border-left:4px stripe when toggle OFF');
// Reset
await page.evaluate(() => localStorage.setItem('meshcore-color-packets-by-hash', 'true'));
});
// --- Live feed hash-color stripe ---
await test('Live feed items have border-left stripe when toggle ON', async () => {
await page.evaluate(() => localStorage.setItem('meshcore-color-packets-by-hash', 'true'));
await page.goto(BASE + '/#/live');
await page.waitForTimeout(3000); // allow feed to populate
const hasStripe = await page.evaluate(() => {
const items = document.querySelectorAll('.live-feed-item');
for (const item of items) {
if ((item.getAttribute('style') || item.style.cssText || '').includes('border-left')) return true;
}
return false;
});
// May not have live packets in fixture — skip if no feed items
const itemCount = await page.evaluate(() => document.querySelectorAll('.live-feed-item').length);
if (itemCount === 0) {
console.log(' (skipped — no live feed items in fixture)');
return;
}
assert(hasStripe, 'At least one .live-feed-item should have hash-color border-left stripe when toggle ON');
});
// --- Map polyline uses hash color ---
await test('Map trace polyline uses hash-derived color when toggle ON', async () => {
await page.evaluate(() => localStorage.setItem('meshcore-color-packets-by-hash', 'true'));
await page.goto(BASE + '/#/live');
await page.waitForTimeout(3000);
// Use the dedicated .live-packet-trace class so we don't pick up
// unrelated leaflet paths (geofilter polygons, region overlays, etc).
const pathCount = await page.evaluate(() => document.querySelectorAll('path.live-packet-trace').length);
if (pathCount === 0) {
console.log(' (skipped — no live-packet-trace polylines drawn in 3s window)');
return;
}
const hasHslPolyline = await page.evaluate(() => {
const paths = document.querySelectorAll('path.live-packet-trace');
for (const p of paths) {
const stroke = p.getAttribute('stroke') || '';
if (stroke.startsWith('hsl(')) return true;
}
return false;
});
assert(hasHslPolyline, 'At least one live-packet-trace polyline should have hsl() stroke color from hash');
});
await browser.close();
// Summary
+150
View File
@@ -0,0 +1,150 @@
/* test-hash-color.js Unit tests for hash-color.js (vm.createContext sandbox)
* Tests: purity, theme split, saturation variability, lightness variability,
* outline darker than fill, sentinel, perceptual distance
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, 'public', 'hash-color.js'), 'utf8');
function createSandbox() {
const sandbox = { window: {}, module: {} };
vm.createContext(sandbox);
vm.runInContext(src, sandbox);
return sandbox.window.HashColor || sandbox.module.exports;
}
const HashColor = createSandbox();
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
function parseHsl(str) {
const m = str.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
if (!m) return null;
return { h: parseInt(m[1]), s: parseInt(m[2]), l: parseInt(m[3]) };
}
// --- Purity: same input → same output ---
console.log('Purity:');
const r1 = HashColor.hashToHsl('a1b2c3d4', 'light');
const r2 = HashColor.hashToHsl('a1b2c3d4', 'light');
assert(r1 === r2, 'Same hash+theme → identical output');
const r3 = HashColor.hashToHsl('a1b2c3d4', 'light');
assert(r1 === r3, 'Third call still identical (no internal state)');
// --- Theme split: light vs dark produce different L ---
console.log('Theme split:');
const light = HashColor.hashToHsl('ff00aa80', 'light');
const dark = HashColor.hashToHsl('ff00aa80', 'dark');
assert(light !== dark, 'Light and dark produce different colors for same hash');
const lightP = parseHsl(light);
const darkP = parseHsl(dark);
assert(lightP.l >= 50 && lightP.l <= 65, 'Light theme L in [50,65] (got ' + lightP.l + ')');
assert(darkP.l >= 55 && darkP.l <= 72, 'Dark theme L in [55,72] (got ' + darkP.l + ')');
// --- Saturation varies with byte 2 ---
console.log('Saturation variability (byte 2):');
const lowSat = HashColor.hashToHsl('000000ff', 'light'); // byte2=0x00
const highSat = HashColor.hashToHsl('0000ffff', 'light'); // byte2=0xff
const lowSatP = parseHsl(lowSat);
const highSatP = parseHsl(highSat);
assert(lowSatP.s === 55, 'byte2=0x00 → S=55% (got ' + lowSatP.s + ')');
assert(highSatP.s === 95, 'byte2=0xff → S=95% (got ' + highSatP.s + ')');
// Mid value
const midSat = HashColor.hashToHsl('00008000', 'light'); // byte2=0x80
const midSatP = parseHsl(midSat);
assert(midSatP.s > 55 && midSatP.s < 95, 'byte2=0x80 → S between 55 and 95 (got ' + midSatP.s + ')');
// --- Lightness varies with byte 3 ---
console.log('Lightness variability (byte 3):');
const lowL = HashColor.hashToHsl('00000000', 'light'); // byte3=0x00
const highL = HashColor.hashToHsl('000000ff', 'light'); // byte3=0xff
const lowLP = parseHsl(lowL);
const highLP = parseHsl(highL);
assert(lowLP.l === 50, 'byte3=0x00 light → L=50 (got ' + lowLP.l + ')');
assert(highLP.l === 65, 'byte3=0xff light → L=65 (got ' + highLP.l + ')');
const lowLD = HashColor.hashToHsl('00000000', 'dark');
const highLD = HashColor.hashToHsl('000000ff', 'dark');
assert(parseHsl(lowLD).l === 55, 'byte3=0x00 dark → L=55 (got ' + parseHsl(lowLD).l + ')');
assert(parseHsl(highLD).l === 72, 'byte3=0xff dark → L=72 (got ' + parseHsl(highLD).l + ')');
// --- Outline is darker than fill ---
console.log('Outline darker than fill:');
['a1b2c3d4', 'ff00aa80', '12345678', 'deadbeef'].forEach(h => {
['light', 'dark'].forEach(theme => {
const fill = parseHsl(HashColor.hashToHsl(h, theme));
const outline = parseHsl(HashColor.hashToOutline(h, theme));
assert(outline.l < fill.l, 'Outline L(' + outline.l + ') < Fill L(' + fill.l + ') for ' + h + '/' + theme);
});
});
// --- Outline same hue as fill ---
console.log('Outline same hue as fill:');
['a1b2c3d4', 'deadbeef'].forEach(h => {
const fill = parseHsl(HashColor.hashToHsl(h, 'light'));
const outline = parseHsl(HashColor.hashToOutline(h, 'light'));
assert(fill.h === outline.h, 'Hue matches: fill=' + fill.h + ' outline=' + outline.h + ' for ' + h);
});
// --- Sentinel: null/empty/short hash ---
console.log('Sentinel:');
assert(HashColor.hashToHsl(null, 'light') === 'hsl(0, 0%, 50%)', 'null → sentinel');
assert(HashColor.hashToHsl('', 'light') === 'hsl(0, 0%, 50%)', 'empty string → sentinel');
assert(HashColor.hashToHsl('ab', 'dark') === 'hsl(0, 0%, 50%)', 'too short (2 chars) → sentinel');
assert(HashColor.hashToHsl('abcdef', 'dark') === 'hsl(0, 0%, 50%)', '6 chars (need 8) → sentinel');
assert(HashColor.hashToHsl(undefined, 'dark') === 'hsl(0, 0%, 50%)', 'undefined → sentinel');
assert(HashColor.hashToOutline(null, 'light') === 'hsl(0, 0%, 30%)', 'null outline → sentinel');
// --- Variability: different hashes → different colors (anti-tautology) ---
console.log('Variability (anti-tautology):');
const colors = new Set();
['00008080', '80008080', 'ff008080', '00ff8080', 'ffff8080'].forEach(h => {
colors.add(HashColor.hashToHsl(h, 'light'));
});
assert(colors.size >= 4, 'At least 4 distinct colors from 5 different hashes (got ' + colors.size + ')');
// Adjacent hashes differ
const c1 = HashColor.hashToHsl('01008080', 'light');
const c2 = HashColor.hashToHsl('02008080', 'light');
assert(c1 !== c2, 'Adjacent hashes produce different colors');
// --- Perceptual distance: sample 50 hashes, compute pairwise HSL distance ---
console.log('Perceptual distance (50 sample hashes):');
function hslDistance(a, b) {
// Simple cylindrical distance: weight hue wrap, sat, lightness
var dh = Math.min(Math.abs(a.h - b.h), 360 - Math.abs(a.h - b.h)) / 180; // 0-1
var ds = Math.abs(a.s - b.s) / 100; // 0-1
var dl = Math.abs(a.l - b.l) / 100; // 0-1
return Math.sqrt(dh*dh + ds*ds + dl*dl);
}
const deterministicHashes = [];
for (var i = 0; i < 50; i++) {
var hex = ('0000000' + (i * 5347 + 12345).toString(16)).slice(-8);
deterministicHashes.push(hex);
}
const parsedColors = deterministicHashes.map(h => parseHsl(HashColor.hashToHsl(h, 'light')));
var distances = [];
for (var i = 0; i < parsedColors.length; i++) {
for (var j = i + 1; j < parsedColors.length; j++) {
distances.push(hslDistance(parsedColors[i], parsedColors[j]));
}
}
var avgDist = distances.reduce((a, b) => a + b, 0) / distances.length;
var minDist = Math.min(...distances);
console.log(' Avg pairwise HSL distance: ' + avgDist.toFixed(4));
console.log(' Min pairwise HSL distance: ' + minDist.toFixed(4));
assert(avgDist > 0.15, 'Average pairwise distance > 0.15 (got ' + avgDist.toFixed(4) + ')');
assert(minDist > 0.01, 'Min pairwise distance > 0.01 (got ' + minDist.toFixed(4) + ')');
// --- Summary ---
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
+50
View File
@@ -928,6 +928,56 @@ console.log('\n=== live.js: source-level safety checks ===');
});
}
// ===== Node filter (M3 — #771) =====
console.log('\n=== live.js: node filter ===');
{
const ctx = makeLiveSandbox();
const pktInvolvesFilter = ctx.window._livePacketInvolvesFilterNode;
assert.ok(pktInvolvesFilter, '_livePacketInvolvesFilterNode must be exposed');
const makePkt = (hops) => ({ decoded: { path: { hops }, payload: {} } });
test('packetInvolvesFilterNode returns true when filter is empty', () => {
assert.strictEqual(pktInvolvesFilter(makePkt(['abcd1234']), []), true);
});
test('packetInvolvesFilterNode matches hop by prefix', () => {
assert.strictEqual(pktInvolvesFilter(makePkt(['abcd1234', 'ef012345']), ['abcd1234567890ab']), true);
});
test('packetInvolvesFilterNode matches full key against short hop', () => {
assert.strictEqual(pktInvolvesFilter(makePkt(['abcd']), ['abcd1234567890ab']), true);
});
test('packetInvolvesFilterNode returns false when no hop matches', () => {
assert.strictEqual(pktInvolvesFilter(makePkt(['ffff1234', '00001111']), ['abcd1234567890ab']), false);
});
test('packetInvolvesFilterNode matches any of multiple filter keys (OR logic)', () => {
assert.strictEqual(pktInvolvesFilter(makePkt(['ffff0000']), ['abcd1234', 'ffff0000']), true);
});
test('packetInvolvesFilterNode returns false for packet with no hops', () => {
assert.strictEqual(pktInvolvesFilter(makePkt([]), ['abcd1234']), false);
});
const getNodeFilterKeys = ctx.window._liveGetNodeFilterKeys;
assert.ok(getNodeFilterKeys, '_liveGetNodeFilterKeys must be exposed');
test('node filter defaults to empty array when localStorage is unset', () => {
assert.strictEqual(getNodeFilterKeys().length, 0);
});
test('node filter saves to localStorage when set', () => {
const setFilter = ctx.window._liveSetNodeFilter;
assert.ok(setFilter, '_liveSetNodeFilter must be exposed');
setFilter(['abcd1234', 'ef012345']);
assert.strictEqual(ctx.localStorage.getItem('live-node-filter'), 'abcd1234,ef012345');
setFilter([]);
assert.strictEqual(ctx.localStorage.getItem('live-node-filter'), '');
});
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);