mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-07 05:31:38 +00:00
dfe383cc51f80136a5c07ee02045ede6f6810ac0
693 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dfe383cc51 |
fix: node detail panel Details/Analytics links don't navigate (#779)
Fixes #778 ## Problem The Details and Analytics links in the node side panel don't navigate when clicked. This is a regression from #739 (desktop node deep linking). **Root cause:** When a node is selected, `selectNode()` uses `history.replaceState()` to set the URL to `#/nodes/{pubkey}`. The Details link has `href="#/nodes/{pubkey}"` — the same hash. Clicking an anchor with the same hash as the current URL doesn't fire the `hashchange` event, so the SPA router never triggers navigation. ## Fix Added a click handler on the `nodesRight` panel that intercepts clicks on `.btn-primary` navigation links: 1. `e.preventDefault()` to stop the default anchor behavior 2. If the current hash already matches the target, temporarily clear it via `replaceState` 3. Set `location.hash` to the target, which fires `hashchange` and triggers the SPA router This handles both the Details link (`#/nodes/{pubkey}`) and the Analytics link (`#/nodes/{pubkey}/analytics`). ## Testing - All frontend helper tests pass (552/552) - All packet filter tests pass (62/62) - All aging tests pass (29/29) - Go server tests pass --------- Co-authored-by: you <you@example.com> |
||
|
|
a9a18ff051 |
fix: neighbor graph slider persists to localStorage, default 0.7 (#776)
## Summary The neighbor graph min score slider didn't persist its value to localStorage, resetting to 0.10 on every page load. This was a poor default for most use cases. ## Changes - **Default changed from 0.10 to 0.70** — more useful starting point that filters out low-confidence edges - **localStorage persistence** — slider value saved on change, restored on page load - **3 new tests** in `test-frontend-helpers.js` verifying default value, load behavior, and save behavior ## Testing - `node test-frontend-helpers.js` — 547 passed, 0 failed - `node test-packet-filter.js` — 62 passed, 0 failed - `node test-aging.js` — 29 passed, 0 failed --------- Co-authored-by: you <you@example.com> |
||
|
|
ceea136e97 |
feat: observer graph representation (M1+M2) (#774)
## Summary Fixes #753 — Milestones M1 and M2: Observer nodes in the neighbor graph are now correctly labeled, colored, and filterable. ### M1: Label + color observers **Backend** (`cmd/server/neighbor_api.go`): - `buildNodeInfoMap()` now queries the `observers` table after building from `nodes` - Observer-only pubkeys (not already in the map as repeaters etc.) get `role: "observer"` and their name from the observers table - Observer-repeaters keep their repeater role (not overwritten) **Frontend**: - CSS variable `--role-observer: #8b5cf6` added to `:root` - `ROLE_COLORS.observer` was already defined in `roles.js` ### M2: Observer filter checkbox (default unchecked) **Frontend** (`public/analytics.js`): - Observer checkbox added to the role filter section, **unchecked by default** - Observers create hub-and-spoke patterns (one observer can have 100+ edges) that drown out the actual repeater topology — hiding them by default keeps the graph clean - Fixed `applyNGFilters()` which previously always showed observers regardless of checkbox state ### Tests - Backend: `TestBuildNodeInfoMap_ObserverEnrichment` — verifies observer-only pubkeys get name+role from observers table, and observer-repeaters keep their repeater role - All existing Go tests pass - All frontend helper tests pass (544/544) --------- Co-authored-by: you <you@example.com> |
||
|
|
ba7cd0fba7 |
fix: clock skew sanity checks — filter epoch-0, cap drift, min samples (#769)
Nodes with dead RTCs show -690d skew and -3 billion s/day drift. Fix: 1. **No Clock severity**: |skew| > 365d → `no_clock`, skip drift 2. **Drift cap**: |drift| > 86400 s/day → nil (physically impossible) 3. **Min samples**: < 5 samples → no drift regression 4. **Frontend**: 'No Clock' badge, '–' for unreliable drift Fixes the crazy stats on the Clock Health fleet view. --------- Co-authored-by: you <you@example.com> |
||
|
|
6a648dea11 |
fix: multi-byte adopters — all node types, role column, advert precedence (#754) (#767)
## Fix: Multi-Byte Adopters Table — Three Bugs (#754) ### Bug 1: Companions in "Unknown" `computeMultiByteCapability()` was repeater-only. Extended to classify **all node types** (companions, rooms, sensors). A companion advertising with 2-byte hash is now correctly "Confirmed". ### Bug 2: No Role Column Added a **Role** column to the merged Multi-Byte Hash Adopters table, color-coded using `ROLE_COLORS` from `roles.js`. Users can now distinguish repeaters from companions without clicking through to node detail. ### Bug 3: Data Source Disagreement When adopter data (from `computeAnalyticsHashSizes`) shows `hashSize >= 2` but capability only found path evidence ("Suspected"), the advert-based adopter data now takes precedence → "Confirmed". The adopter hash sizes are passed into `computeMultiByteCapability()` as an additional confirmed evidence source. ### Changes - `cmd/server/store.go`: Extended capability to all node types, accept adopter hash sizes, prioritize advert evidence - `public/analytics.js`: Added Role column with color-coded badges - `cmd/server/multibyte_capability_test.go`: 3 new tests (companion confirmed, role populated, adopter precedence) ### Tests - All 10 multi-byte capability tests pass - All 544 frontend helper tests pass - All 62 packet filter tests pass - All 29 aging tests pass --------- Co-authored-by: you <you@example.com> |
||
|
|
29157742eb |
feat: show collision details in Hash Usage Matrix for all hash sizes (#758)
## Summary Shows which prefixes are colliding in the Hash Usage Matrix, making the "PREFIX COLLISIONS: N" count actionable. Fixes #757 ## Changes ### Frontend (`public/analytics.js`) - **Clickable collision count**: When collisions > 0, the stat card is clickable and scrolls to the collision details section. Shows a `▼` indicator. - **3-byte collision table**: The collision risk section and `renderCollisionsFromServer` now render for all hash sizes including 3-byte (was previously hidden/skipped for 3-byte). - **Helpful hint**: 3-byte panel now says "See collision details below" when collisions exist. ### Backend (`cmd/server/collision_details_test.go`) - Test that collision details include correct prefix and node name/pubkey pairs - Test that collision details are empty when no collisions exist ### Frontend Tests (`test-frontend-helpers.js`) - Test clickable stat card renders `onclick` and `cursor:pointer` when collisions > 0 - Test non-clickable card when collisions = 0 - Test collision table renders correct node links (`#/nodes/{pubkey}`) - Test no-collision message renders correctly ## What was already there The backend already returned full collision details (prefix, nodes with pubkeys/names/coords, distance classification) in the `hash-collisions` API. The frontend already had `renderCollisionsFromServer` rendering a rich table with node links. The gap was: 1. The 3-byte tab hid the collision risk section entirely 2. No visual affordance to navigate from the stat count to the details ## Perf justification No new computation — collision data was already computed and returned by the API. The only change is rendering it for 3-byte (same as 1-byte/2-byte). The collision list is already limited by the backend sort+slice pattern. --------- Co-authored-by: you <you@example.com> |
||
|
|
ed19a19473 |
fix: correct field table offsets for transport routes (#766)
## Summary Fixes #765 — packet detail field table showed wrong byte offsets for transport routes. ## Problem `buildFieldTable()` hardcoded `path_length` at byte 1 for ALL packet types. For `TRANSPORT_FLOOD` (route_type=0) and `TRANSPORT_DIRECT` (route_type=3), transport codes occupy bytes 1-4, pushing `path_length` to byte 5. This caused: - Wrong offset numbers in the field table for transport packets - Transport codes displayed AFTER path length (wrong byte order) - `Advertised Hash Size` row referenced wrong byte ## Fix - Use dynamic `offset` tracking that accounts for transport codes - Render transport code rows before path length (matching actual wire format) - Store `pathLenOffset` for correct reference in ADVERT payload section - Reuse already-parsed `pathByte0` for hash size calculation in path section ## Tests Added 4 regression tests in `test-frontend-helpers.js`: - FLOOD (route_type=1): path_length at byte 1, no transport codes - TRANSPORT_FLOOD (route_type=0): transport codes at bytes 1-4, path_length at byte 5 - TRANSPORT_DIRECT (route_type=3): same offsets as TRANSPORT_FLOOD - Field table row order matches byte layout for transport routes All existing tests pass (538 frontend helpers, 62 packet filter, 29 aging). Co-authored-by: you <you@example.com> |
||
|
|
bffcbdaa0b |
feat: add channel UX — visible button, hint, status feedback (#760)
## Fixes #759 The "Add Channel" input was a bare text field with no visible submit button and no feedback — users didn't know how to submit or whether it worked. ### Changes **`public/channels.js`** - Replaced bare `<input>` with structured form: label, input + button row, hint text, status div - Added `showAddStatus()` helper for visual feedback during/after channel add - Status messages: loading → success (with decrypted message count) / warning (no messages) / error - Auto-hide status after 5 seconds - Fallback click handler on the `+` button for browsers that don't fire form submit **`public/style.css`** - `.ch-add-form` — form container - `.ch-add-label` — bold 13px label - `.ch-add-row` — flex row for input + button - `.ch-add-btn` — 32×32 accent-colored submit button - `.ch-add-hint` — muted helper text - `.ch-add-status` — feedback with success/warn/error/loading variants **`test-channel-add-ux.js`** — 20 tests validating HTML structure, CSS classes, and feedback logic ### Before / After **Before:** Bare input field, no button, no hint, no feedback **After:** Labeled section with visible `+` button, format hint, and status messages showing decryption results --------- Co-authored-by: you <you@example.com> |
||
|
|
3bdf72b4cf |
feat: clock skew UI — node badges, detail sparkline, fleet analytics (#690 M2+M3) (#752)
## Summary Frontend visualizations for clock skew detection. Implements #690 M2 and M3. Does NOT close #690 — M4+M5 remain. ### M2: Node badges + detail sparkline - Severity badges (⏰ green/yellow/orange/red) on node list next to each node - Node detail: Clock Skew section with current value, severity, drift rate - Inline SVG sparkline showing skew history, color-coded by severity zones ### M3: Fleet analytics view - 'Clock Health' section on Analytics page - Sortable table: Name | Skew | Severity | Drift | Last Advert - Filter buttons by severity (OK/Warning/Critical/Absurd) - Summary stats: X nodes OK, Y warning, Z critical - Color-coded rows ### Changes - `public/nodes.js` — badge rendering + detail section - `public/analytics.js` — fleet clock health view - `public/roles.js` — severity color helpers - `public/style.css` — badge + sparkline + fleet table styles - `cmd/server/clock_skew.go` — added fleet summary endpoint - `cmd/server/routes.go` — wired fleet endpoint - `test-frontend-helpers.js` — 11 new tests --------- Co-authored-by: you <you@example.com> |
||
|
|
1b315bf6d0 |
feat: PSK channels, channel removal, message caching (#725 M3+M4+M5) (#750)
## Summary Implements milestones M3, M4, and M5 from #725 — all client-side, zero server changes. ### M3: PSK channel support The channel input field now accepts both `#channelname` (hashtag derivation) and raw 32-char hex keys (PSK). Auto-detection: if input starts with `#`, derive key via SHA-256; otherwise validate as hex and store directly. Same decrypt pipeline — `ChannelDecrypt.decrypt()` takes key bytes regardless of source. Input placeholder updated to: `#LongFast or paste hex key` ### M4: Channel removal User-added channels now show a ✕ button on hover. Click → confirm dialog → removes: - Key from localStorage (`ChannelDecrypt.removeKey()`) - Cached messages from localStorage (`ChannelDecrypt.clearChannelCache()`) - Channel entry from sidebar If the removed channel was selected, the view resets to the empty state. ### M5: localStorage message caching with delta fetch After client-side decryption, results are cached in localStorage keyed by channel name: ``` { messages: [...], lastTimestamp: "...", count: N, ts: Date.now() } ``` On subsequent visits: 1. **Instant render** — cached messages displayed immediately via `onCacheHit` callback 2. **Delta fetch** — only packets newer than `lastTimestamp` are fetched and decrypted 3. **Merge** — new messages merged with cache, deduplicated by `packetHash` 4. **Cache invalidation** — if total candidate count changes, full re-decrypt triggered 5. **Size limit** — max 1000 messages cached per channel (most recent kept) ### Performance - Delta fetch avoids re-decrypting the full history on every page load - Cache-first rendering provides instant UI response - `deduplicateAndMerge()` uses a hash set for O(n) dedup - 1000-message cap prevents localStorage quota issues ### Tests (24 new) - M3: hex key detection (valid/invalid patterns) - M3: key derivation round-trip, channel hash computation - M3: PSK key storage and retrieval - M4: channel removal clears both key and cache - M5: cache size limit enforcement (1200 → 1000 stored) - M5: cache stores count and lastTimestamp - M5: clearChannelCache works independently - All existing tests pass (523 frontend helpers, 62 packet filter) ### Files changed | File | Change | |------|--------| | `public/channel-decrypt.js` | `removeKey()` now clears cache; `clearChannelCache()`; `setCache()` with count + size limit | | `public/channels.js` | Extracted `decryptCandidates()`, `deduplicateAndMerge()`; delta fetch logic; remove button handler; cache-first rendering | | `public/style.css` | `.ch-remove-btn` styles (hover-reveal ✕) | | `test-channel-decrypt-m345.js` | 24 new tests | Implements #725 Co-authored-by: you <you@example.com> |
||
|
|
2aea01f10c |
fix: merge repeater+observer into single map marker (#745)
## Problem When a node acts as both a repeater and an observer (same public key — common with powered repeaters running MQTT clients), the map shows two separate markers at the same location: a repeater rectangle and an observer star. This causes visual clutter and both markers get pushed out from the real location by the deconfliction algorithm. ## Solution Detect combined repeater+observer nodes by matching node `public_key` against observer `id`. When matched: - **Label mode (hash labels on):** The repeater label gets a gold ★ appended inside the rectangle - **Icon mode (hash labels off):** The repeater diamond gets a small star overlay in the top-right corner of the SVG - **Popup:** Shows both REPEATER and OBSERVER badges - **Observer markers:** Skipped when the observer is already represented as a combined node marker - **Legend count:** Observer count excludes combined nodes (shows standalone observers only) ## Performance - Observer lookup uses a `Map` keyed by lowercase pubkey — O(1) per node check - Legend count uses a `Set` of node pubkeys — O(n+m) instead of O(n×m) - No additional API calls; uses existing `observers` array already fetched ## Testing - All 523 frontend helper tests pass - All 62 packet filter tests pass - Visual: combined nodes show as single marker with star indicator Fixes #719 --------- Co-authored-by: you <you@example.com> |
||
|
|
1de80a9eaf |
feat: serve geofilter builder from app, link from customizer (#735)
## Summary Part of #669 — M2: Link the builder from the app. - **`public/geofilter-builder.html`** — the existing `tools/geofilter-builder.html` is now served by the static file server at `/geofilter-builder.html`. Additions vs the original: a `← CoreScope` back-link in the header, inline code comments explaining the output format, and a help bar below the output panel with paste instructions and a link to the documentation. - **`public/customize-v2.js`** — adds a "Tools" section at the bottom of the Export tab with a `🗺️ GeoFilter Builder →` link and a one-line description. - **`docs/user-guide/customization.md`** — documents the new GeoFilter Builder entry in the Export tab. > **Note:** `tools/geofilter-builder.html` is kept as-is for local/offline use. The `public/` copy is what the server serves. > **Depends on:** #734 (M1 docs) for `docs/user-guide/geofilter.md` — the link in the help bar references that file. Can be merged independently; the link still works once M1 lands. ## Test plan - [x] Open the app, go to Customizer → Export tab — "Tools" section appears with GeoFilter Builder link - [x] Click the link — opens `/geofilter-builder.html` in a new tab - [x] Builder loads the Leaflet map, draw 3+ points — JSON output appears - [x] Copy button works, output is valid `{ "geo_filter": { ... } }` JSON - [x] `← CoreScope` back-link navigates to `/` - [x] Help bar shows paste instructions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
e6ace95059 |
fix: desktop node click updates URL hash, deep link opens split panel (#676) (#739)
## Problem
Clicking a node on desktop opened the side panel but never updated the
URL hash, making nodes non-shareable/bookmarkable on desktop. Loading
`#/nodes/{pubkey}` directly on desktop also incorrectly showed the
full-screen mobile view.
## Changes
- `selectNode()` on desktop: adds `history.replaceState(null, '',
'#/nodes/' + pubkey)` so the URL updates on every click
- `init()`: full-screen path is now gated to `window.innerWidth <= 640`
(mobile only); desktop with a `routeParam` falls through to the split
panel and calls `selectNode()` to pre-select the node
- Deselect (Escape / close button): also calls `history.replaceState`
back to `#/nodes`
## Test plan
- [x] Desktop: click a node → URL updates to `#/nodes/{pubkey}`, split
panel opens
- [x] Desktop: copy URL, open in new tab → split panel opens with that
node selected (not full-screen)
- [x] Desktop: press Escape → URL reverts to `#/nodes`
- [x] Mobile (≤640px): clicking a node still navigates to full-screen
view
Closes #676
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
f605d4ce7e |
fix: serialize filter params in URL hash for deep linking (#682) (#740)
## Problem Applying packet filters (hash, node, observer, Wireshark expression) did not update the URL hash, so filtered views could not be shared or bookmarked. ## Changes **`buildPacketsQuery()`** — extended to include: - `hash=` from `filters.hash` - `node=` from `filters.node` - `observer=` from `filters.observer` - `filter=` from `filters._filterExpr` (Wireshark expression string) **`updatePacketsUrl()`** — now called on every filter change: - hash input (debounced) - observer multi-select change - node autocomplete select and clear - Wireshark filter input (on valid expression or clear) **URL restore on load** — `getHashParams()` now reads `hash`, `node`, `observer`, `filter` and restores them into `filters` before the DOM is built. Input fields pick up values from `filters` as before. Wireshark expression is also recompiled and `filter-active` class applied. ## Test plan - [ ] Type in hash filter → URL updates with `&hash=...` - [ ] Copy URL, open in new tab → hash filter is pre-filled - [ ] Select an observer → URL updates with `&observer=...` - [ ] Select a node filter → URL updates with `&node=...` - [ ] Type `type=ADVERT` in Wireshark filter → URL updates with `&filter=type%3DADVERT` - [ ] Load that URL → filter expression restored and active Closes #682 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
84f03f4f41 |
fix: hide undecryptable channel messages by default (#727) (#728)
## Problem Channels page shows 53K 'Unknown' messages — undecryptable GRP_TXT packets with no content. Pure noise. ## Fix - Backend: channels API filters out undecrypted messages by default - `?includeEncrypted=true` param to include them - Frontend: 'Show encrypted' toggle in channels sidebar - Unknown channels grayed out with '(no key)' label - Toggle persists in localStorage Fixes #727 --------- Co-authored-by: you <you@example.com> |
||
|
|
8158631d02 |
feat: client-side channel decryption — add custom channels in browser (#725 M2) (#733)
## Summary Pure client-side channel decryption. Users can add custom hashtag channels or PSK channels directly in the browser. **The server never sees the keys.** Implements #725 M2 (revised). Does NOT close #725. ## How it works 1. User types `#channelname` or pastes a hex PSK in the channels sidebar 2. Browser derives key (`SHA256("#name")[:16]`) using Web Crypto API 3. Key stored in **localStorage** — never sent to the server 4. Browser fetches encrypted GRP_TXT packets via existing API 5. Browser decrypts client-side: AES-128-ECB + HMAC-SHA256 MAC verification 6. Decrypted messages cached in localStorage 7. Progressive rendering — newest messages first, chunk-based ## Security - Keys never leave the browser - No new API endpoints - No server-side changes whatsoever - Channel interest partially observable via hash-based API requests (documented, acceptable tradeoff) ## Changes - `public/channels.js` — client-side decrypt module + UI integration (+307 lines) - `public/index.html` — no new script (inline in channels.js IIFE) - `public/style.css` — add-channel input styling --------- Co-authored-by: you <you@example.com> |
||
|
|
14367488e2 |
fix: TRACE path_json uses path_sz from flags byte, not header hash_size (#732)
## Summary TRACE packets encode their route hash size in the flags byte (`flags & 0x03`), not the header path byte. The decoder was using `path.HashSize` from the header, which could be wrong or zero for direct-route TRACEs, producing incorrect hop counts in `path_json`. ## Protocol Note Per firmware, TRACE packets are **always direct-routed** (route_type 2 = DIRECT, or 3 = TRANSPORT_DIRECT). FLOOD-routed TRACEs (route_type 1) are anomalous — firmware explicitly rejects TRACE via flood. The decoder handles these gracefully without crashing. ## Changes **`cmd/server/decoder.go` and `cmd/ingestor/decoder.go`:** - Read `pathSz` from TRACE flags byte: `(traceFlags & 0x03) + 1` (0→1byte, 1→2byte, 2→3byte) - Use `pathSz` instead of `path.HashSize` for splitting TRACE payload path data into hops - Update `path.HashSize` to reflect the actual TRACE path size - Added `HopsCompleted` field to ingestor `Path` struct for parity with server - Updated comments to clarify TRACE is always direct-routed per firmware **`cmd/server/decoder_test.go` — 5 new tests:** - `TraceFlags1_TwoBytePathSz`: flags=1 → 2-byte hashes via DIRECT route - `TraceFlags2_ThreeBytePathSz`: flags=2 → 3-byte hashes via DIRECT route - `TracePathSzUnevenPayload`: payload not evenly divisible by path_sz - `TraceTransportDirect`: route_type=3 with transport codes + TRACE path parsing - `TraceFloodRouteGraceful`: anomalous FLOOD+TRACE handled without crash All existing TRACE tests (flags=0, 1-byte hashes) continue to pass. Fixes #731 --------- Co-authored-by: you <you@example.com> |
||
|
|
7af91f7ef6 |
fix: perf page shows tracked memory instead of heap allocation (#718)
## Summary The perf page "Memory Used" tile displayed `estimatedMB` (Go `runtime.HeapAlloc`), which includes all Go runtime allocations — not just packet store data. This made the displayed value misleading: it showed ~2.4GB heap when only ~833MB was actual tracked packet data. ## Changes ### Frontend (`public/perf.js`) - Primary tile now shows `trackedMB` as **"Tracked Memory"** — the self-accounted packet store memory - Added separate **"Heap (debug)"** tile showing `estimatedMB` for runtime visibility ### Backend - **`types.go`**: Added `TrackedMB` field to `HealthPacketStoreStats` struct - **`routes.go`**: Populate `TrackedMB` in `/health` endpoint response from `GetPerfStoreStatsTyped()` - **`routes_test.go`**: Assert `trackedMB` exists in health endpoint's `packetStore` - **`testdata/golden/shapes.json`**: Updated shape fixture with new field ### What was already correct - `/api/perf/stats` already exposed both `estimatedMB` and `trackedMB` - `trackedMemoryMB()` method already existed in store.go - Eviction logic already used `trackedBytes` (not HeapAlloc) ## Testing - All Go tests pass (`go test ./... -count=1`) - No frontend logic changes beyond template string field swap Fixes #717 Co-authored-by: you <you@example.com> |
||
|
|
45623672d9 |
fix: integrate multi-byte capability into adopters table, fix filter buttons (#712) (#713)
## Summary Fixes #712 — Multi-byte capability filter buttons broken + needs integration with Hash Adopters. ### Changes **M1: Fix filter buttons breaking after first click** - Root cause: `section.replaceWith(newSection)` replaced the entire DOM node, but the event listener was attached to the old node. After replacement, clicks went unhandled. - Fix: Instead of replacing the whole section, only swap the table content inside a stable `#mbAdoptersTableWrap` div. The event listener on `#mbAdoptersSection` persists across filter changes. - Button active state is now toggled via `classList.toggle` instead of full DOM rebuild. **M2: Better button labels** - Changed from icon-only (`✅ 76`) to descriptive labels: `✅ Confirmed (76)`, `⚠️ Suspected (81)`, `❓ Unknown (223)` **M3: Integrate with Multi-Byte Hash Adopters** - Merged capability status into the existing adopters table as a new "Status" column - Removed the separate "Repeater Multi-Byte Capability" section - Filter buttons now apply to the integrated table - Nodes without capability data default to ❓ Unknown - Capability data is looked up by pubkey from the existing `multiByteCapability` API response (no backend changes needed) ### Performance - No new API calls — capability data already exists in the hash sizes response - Filter toggle is O(n) where n = number of adopter nodes (typically <500) - Event delegation on stable parent — no listener re-attachment needed ### Tests - Updated existing `renderMultiByteCapability` tests for new label format - Added 5 new tests for `renderMultiByteAdopters`: empty state, status integration, text labels with counts, unknown default, Status column presence - All 507 frontend tests pass, all Go tests pass Co-authored-by: you <you@example.com> |
||
|
|
7e0b904d09 |
fix: refresh live feed relative timestamps every 10s (#709)
## Summary Fixes #701 — Live feed timestamps showed stale relative times (e.g. "2s ago" never updated to "5m ago"). ## Root Cause `formatLiveTimestampHtml()` was called once when each feed item was created and never refreshed. The dedup path (when a duplicate hash moves an item to the top) also didn't update the timestamp. ## Changes ### `public/live.js` - **`data-ts` attribute on `.feed-time` spans**: All three feed item creation paths (VCR replay, `addFeedItemDOM`, `addFeedItem`) now store the packet timestamp as `data-ts` on the `.feed-time` span element - **10-second refresh interval**: A `setInterval` queries all `.feed-time[data-ts]` elements and re-renders their content via `formatLiveTimestampHtml()`, keeping relative times accurate - **Dedup path timestamp update**: When a duplicate hash observation moves an existing feed item to the top, the `.feed-time` span is updated with the new observation's timestamp - **Cleanup**: The interval is cleared on page teardown alongside other intervals ### `test-live.js` - 3 new tests: formatting idempotency, numeric timestamp acceptance, `data-ts` round-trip correctness ## Performance - The refresh interval runs every 10s, iterating over at most 25 `.feed-time` DOM elements (feed is capped at 25 items via `while (feed.children.length > 25)`). Negligible overhead. - Uses `querySelectorAll` with attribute selector — O(n) where n ≤ 25. ## Testing - All 3 new tests pass - All pre-existing test suites pass (70 live.js tests, 62 packet-filter, 501 frontend-helpers) - 8 pre-existing failures in `test-live.js` are unrelated (`getParsedDecoded` missing from sandbox) Co-authored-by: you <you@example.com> |
||
|
|
ef8bce5002 |
feat: repeater multi-byte capability inference table (#706)
## Summary Adds a new "Repeater Multi-Byte Capability" section to the Hash Stats analytics tab that classifies each repeater's ability to handle multi-byte hash prefixes (firmware >= v1.14). Fixes #689 ## What Changed ### Backend (`cmd/server/store.go`) - New `computeMultiByteCapability()` method that infers capability for each repeater using two evidence sources: - **Confirmed** (100% reliable): node has advertised with `hash_size >= 2`, leveraging existing `computeNodeHashSizeInfo()` data - **Suspected** (<100%): node's prefix appears as a hop in packets with multi-byte path headers, using the `byPathHop` index. Prefix collisions mean this isn't definitive. - **Unknown**: no multi-byte evidence — could be pre-1.14 or 1.14+ with default settings - Extended `/api/analytics/hash-sizes` response with `multiByteCapability` array ### Frontend (`public/analytics.js`) - New `renderMultiByteCapability()` function on the Hash Stats tab - Color-coded table: green confirmed, yellow suspected, gray unknown - Filter buttons to show all/confirmed/suspected/unknown - Column sorting by name, role, status, evidence, max hash size, last seen - Clickable rows link to node detail pages ### Tests (`cmd/server/multibyte_capability_test.go`) - `TestMultiByteCapability_Confirmed`: advert with hash_size=2 → confirmed - `TestMultiByteCapability_Suspected`: path appearance only → suspected - `TestMultiByteCapability_Unknown`: 1-byte advert only → unknown - `TestMultiByteCapability_PrefixCollision`: two nodes sharing prefix, one confirmed via advert, other correctly marked suspected (not confirmed) ## Performance - `computeMultiByteCapability()` runs once per cache cycle (15s TTL via hash-sizes cache) - Leverages existing `GetNodeHashSizeInfo()` cache (also 15s TTL) — no redundant advert scanning - Path hop scan is O(repeaters × prefix lengths) lookups in the `byPathHop` map, with early break on first match per prefix - Only computed for global (non-regional) requests to avoid unnecessary work --------- Co-authored-by: you <you@example.com> |
||
|
|
922ebe54e7 |
BYOP Advert signature validation (#686)
For BYOP mode in the packet analyzer, perform signature validation on advert packets and display whether successful or not. This is added as we observed many corrupted advert packets that would be easily detectable as such if signature validation checks were performed. At present this MR is just to add this status in BYOP mode so there is minimal impact to the application and no performance penalty for having to perform these checks on all packets. Moving forward it probably makes sense to do these checks on all advert packets so that corrupt packets can be ignored in several contexts (like node lists for example). Let me know what you think and I can adjust as needed. --------- Co-authored-by: you <you@example.com> |
||
|
|
bc22dbdb14 |
feat: DragManager — core drag mechanics (#608 M1) (#697)
## Summary Implements M1 of the draggable panels spec from #608: the `DragManager` class with core drag mechanics. Fixes #608 (M1: DragManager core drag mechanics) ## What's New ### `public/drag-manager.js` (~215 lines) - **State machine:** `IDLE → PENDING → DRAGGING → IDLE` - **5px dead zone** on `.panel-header` to disambiguate click vs drag — prevents hijacking corner toggle and close button clicks - **Pointer events** with `setPointerCapture` for reliable tracking - **`transform: translate()`** during drag — zero layout reflow - **Snap-to-edge** on release: 20px threshold snaps to 12px margin - **Z-index management** — dragged panel comes to front (counter from 1000) - **`_detachFromCorner()`** — transitions panel from M0 corner CSS to fixed positioning - **Escape key** cancels drag and reverts to pre-drag position - **`restorePositions()`** — applies saved viewport percentages on init - **`handleResize()`** — clamps dragged panels inside viewport on window resize - **`enable()`/`disable()`** — responsive gate control ### `public/live.js` integration - Instantiates `DragManager` after `initPanelPositions()` - Registers `liveFeed`, `liveLegend`, `liveNodeDetail` panels - **Responsive gate:** `matchMedia('(pointer: fine) and (min-width: 768px)')` — disables drag on touch/small screens, reverts to M0 corner toggle - **Resize clamping** debounced at 200ms ### `public/live.css` additions - `cursor: grab/grabbing` on `.panel-header` (desktop only via `@media (pointer: fine)`) - `.is-dragging` class: opacity 0.92, elevated box-shadow, `will-change: transform`, transitions disabled - `[data-dragged="true"]` disables corner transition animations - `prefers-reduced-motion` support ### Persistence - **Format:** `panel-drag-{id}` → `{ xPct, yPct }` (viewport percentages) - **Survives resize:** positions recalculated from percentages - **Corner toggle still works:** clicking corner button after drag clears drag state (handled by existing M0 code) ## Tests 14 new unit tests in `test-drag-manager.js`: - State machine transitions (IDLE → PENDING → DRAGGING → IDLE) - Dead zone enforcement - Button click guard (no drag on button pointerdown) - Snap-to-edge behavior - Position persistence as viewport percentages - Restore from localStorage - Resize clamping - Disable/enable ## Performance - `transform: translate()` during drag — compositor-only, no layout reflow - `will-change: transform` only during active drag (`.is-dragging`), removed on drop - `localStorage` write only on `pointerup`, never during `pointermove` - Resize handler debounced at 200ms - Single `style.transform` assignment per pointermove frame — negligible cost --------- Co-authored-by: you <you@example.com> |
||
|
|
2e1a4a2e0d |
fix: handle companion nodes without adverts in My Mesh health cards (#696)
## Summary Fixes #665 — companion nodes claimed in "My Mesh" showed "Could not load data" because they never sent an advert, so they had no `nodes` table entry, causing the health API to return 404. ## Three-Layer Fix ### 1. API Resilience (`cmd/server/store.go`) `GetNodeHealth()` now falls back to building a partial response from the in-memory packet store when `GetNodeByPubkey()` returns nil. Returns a synthetic node stub (`role: "unknown"`, `name: "Unknown"`) with whatever stats exist from packets, instead of returning nil → 404. ### 2. Ingestor Cleanup (`cmd/ingestor/main.go`) Removed phantom sender node creation that used `"sender-" + name` as the pubkey. Channel messages don't carry the sender's real pubkey, so these synthetic entries were unreachable from the claiming/health flow — they just polluted the nodes table with unmatchable keys. ### 3. Frontend UX (`public/home.js`) The catch block in `loadMyNodes()` now distinguishes 404 (node not in DB yet) from other errors: - **404**: Shows 📡 "Waiting for first advert — this node has been seen in channel messages but hasn't advertised yet" - **Other errors**: Shows ❓ "Could not load data" (unchanged) ## Tests - Added `TestNodeHealthPartialFromPackets` — verifies a node with packets but no DB entry returns 200 with synthetic node stub and stats - Updated `TestHandleMessageChannelMessage` — verifies channel messages no longer create phantom sender nodes - All existing tests pass (`cmd/server`, `cmd/ingestor`) Co-authored-by: you <you@example.com> |
||
|
|
a1e1e0bd2f |
fix: bottom-positioned panels overlap VCR bar (#693)
Fixes #685 ## Problem Corner positioning CSS (from PR #608) sets `bottom: 12px` for bottom-positioned panels (`bl`, `br`), but the VCR bar at the bottom of the live page is ~50px tall. This causes the legend (and any bottom-positioned panel) to overlap the VCR controls. ## Fix Changed `bottom: 12px` → `bottom: 58px` for both `.live-overlay[data-position="bl"]` and `.live-overlay[data-position="br"]`, matching the legend's original `bottom: 58px` value that properly clears the VCR bar. The VCR bar height is fixed (`.vcr-bar` class with consistent padding), so a hardcoded value is appropriate here. ## Testing - All existing tests pass (`npm test` — 13/13) - CSS-only change, no logic affected Co-authored-by: you <you@example.com> |
||
|
|
5606bc639e |
fix: table sorting broken on all node tables — wrong data attribute (#679) (#680)
## Problem All table sorting on the Nodes page was broken — clicking column headers did nothing. Affected: - Nodes list table - Node detail → Neighbors table - Node detail → Observers table ## Root Cause **Not a race condition** — the actual bug was a **data attribute mismatch**. `TableSort.init()` (in `table-sort.js`) queries for `th[data-sort-key]` to find sortable columns. But all table headers in `nodes.js` used `data-sort="..."` instead of `data-sort-key="..."`. The selector never matched any headers, so no click handlers were attached and sorting silently failed. Additionally, `data-type="number"` was used but TableSort's built-in comparator is named `numeric`, causing numeric columns to fall back to text comparison. The packets table (`packets.js`) was unaffected because it already used the correct `data-sort-key` and `data-type="numeric"` attributes. ## Fix 1. **`public/nodes.js`**: Changed all `data-sort="..."` to `data-sort-key="..."` on `<th>` elements (nodes list, neighbors table, observers table) 2. **`public/nodes.js`**: Changed `data-type="number"` to `data-type="numeric"` to match TableSort's comparator names 3. **`public/packets.js`**: Added timestamp tiebreaker to packet sort for stable ordering when primary column values are equal ## Testing - All existing tests pass (`npm test`) - No changes to test infrastructure needed — this was a pure HTML attribute fix Fixes #679 --------- Co-authored-by: you <you@example.com> |
||
|
|
1373106b50 |
Fix panel corner toggle buttons invisible and scrolling away (#678)
## Summary Panel corner toggle buttons (◫) were invisible due to small size, low opacity, and `position: absolute` causing them to scroll away with panel content. ## Changes ### Panel structure — non-scrolling header All 3 live overlay panels (feed, node detail, legend) now use a flex layout: - **`.panel-header`** — non-scrolling row with corner toggle + close button - **`.panel-content`** — scrollable content area ### CSS updates - `.live-overlay`: `display: flex; flex-direction: column` - `.panel-header`: flex row, `flex-shrink: 0` - `.panel-content`: `flex: 1; overflow-y: auto` - `.panel-corner-btn`: removed `position: absolute`, increased to 28×28px, opacity 0.6, hover background ### JS updates - Feed items now appended to `.panel-content` child instead of panel root - `rebuildFeedList` and `addFeedItem` updated to target `.panel-content` - Resize handle still attaches to panel root (correct behavior) ## Testing - All 490+ frontend helper tests pass - All panel-corner tests pass (14/14) - No test changes needed — tests exercise logic, not DOM structure Fixes #677 --------- Co-authored-by: you <you@example.com> |
||
|
|
68a4628edf |
fix: channel color picker — data shape mismatch + redesign for discoverability (#675)
## Fix: Channel Color Picker — Data Shape Mismatch + Redesign (#674) ### Problem The channel color picker was completely non-functional — dead code. Three locations in `live.js` attempted to read `decoded.header.payloadTypeName` and `decoded.payload.channelName`, but: 1. The decoded payload structure is flat (`decoded.payload.channelHash`), not nested with separate `header`/`payload` objects within the payload 2. The field is `channelHash` (an integer), not `channelName` 3. `_ccChannel` was **never set** on any DOM element, so all picker handlers exited early Additionally, the picker had zero discoverability — hidden behind right-click/long-press with no visual affordance. ### Changes **M1 — Fix the data shape bug:** - Fixed `_ccChannel` assignment in 3 locations in `live.js` to use `decoded.payload.channelHash` (converted to string) - Fixed `_getChannelStyle()` to use the same flat structure - Channel colors now key on the hash string (e.g. `"5"`) matching the channels API **M2 — Redesign for discoverability:** - Reduced palette from 10 to **8 maximally-distinct colors** (removed teal/rose — too close to cyan/red) - Removed `<input type="color">` custom picker, "Apply" button, title bar, close button - Popover is now just 8 circle swatches + "Clear color" — click outside to dismiss - Added **12px clickable color dots** next to channel names on the channels page (primary configuration surface) - Unassigned channels show a dashed-border empty circle; assigned show filled - Channel list items get `border-left: 3px solid` when colored - **Removed long-press handler entirely** — dots handle mobile interaction - Mobile: bottom-sheet with 36px touch targets via `@media (pointer: coarse)` **M3 — Visual encoding:** - Left border only (3px) — no background tint (per Tufte spec: minimum effective dose) - Consistent encoding across live feed items, channel list, packets table ### Tests 17 new tests in `test-channel-color-picker.js`: - `_ccChannel` correctly set for GRP_TXT with various `channelHash` values (including 0) - `_ccChannel` not set for non-GRP_TXT packets - `getRowStyle` returns `border-left:3px` only (no background) - Palette is exactly 8 colors, no teal/rose - All existing tests pass (62 + 29 + 490) Fixes #674 --------- Co-authored-by: you <you@example.com> |
||
|
|
e0e9aaa324 |
feat: noise floor column chart with color-coded thresholds (#659)
## Noise Floor: Line Chart → Color-Coded Column Chart Implements M3a from the [RF Health Dashboard spec](https://github.com/Kpa-clawbot/CoreScope/issues/600#issuecomment-2784399622) — replacing the noise floor line chart with discrete color-coded columns. ### What changed **`public/analytics.js`** — replaced `rfNFLineChart()` with `rfNFColumnChart()`: - **Color-coded bars by threshold**: green (`< -100 dBm`), yellow (`-100 to -85 dBm`), red (`≥ -85 dBm`) - **Instant hover tooltips**: exact dBm value + UTC timestamp via native SVG `<title>` — no delay - **Column highlighting on hover**: CSS `:hover` with opacity change + border stroke - **Inline legend**: green/yellow/red threshold key in chart header - **Removed reference lines**: the `-100 warning` and `-85 critical` dashed lines are eliminated — threshold info is now encoded directly in bar color (data-ink ratio improvement) - **No gap detection**: column charts render discrete bars — each data point is an independent observation, so line-chart-style gap detection doesn't apply. Every sample gets a bar. - **Reboot markers**: vertical dashed lines with "reboot" labels at reboot timestamps (shared `rfRebootMarkers` helper, same as other RF charts) - **Division-by-zero guard**: constant values or single data points use a ±5 dBm window so bars render with visible height - **Sparklines unchanged**: fleet overview sparklines remain as polylines (correct at 140×24px scale) ### Why columns instead of lines A polyline connecting discrete 5-minute noise floor samples creates false visual continuity — it implies interpolation between measurements that doesn't exist. When readings jump between -115 and -95 irregularly, the line becomes a jagged mess. Column bars encode each sample as a discrete, independent observation: one bar = one measurement. ### Testing - 12 unit tests in `test-frontend-helpers.js` covering: SVG output, threshold color coding, tooltips, empty/single/constant data, legend rendering, reboot markers, shared time axis - All existing tests pass (packet-filter: 62, aging: 29, frontend-helpers: 490) ### No backend changes Pure frontend change — ~150 lines in `analytics.js`. Fixes #600 --------- Co-authored-by: you <you@example.com> |
||
|
|
b8e9b04a97 |
feat: panel corner-position toggle (M0) (#657)
## Panel Corner-Position Toggle (M0) Fixes #608 ### What Each overlay panel on the live map page (feed, legend, node detail) gets a small corner-toggle button that cycles through **TL → TR → BR → BL** placement. This solves the panel-blocking-map-data problem with minimal complexity. ### Changes **`public/live.css`** (~60 lines) - CSS classes for 4 corner positions via `data-position` attribute - Smooth transitions with `cubic-bezier` easing - `prefers-reduced-motion` support - Direction-aware hide animations for positioned panels - `.panel-corner-btn` styling (subtle, hover-to-reveal) - Mobile: corner buttons hidden (`<640px` — panels are hidden or bottom-sheet) - `.sr-only` class for screen reader announcements **`public/live.js`** (~90 lines) - `PANEL_DEFAULTS`, `CORNER_CYCLE`, `CORNER_ARROWS` constants - `getPanelPositions()` — reads from localStorage with defaults - `nextAvailableCorner()` — collision avoidance (skips occupied corners) - `applyPanelPosition()` — sets `data-position` + updates button - `onCornerClick()` — cycle logic + persistence + SR announcement - `resetPanelPositions()` — clears saved positions - Corner toggle buttons added to feed, legend, and node detail panel HTML - `initPanelPositions()` called during page init **`test-panel-corner.js`** (14 tests) - `nextAvailableCorner`: available, skip occupied, skip multiple, self-exclusion - `getPanelPositions`: defaults, saved values - `applyPanelPosition`: attribute setting, button update, missing element - `onCornerClick`: cycling, collision avoidance - `resetPanelPositions`: clear + restore defaults - Cycle order and default position validation ### What this does NOT include - Drag-and-drop (M1–M4) - Snap-to-edge - Z-index management - Keyboard repositioning - Any of the full drag system ### Design decisions - **`data-position` + CSS classes** over inline transforms — avoids conflict with existing show/hide `transform` animations - **Cycle (TL→TR→BR→BL)** over toggle-to-opposite — predictable, learnable - **3 panels, 4 corners** — collision avoidance is trivial, always a free corner - **Header/stats panel excluded** — it's contextual chrome, not repositionable --------- Co-authored-by: you <you@example.com> |
||
|
|
7d71dc857b |
feat: expose hopsCompleted for TRACE packets, show real path on live map (#656)
## Summary TRACE packets on the live map previously animated the **full intended route** regardless of how far the trace actually reached. This made it impossible to distinguish a completed route from a failed one — undermining the primary diagnostic purpose of trace packets. ## Changes ### Backend — `cmd/server/decoder.go` - Added `HopsCompleted *int` field to the `Path` struct - For TRACE packets, the header path contains SNR bytes (one per hop that actually forwarded). Before overwriting `path.Hops` with the full intended route from the payload, we now capture the header path's `HashCount` as `hopsCompleted` - This field is included in API responses and WebSocket broadcasts via the existing JSON serialization ### Frontend — `public/live.js` - For TRACE packets with `hopsCompleted < totalHops`: - Animate only the **completed** portion (solid line + pulse) - Draw the **unreached** remainder as a dashed/ghosted line (25% opacity, `6,8` dash pattern) with ghost markers - Dashed lines and ghost markers auto-remove after 10 seconds - When `hopsCompleted` is absent or equals total hops, behavior is unchanged ### Tests — `cmd/server/decoder_test.go` - `TestDecodePacket_TraceHopsCompleted` — partial completion (2 of 4 hops) - `TestDecodePacket_TraceNoSNR` — zero completion (trace not forwarded yet) - `TestDecodePacket_TraceFullyCompleted` — all hops completed ## How it works The MeshCore firmware appends an SNR byte to `pkt->path[]` at each hop that forwards a TRACE packet. The count of these SNR bytes (`path_len`) indicates how far the trace actually got. CoreScope's decoder already parsed the header path, but the TRACE-specific code overwrote it with the payload hops (full intended route) without preserving the progress information. Now we save that count first. Fixes #651 --------- Co-authored-by: you <you@example.com> |
||
|
|
144e98bcdf |
fix: hide hash size for zero-hop direct adverts (#649) (#653)
## Fix: Zero-hop DIRECT packets report bogus hash_size Closes #649 ### Problem When a DIRECT packet has zero hops (pathByte lower 6 bits = 0), the generic `hash_size = (pathByte >> 6) + 1` formula produces a bogus value (1-4) instead of 0/unknown. This causes incorrect hash size displays and analytics for zero-hop direct adverts. ### Solution **Frontend (JS):** - `packets.js` and `nodes.js` now check `(pathByte & 0x3F) === 0` to detect zero-hop packets and suppress bogus hash_size display. **Backend (Go):** - Both `cmd/server/decoder.go` and `cmd/ingestor/decoder.go` reset `HashSize=0` for DIRECT packets where `pathByte & 0x3F == 0` (hash_count is zero). - TRACE packets are excluded since they use hashSize to parse hop data from the payload. - The condition uses `pathByte & 0x3F == 0` (not `pathByte == 0x00`) to correctly handle the case where hash_size bits are non-zero but hash_count is zero — matching the JS frontend approach. ### Testing **Backend:** - Added 4 tests each in `cmd/server/decoder_test.go` and `cmd/ingestor/decoder_test.go`: - DIRECT + pathByte 0x00 → HashSize=0 ✅ - DIRECT + pathByte 0x40 (hash_size bits set, hash_count=0) → HashSize=0 ✅ - Non-DIRECT + pathByte 0x00 → HashSize=1 (unchanged) ✅ - DIRECT + pathByte 0x01 (1 hop) → HashSize=1 (unchanged) ✅ - All existing tests pass (`go test ./...` in both cmd/server and cmd/ingestor) **Frontend:** - Verified hash size display is suppressed for zero-hop direct adverts --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: you <you@example.com> |
||
|
|
bd54707987 |
feat: distance unit preference — km, mi, or auto (#621) (#646)
## Summary - **`app.js`**: `getDistanceUnit()`, `formatDistance(km)`, `formatDistanceRound(km)` helpers. Auto mode uses `navigator.language` — miles for `en-US`, `en-GB`, `my`, `lr`; km everywhere else. - **`customize-v2.js`**: Distance Unit preference (km / mi / auto) in Display Settings panel. Stored in `localStorage['meshcore-distance-unit']` via the existing apply pipeline. Override dot and reset work. Display tab badge counts it. - **`nodes.js`**: Neighbor table distance cell uses `formatDistance()`. - **`analytics.js`**: All rendered km values use `formatDistance()` or `formatDistanceRound()`. Column headers (`km`/`mi`) respond to the active unit. Collision classification thresholds (Local < 50 km / Regional 50–200 km / Distant > 200 km) also adapt. Default is `auto` — no change for existing users unless their locale maps to miles. ## Test plan - [x] `node test-frontend-helpers.js` — 456 passed, 0 failed (10 new formatDistance tests) - [ ] Set unit to **mi** in customize → Neighbors table shows `7.6 mi` instead of `12.3 km` - [ ] Analytics → Distance tab → stat cards, leaderboard, and column headers all show miles - [ ] Collision tool → Local/Regional/Distant thresholds show `31 mi` / `124 mi` - [ ] Route patterns popup shows miles per hop and total - [ ] Reset override dot → unit returns to auto Closes #621 🤖 Generated with [Claude Code](https://claude.ai/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: you <you@example.com> |
||
|
|
1033555d00 |
fix: resolve originLat out-of-scope ReferenceError in resolveHopPositions (#647) (#648)
## Summary - `originLat` was declared with `const` inside two block-scoped `if`/`else` branches in `resolveHopPositions` (lines 1914 and 1921) but referenced at line 1945 outside both blocks → `ReferenceError: originLat is not defined` thrown on every packet render on the live page. - Fix: introduce `senderLat` derived directly from `payload.lat`/`payload.lon` at the point of use, using the same null/zero guard as the existing declarations. ## Test plan - [x] Live page no longer shows `ReferenceError: originLat is not defined` in the console - [x] Packet path animations still render correctly for packets with GPS coords - [x] Packets without GPS coords still handled (senderLat === null, anchor not added) Closes #647 🤖 Generated with [Claude Code](https://claude.ai/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: you <you@example.com> |
||
|
|
37be3dcd1f |
fix: Prefix Tool text consistency — use 'repeaters' everywhere (#642) (#645)
## Summary Fixes remaining text inconsistencies in the Prefix Tool after #643 added the repeater filter. The Torvalds review on #643 flagged: 1. **Must-fix (already addressed in #643):** "About these numbers" text — fixed 2. **Out-of-scope:** Empty state says "No nodes" should say "No repeaters" This PR fixes ALL remaining "nodes" references in the Prefix Tool to say "repeaters": - Empty state: "No nodes in the network yet" → "No repeaters in the network yet" - Stat card label: "Total nodes" → "Total repeaters" - Region note link: "Check all nodes →" → "Check all repeaters →" - Recommendation text: "With N nodes" → "With N repeaters" Verified: zero occurrences of stale "all nodes", "Total nodes", or "No nodes" remain in the Prefix Tool section. Closes #642 Co-authored-by: you <you@example.com> |
||
|
|
2bff89a546 |
feat: deep link P1 UI states — nodes tab, packets filters, channels node panel (#536) (#618)
## Summary
- **nodes.js**: `#/nodes?tab=repeater` and `#/nodes?search=foo` — role
tab and search query are now URL-addressable; state resets to defaults
on re-navigation
- **packets.js**: `#/packets?timeWindow=60` and
`#/packets?region=US-SFO` — time window and region filter survive
refresh and are shareable
- **channels.js**: `#/channels/{hash}?node=Name` — node detail panel is
URL-addressable; auto-opens on load, URL updates on open/close
- **region-filter.js**: adds `RegionFilter.setSelected(codesArray)` to
public API (needed for URL-driven init)
All changes use `history.replaceState` (not `pushState`) to avoid
polluting browser history. URL params override localStorage on load;
localStorage remains fallback.
## Implementation notes
- Router strips query string before computing `routeParam`, so all pages
read URL params directly from `location.hash`
- `buildNodesQuery(tab, searchStr)` and `buildPacketsUrl(timeWindowMin,
regionParam)` are pure functions exposed on `window` for testability
- Region URL param is applied after `RegionFilter.init()` via a
`_pendingUrlRegion` module-level var to keep ordering explicit
- `showNodeDetail` captures `selectedHash` before the async `lookupNode`
call to avoid stale URL construction
## Test plan
- [x] `node test-frontend-helpers.js` — 459 passed, 0 failed (includes 6
`buildNodesQuery` + 5 `buildPacketsUrl` unit tests)
- [x] Navigate to `#/nodes?tab=repeater` — Repeaters tab active on load
- [x] Click a tab, verify URL updates to `#/nodes?tab=room`
- [x] Navigate to `#/packets?timeWindow=60` — time window dropdown shows
60 min
- [x] Change time window, verify URL updates
- [x] Navigate to `#/channels/{hash}` and click a sender name — URL
updates to `?node=Name`
- [x] Reload that URL — node panel re-opens
Closes #536
🤖 Generated with [Claude Code](https://claude.ai/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
dc079064f5 |
fix: clarify Hash Issues vs Prefix Tool collision data discrepancy (#643)
## Summary Hash Issues and Prefix Tool tabs showed different collision counts because the Prefix Tool was including all node types (companions, rooms, sensors) while Hash Issues correctly filtered to repeaters only. **Only repeaters matter for prefix collisions** — they're the nodes that relay packets using hash-based addressing. Non-repeater collisions are harmless noise. ## Changes 1. **Filtered Prefix Tool to repeaters only** — matches Hash Issues' scope 2. **Updated explanatory text** — both tabs now clearly state they cover repeaters 3. **Added cross-reference links** between the two tabs 4. **Added hash_size badges** in Prefix Tool results Both tabs should now agree on collision counts for each byte size. ## Review Status - ✅ Self-review - ✅ Torvalds review — caught stale 'regardless of role' text, fixed - ✅ All tests pass Fixes #642 --------- Co-authored-by: you <you@example.com> |
||
|
|
43098a0705 |
refactor: DRY hash matrix rendering in analytics.js (#419) (#640)
## Summary Fixes #419 — DRY violation in `renderHashMatrixFromServer` in analytics.js. The 1-byte and 2-byte branches shared ~80% identical HTML structure (stat cards, matrix grid, detail panel, legend, tooltip init, click handlers). This refactor extracts four shared helpers: ### New helpers | Helper | Purpose | |--------|---------| | `classifyHashCell(count, isConfirmed, isPossible)` | Unified cell classification → `{cls, bg}` | | `hashCellTd(hex, cellSize, cls, bg, count, tipHtml, fontWeight)` | Shared `<td>` element generation | | `hashTooltipHtml(hexLabel, statusText, nodesHtml)` | Tooltip HTML assembly | | `renderHashMatrixPanel(el, statCards, cellFn, detailWidth, legend, clickFn)` | Full matrix assembly pipeline | ### What changed - Both branches now call `renderHashMatrixPanel()` with branch-specific callbacks for cell rendering and detail click handling - Cell classification logic (empty → taken → possible → collision with heat scaling) is unified in `classifyHashCell()` - Tooltip and `<td>` generation consolidated — no more duplicated inline template strings - Zero behavioral changes — all existing rendering, tooltips, and click interactions are preserved ### Tests All existing tests pass (445 frontend helpers, 62 packet filter, 29 aging). Co-authored-by: you <you@example.com> |
||
|
|
2d260bbfed |
test: behavioral vscroll tests replacing source-grep (#405, #409) (#641)
## Summary Replace source-grep virtual scroll tests with behavioral tests that exercise actual logic. Fixes #405, Fixes #409. ## What changed ### packets.js - **Extracted `_calcVisibleRange()`** — pure function containing the binary-search range calculation logic previously inline in `renderVisibleRows()`. Takes offsets, scroll position, viewport dimensions, row height, thead offset, and buffer as parameters. Returns `{ startIdx, endIdx, firstEntry, lastEntry }`. - `renderVisibleRows()` now calls `_calcVisibleRange()` instead of inline math — no behavioral change. - Exported via `_packetsTestAPI` for direct testing. ### test-frontend-helpers.js - **Removed 8 source-grep tests** that used `packetsSource.includes(...)` to check strings exist in source code (not behavior): - "renderVisibleRows uses cumulative offsets not flat entry count" - "renderVisibleRows skips DOM rebuild when range unchanged" - "lazy row generation — HTML built only for visible slice" - "observer filter Set is hoisted, not recreated per-packet" - "packets.js display filter checks _children for observer match" - "packets.js WS filter checks _children for observer match" - "buildFlatRowHtml has null-safe decoded_json" - "pathHops null guard in buildFlatRowHtml / detail pane" - "destroy cleans up virtual scroll state" - **Added 11 behavioral tests for `_calcVisibleRange()`** loaded from the actual packets.js via sandbox: - Top of list (scroll = 0) - Middle of list (scroll to row 50) - Bottom of list (scroll past end) - Empty array (0 entries) - Single item - Exact row boundary - Large dataset (30K items) - Various row heights (24px instead of 36px) - Thead offset shifting visible range - Expanded groups with variable row counts - Buffer clamped at boundaries - **Kept all existing behavioral tests**: `cumulativeRowOffsets`, `getRowCount`, observer filter logic (#537). ## Test count - Removed: 8 source-grep tests - Added: 11 behavioral tests - Net: +3 tests (446 total, 0 failures) ## Why Source-grep tests (`packetsSource.includes('...')`) are brittle — they break on refactors even when behavior is preserved, and they pass even when the tested code is buggy. Behavioral tests exercise real inputs/outputs and catch actual regressions. Co-authored-by: you <you@example.com> |
||
|
|
1dd763bf44 |
feat: sortable nodes list + neighbor/observer tables (M2, #620) (#639)
## Summary Implements M2 of the table sorting spec (#620): sortable nodes list + neighbor/observer tables. ### Changes **Shared utility (`public/table-sort.js`)** - IIFE pattern, no dependencies, no build step - DOM-reorder sorting (no innerHTML rebuild) — preserves event listeners - `data-value` attributes for raw sortable values, `data-type` on `<th>` for type detection - Built-in comparators: text (`localeCompare`), number, date, dBm - `aria-sort` attributes, keyboard support (Enter/Space), sort arrows - localStorage persistence with `storageKey` option - `onSort` callback for custom re-render triggers **Nodes list table** - Wired via `TableSort.init` with `onSort` callback that triggers `renderRows()` - Keeps JS-array-level sorting for claimed/favorites pinning (TableSort can't handle pinned rows) - Replaces old `sortState`, `toggleSort()`, `sortArrow()` with TableSort controller - Test hooks preserved for backward compatibility (fallback state for non-DOM tests) **Neighbor table** - Added `data-sort` and `data-value` attributes to all columns (name, role, score, count, last_seen, distance) - Default sort: count descending - `TableSort.init` called after neighbor data renders **Observer table (full detail page)** - Converted from plain `<table>` to sortable table with data attributes - Sortable columns: observer, region, packets, avg SNR, avg RSSI - Default sort: packets descending ### Testing - 18 new unit tests for `table-sort.js` (custom DOM mock, no jsdom dependency) - All 445 existing frontend tests pass unchanged - All packet-filter (62) and aging (29) tests pass ### Note This branch includes `table-sort.js` since M1 hasn't merged yet. The utility code is identical to the M1 spec. --------- Co-authored-by: you <you@example.com> |
||
|
|
6f3e3535c9 |
feat: shared table sort utility + packets table sorting (M1, #620) (#638)
## Summary Implements M1 of the table sorting spec (#620): a shared `TableSort` utility module and integration with the packets table. ### What's included **1. `public/table-sort.js` — Shared sort utility (IIFE, no dependencies)** - `TableSort.init(tableEl, options)` — attaches click-to-sort on `<th data-sort-key="...">` elements - Built-in comparators: text (localeCompare), numeric, date (ISO), dBm (strips suffix) - NaN/null values sort last consistently - Visual: ▲/▼ `<span class="sort-arrow">` appended to active column header - Accessibility: `aria-sort="ascending|descending|none"`, keyboard support (Enter/Space) - DOM reorder via `appendChild` loop (no innerHTML rebuild) - `domReorder: false` option for virtual scroll tables (packets) - `storageKey` option for localStorage persistence - Custom comparator override per column - `onSort(column, direction)` callback - `destroy()` for clean teardown **2. Packets table integration** - All columns sortable: region, time, hash, size, HB, type, observer, path, rpt - Default sort: time descending (matches existing behavior) - Uses `domReorder: false` + `onSort` callback to sort the data array, then re-render via virtual scroll - Works with both grouped and ungrouped views - WebSocket updates respect active sort column - Sort preference persisted in localStorage (`meshcore-packets-sort`) **3. Tests — 22 unit tests (`test-table-sort.js`)** - All 4 built-in comparators (text, numeric, date, dBm) - NaN/null edge cases - Direction toggle on click - aria-sort attribute correctness - Visual indicator (▲/▼) presence and updates - onSort callback - domReorder: false behavior - destroy() cleanup - Custom comparator override ### Performance Packets table sorting works at the data array level (single `Array.sort` call), not DOM level. Virtual scroll then renders only visible rows. No new DOM nodes are created during sort — it's purely a data reorder + re-render of the existing visible window. Expected sort time for 30K packets: ~50-100ms (array sort) + existing virtual scroll render time. Closes #620 (M1) Co-authored-by: you <you@example.com> |
||
|
|
e046a6f632 |
fix: mobile accessibility — touch targets, ARIA, small viewport support (#630) (#633)
## Summary Fixes critical and major mobile accessibility items from #630, focused on small phone viewports (320px–375px). ### Critical fixes 1. **Touch targets ≥ 44px** — All interactive elements (filter buttons, tab buttons, search inputs, nav buttons, region pills, dropdowns) get `min-height: 44px; min-width: 44px` via `@media (pointer: coarse)` — desktop/mouse users are unaffected. 2. **ARIA live regions** — Added `aria-live="polite"` to: packet list (`#pktLeft`), node list (`#nodesLeft`), analytics content (`#analyticsContent`), live feed (`#liveFeed` with `role="log"`). Screen readers now announce dynamic content updates. 3. **Color-only status indicators** — Status dots in live view marked `aria-hidden="true"` (text labels like "Online"/"Degraded"/"Offline" already present alongside). 4. **Detail panel on mobile** — Side panel (`panel-right`) renders as a full-screen fixed overlay on ≤640px. Close button (✕) added to nodes detail panel. Escape key closes both nodes and packets detail panels. ### Major fixes 5. **Analytics tabs overflow** — Tabs switch to `flex-wrap: nowrap; overflow-x: auto` on ≤640px, preventing overflow on 320px screens. 6. **Table horizontal scroll** — Added `.table-scroll-wrap` class and `min-width: 480px` on `.data-table` at ≤640px for horizontal scrolling when columns don't fit. 7. **SPA focus management** — On every page navigation, focus moves to first heading (`h1`/`h2`/`h3`) or falls back to `#app`. Uses `requestAnimationFrame` for correct DOM timing. ### Bonus - Analytics tabs get `role="tablist"` + `aria-label` for screen reader semantics. ### Known follow-ups (not blocking) - Individual tab buttons should get `role="tab"` + `aria-selected` + `aria-controls` for complete ARIA tab pattern. - `sr-status-label` and `table-scroll-wrap` CSS classes are defined but not yet used in JS — ready for future use when status text labels and table wrappers are wired up. Closes #630 Co-authored-by: you <you@example.com> |
||
|
|
7cef89e07b |
fix: mobile UX improvements for channel color picker (#619) (#626)
## Summary Mobile UX fixes for the channel color picker (addresses #619). ## Changes ### Commit 1: Mobile UX improvements - **Bottom-sheet pattern on mobile**: Color picker renders as a fixed bottom sheet on touch devices (`@media (pointer: coarse)`) with `env(safe-area-inset-bottom)` for notched phones - **40px touch targets**: Swatches enlarged from default to 40×40px on mobile - **Native color picker hidden on touch**: `<input type="color">` is hidden on mobile — preset swatches only - **Scroll lock**: `document.body.style.overflow = 'hidden'` while popover is open, restored on close - **CSS context menu suppression**: `-webkit-touch-callout: none` and `user-select: none` on `.live-feed-item` - **Long-press with `passive: true`**: touchstart listener is passive to avoid scroll jank ### Commit 2: Remove preventDefault on touchstart - Removed `e.preventDefault()` from the touchstart handler — it was blocking scroll initiation on feed items - Context menu suppression handled entirely via CSS (see above) ## Desktop behavior Unchanged. All mobile-specific styles scoped under `@media (pointer: coarse)`. Desktop positioning logic unchanged. ## Review Status - ✅ Rebased onto master (no conflicts) - ✅ Self-review complete — all checklist items verified - ✅ Tufte analysis posted as comment --------- Co-authored-by: you <you@example.com> |
||
|
|
f7000992ca |
fix(rf-health): auto-scale airtime Y-axis + hover tooltips (#600) (#623)
## Summary Addresses user feedback on #600 — two improvements to RF Health detail panel charts: ### 1. Auto-scale airtime Y-axis Previously fixed 0-100% which made low-activity nodes unreadable (e.g. 0.1% TX barely visible). Now auto-scales to the actual data range with 20% headroom (minimum 1%), matching how the noise floor chart already works. ### 2. Hover tooltips on all chart data points Invisible SVG `<circle>` elements with native `<title>` tooltips on every data point across all 4 charts: - **Noise floor**: `NF: -112.3 dBm` + UTC timestamp - **Airtime**: `TX: 2.1%` or `RX: 8.3%` + UTC timestamp - **Error rate**: `Err: 0.05%` + UTC timestamp - **Battery**: `Batt: 3.85V` + UTC timestamp Uses native browser SVG tooltips — zero dependencies, accessible, no JS event handlers. ### Design rationale (Tufte) - Auto-scaling increases data-ink ratio by eliminating wasted vertical space - Tooltips provide detail-on-demand without cluttering the chart with labels on every point ### Spec update Added M2 feedback improvements section to `docs/specs/rf-health-dashboard.md`. --------- Co-authored-by: you <you@example.com> |
||
|
|
3415d3babb |
fix: measure VSCROLL_ROW_HEIGHT and theadHeight dynamically (#625)
## Summary Replaces hardcoded `VSCROLL_ROW_HEIGHT = 36` and `theadHeight = 40` in the virtual scroll logic with dynamic DOM measurement, so the values stay correct if CSS changes. ## Changes - `VSCROLL_ROW_HEIGHT`: measured once from the first rendered data row's `offsetHeight` after the initial full rebuild. Falls back to 36px until measurement occurs. - `theadHeight`: measured from the actual `<thead>` element's `offsetHeight` on every `renderVisibleRows` call. Falls back to 40px if no thead is found. - Both variables are now `let` instead of `const` to allow runtime updates. ## Performance No performance impact — both measurements are single `offsetHeight` reads (no reflow triggered since the DOM was just written). Row height measurement runs only once (guarded by `_vscrollRowHeightMeasured` flag). Thead measurement is a single property read per scroll event. Fixes #407 Co-authored-by: you <you@example.com> |
||
|
|
b587f20d1c |
feat: add distance column to neighbor table in node details (#617)
Closes #616 ## What Adds a **Distance** column to the neighbor table on the node detail page. When both the viewed node and a neighbor have GPS coordinates recorded, the table shows the haversine distance between them (e.g. `3.2 km`). When either node lacks GPS, the cell shows `—`. ## Changes **Backend** (`cmd/server/neighbor_api.go`): - Added `distance_km *float64` (omitempty) to `NeighborEntry` - In `handleNodeNeighbors`: look up source node coords from `nodeMap`, then for each resolved (non-ambiguous) neighbor with GPS, compute `haversineKm` and set the field **Frontend** (`public/nodes.js`): - Added `Distance` column header between Last Seen and Conf - Cell renders `X.X km` or `—` (muted) when unavailable **Tests** (`cmd/server/neighbor_api_test.go`): - `TestNeighborAPI_DistanceKm_WithGPS`: two nodes with real coords → `distance_km` is positive - `TestNeighborAPI_DistanceKm_NoGPS`: two nodes at 0,0 → `distance_km` is nil ## Verification Test at **https://staging.on8ar.eu** — navigate to any node detail page and scroll to the Neighbors section. Nodes with GPS coordinates show a distance; those without show `—`. |
||
|
|
382b3505dc |
feat: channel color quick-assign UI (M2, #271) (#611)
## Summary Implements M2 of channel color highlighting (#271): a right-click context menu popover for quick-assigning colors to hash channels. Builds on M1 (PR #607) which provides `ChannelColors.set/get/remove` storage primitives. ## What's new ### Color picker popover (`channel-color-picker.js`) - **Right-click** any GRP_TXT/CHAN row in the **live feed** or **packets table** → opens a color picker popover at the click point - **Long-press** (500ms) on mobile triggers the same popover - **10 preset swatches** — maximally distinct, ColorBrewer-inspired palette - **Custom hex** — native `<input type="color">` with Apply button - **Clear button** — removes color assignment (hidden when no color assigned) - **Popover positioning** — auto-adjusts to avoid viewport overflow - **Dismiss** — click outside or Escape key ### Immediate feedback - Assigning a color instantly re-styles all visible live feed items with that channel - Packets table triggers `renderVisibleRows()` via exposed `window._packetsRenderVisible` ### Wiring - Feed items store `_ccPkt` packet reference for channel extraction - Picker installed via `registerPage` init hooks in both `live.js` and `packets.js` - Single shared popover DOM element, repositioned on each open ### Styling - Dark card with border, matching existing CoreScope dropdown patterns - CSS in `style.css` under `.cc-picker-*` classes - Uses CSS variables (`--surface-1`, `--border`, `--accent`, etc.) for theme compatibility ## Files changed | File | Change | |------|--------| | `public/channel-color-picker.js` | New — popover component (IIFE, no dependencies except `ChannelColors`) | | `public/index.html` | Script tag for picker | | `public/live.js` | Store `_ccPkt` on feed items, install picker on init | | `public/packets.js` | Install picker on init, expose `_packetsRenderVisible` | | `public/style.css` | Popover CSS | | `test-channel-colors.js` | 2 new tests for picker loading and graceful degradation | ## Testing - All 21 channel-colors tests pass (19 M1 + 2 M2) - All 445 frontend-helpers tests pass - All 62 packet-filter tests pass ## Performance No hot-path impact. The popover is a single shared DOM element created lazily on first use. Context menu handlers use event delegation on the feed/table containers (one listener each, not per-row). The `refreshVisibleRows` function only iterates currently-visible DOM elements. Closes milestone M2 of #271. --------- Co-authored-by: you <you@example.com> |
||
|
|
3328ca4354 |
feat: channel color highlighting M1 — core model + feed row (#271) (#607)
## Summary Implements M1 of the [channel color highlighting spec](docs/specs/channel-color-highlighting.md) for issue #271. Allows users to assign custom highlight colors to specific hash channels. When a `GRP_TXT` packet arrives with an assigned channel color, the feed row and packets table row get: - **4px colored left border** in the assigned color - **Subtle background tint** (color at 10% opacity) ## What's included ### `public/channel-colors.js` — Storage model - `ChannelColors.get(channel)` → hex color or null - `ChannelColors.set(channel, color)` — assign a color - `ChannelColors.remove(channel)` — clear assignment - `ChannelColors.getAll()` → all assignments - `ChannelColors.getRowStyle(typeName, channel)` → inline CSS string for row highlighting - Uses `localStorage` key `live-channel-colors` - Gracefully handles corrupt/missing localStorage data ### Feed row highlighting (`public/live.js`) - Both `addFeedItem` (live WS) and `addFeedItemDOM` (replay/DB load) apply channel color styles - Reads `decoded.payload.channelName` from the packet ### Packets table highlighting (`public/packets.js`) - `buildFlatRowHtml` and `buildGroupRowHtml` apply channel color styles to `<tr>` elements - Reads channel from `getParsedDecoded(p).channel` ### Tests (`test-channel-colors.js`) - 16 unit tests covering storage CRUD, edge cases (null, empty, corrupt data), and style generation - Tests verify only GRP_TXT/CHAN types get coloring, other types are unaffected ## Design decisions - **Only GRP_TXT/CHAN packets** — other types retain default `TYPE_COLORS` styling - **Channel color takes priority** over default type colors for row highlighting - **No UI for assigning colors yet** — that's M2 (right-click context menu + color picker) - **Storage key abstracted** behind functions to ease future migration if customizer rework (#288) lands - **10% opacity tint** (`#hexcolor` + `1a` suffix) ensures readability in both dark/light modes ## Performance - `getRowStyle()` is O(1) — single localStorage read + JSON parse per call - No per-packet API calls; all data is client-side - No impact on hot rendering paths beyond one localStorage read per row render Closes #271 (M1 only — further milestones in separate PRs) --------- Co-authored-by: you <you@example.com> |
||
|
|
e42477b810 |
feat: collapsible panels + medium breakpoint on live map (#606)
## Summary Adds collapsible/minimizable UI panels on the live map page so overlay panels don't block map content on medium-sized screens. Fixes #279 ## Changes ### Collapsible Legend Panel (all screen sizes) - The legend toggle button (🎨/✕) is now visible at **all** screen sizes, not just mobile - Clicking it smoothly collapses/expands the legend with a CSS transition - Collapsed state persists in `localStorage` (`live-legend-hidden`) - Feed panel already had hide/show with localStorage — no changes needed there ### Medium Breakpoint (768px) New `@media (max-width: 768px)` rules for tablet/small laptop screens: - Feed panel: 360px → 280px wide, max-height 340px → 200px - Node detail panel: 320px → 260px wide - Legend: smaller font (10px) and tighter padding - Header: reduced gap and padding - Stats/toggles: smaller font sizes ### What's NOT changed - Mobile (≤640px): existing behavior preserved (feed/legend hidden entirely) - Desktop (>768px): no changes — panels render at full size as before ## Testing - `test-packet-filter.js`: 62 passed - `test-aging.js`: 29 passed - `test-frontend-helpers.js`: 445 passed --------- Co-authored-by: you <you@example.com> |
||
|
|
168866ecb6 |
fix: View Route on Map button works on packet detail page
The button click handler used document.getElementById() which fails on /packet/[ID] pages because renderDetail() runs before the container is appended to the DOM. Changed to panel.querySelector() which searches within the detached element tree. Fixes #601 |