mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 15:41:25 +00:00
1bfbbd6bb2f3fdb10cfee461dbf16bce7d34da1f
82 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7342166f0a |
feat(nodes): add sortable Scope column to nodes list (#1195)
## Summary - Adds a **Scope** column to the nodes list table, positioned after Role - Shows `default_scope` for nodes that have one (populated from scoped ADVERT packets, landed in #899), empty for the rest - Column is sortable (alphabetical); hidden on narrow screens (`data-priority="3"`, same as Public Key) ## Test Plan - [x] `node test-frontend-helpers.js` — all existing tests pass, two new sort tests added (`sortNodes sorts by default_scope asc/desc`) - [x] Open `/nodes` — Scope column visible between Role and Last Seen - [x] Nodes with a known scope show the value in monospace; nodes without show an empty cell - [x] Click Scope header → sorts ascending; click again → sorts descending - [x] Empty-scope rows go to the bottom on asc, top on desc - [x] Narrow the browser → Scope column hides at the same breakpoint as Public Key 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
1ca665efde |
docs: document removal of 15 prefix helper tests (fixes #437) (#999)
## Summary Documents the removal of 15 prefix helper tests (`buildOneBytePrefixMap`, `buildTwoBytePrefixInfo`, `buildCollisionHops`) from `test-frontend-helpers.js`. These functions were moved server-side in PR #415. The equivalent logic is now covered by Go tests: - `cmd/server/collision_details_test.go` — collision prefix + node-pair assertions - `cmd/server/store_test.go` — hash-collision endpoint integration Adds a documentation comment in the test file where the tests previously lived, explaining the rationale and pointing to the Go test equivalents. Fixes #437 --------- Co-authored-by: you <you@example.com> |
||
|
|
736b09697d |
fix(analytics): apply customizer timestamp format to chart axes (closes #756) (#981)
## Summary Fixes #756 — the customizer timestamp format setting (ISO/ISO+ms/locale) and timezone (UTC/local) were not applied to chart X-axis labels, tooltips, or certain inline timestamps in the analytics pages. ## Changes ### `public/app.js` - Added `formatChartAxisLabel(date, shortForm)` — a shared helper that reads the customizer's `timestampFormat` and `timestampTimezone` preferences and formats dates for chart axes accordingly. `shortForm=true` returns time-only (for intra-day charts), `shortForm=false` returns date+time (for multi-day ranges). ### `public/analytics.js` - `rfXAxisLabels()`: now calls `formatChartAxisLabel()` instead of hardcoded `toLocaleTimeString()` - `rfTooltipCircles()`: tooltip timestamps now use `formatAbsoluteTimestamp()` instead of raw ISO - Subpath detail first/last seen: now uses `formatAbsoluteTimestamp()` - Neighbor graph last_seen: now uses `formatAbsoluteTimestamp()` ### `public/node-analytics.js` - Packet timeline chart labels: now use `formatChartAxisLabel()` (respects short vs long form based on time range) - SNR over time chart labels: now use `formatChartAxisLabel()` ## Behavior by setting | Setting | Chart axis (short) | Chart axis (long) | |---------|-------------------|-------------------| | ISO | `14:30` | `05-03 14:30` | | ISO+ms | `14:30:05` | `05-03 14:30:05` | | Locale | `2:30 PM` | `May 3, 2:30 PM` | All respect the UTC/local timezone toggle. ## Testing - Server builds cleanly (`go build`) - Served `app.js` contains `formatChartAxisLabel` (verified via curl) - Graceful fallback: all callsites check `typeof formatChartAxisLabel === 'function'` before calling, preserving backward compat if script load order changes --------- Co-authored-by: you <you@example.com> |
||
|
|
6ca5e86df6 |
fix: compute hex-dump byte ranges client-side from per-obs raw_hex (#891)
## Symptom The colored byte strip in the packet detail pane is offset from the labeled byte breakdown below it. Off by N bytes where N is the difference between the top-level packet's path length and the displayed observation's path length. ## Root cause Server computes `breakdown.ranges` once from the top-level packet's raw_hex (in `BuildBreakdown`) and ships it in the API response. After #882 we render each observation's own raw_hex, but we keep using the top-level breakdown — so a 7-hop top-level packet shipped "Path: bytes 2-8", and when we rendered an 8-hop observation we coloured 7 of the 8 path bytes and bled into the payload. The labeled rows below (which use `buildFieldTable`) parse the displayed raw_hex on the client, so they were correct — they just didn't match the strip above. ## Fix Port `BuildBreakdown()` to JS as `computeBreakdownRanges()` in `app.js`. Use it in `renderDetail()` from the actually-rendered (per-obs) raw_hex. ## Test Manually verified the JS function output matches the Go implementation for FLOOD/non-transport, transport, ADVERT, and direct-advert (zero hops) cases. Closes nothing (caught in post-tag bug bash). --------- Co-authored-by: you <you@example.com> |
||
|
|
2b9f305698 |
fix(#874): hop-resolver affinity picker — score candidates by neighbor-graph edges + geographic centroid (#876)
## Problem `pickByAffinity` in `hop-resolver.js` picks wrong regional candidates when 1-byte pubkey prefixes collide. The old implementation only considers one adjacent hop (forward OR backward pass), leading to suboptimal picks when both neighbors provide useful context. Measured on staging: **61.6% of hops have ≥2 same-prefix candidates**, making collision resolution critical. ## Fix Replaced the separate forward/backward pass disambiguation with a **combined iterative resolver** that scores candidates against BOTH prev and next resolved hops: 1. **Neighbor-graph edge weight** (priority 1): Sum edge scores to prev + next pubkeys. Pick max sum. 2. **Geographic centroid** (priority 2): Average lat/lon of prev + next positions. Pick closest candidate by haversine distance. 3. **Single-anchor geo** (priority 3): When only one neighbor is resolved, use it directly. 4. **Fallback** (priority 4): First candidate when no context exists. The iterative approach resolves cascading dependencies — resolving one ambiguous hop may unlock context for its neighbors. ### Dev-mode trace Multi-candidate picks now emit: `[hop-resolver] hash=46 candidates=N scored=[...] chose=<pubkey> method=graph|centroid|fallback` ## Before/After (staging, 1539 packets, 12928 hops) | Metric | Before | After | |--------|--------|-------| | Unreliable hops | 39 (0.3%) | 23 (0.2%) | | Packets with unreliable | 33 (2.14%) | 17 (1.10%) | ~41% reduction in unreliable hops, ~48% reduction in affected packets. ## Tests 5 new tests in `test-frontend-helpers.js`: - Graph edge scoring picks correct regional candidate - Next hop breaks tie when prev has no edges - Centroid fallback when no graph edges exist - Centroid uses average of prev+next positions - Fallback when no context at all All 595 tests pass. No regressions in `test-packet-filter.js` (62 pass) or `test-aging.js` (29 pass). Closes #874 --------- Co-authored-by: you <you@example.com> |
||
|
|
1d449eabc7 |
fix(#872): replace strikethrough with warning badge on unreliable hops (#875)
## Problem The `hop-unreliable` CSS class applied `text-decoration: line-through` and `opacity: 0.5`, making hop names look "dead" to operators. This caused confusion — the repeater itself is fine, only the name→hash assignment is uncertain. ## Fix - **CSS**: Removed `line-through` and heavy opacity from `.hop-unreliable`. Kept subtle `opacity: 0.85` for scanability. Added `.hop-unreliable-btn` style for the new badge. - **JS**: Added a `⚠️` warning badge button next to unreliable hops (similar pattern to existing conflict badges). The badge is always visible, keyboard-focusable, and has both `title` and `aria-label` with an informative tooltip explaining geographic inconsistency. - **Tests**: Added 2 tests in `test-frontend-helpers.js` asserting the badge renders for unreliable hops and does NOT render for reliable ones, and that no `line-through` is present. ### Before → After | Before | After | |--------|-------| | ~~NodeName~~ (struck through, 50% opacity) | NodeName ⚠️ (normal text, small warning badge with tooltip) | ## Scope Resolver logic untouched — #873 covers threshold tuning, #874 covers picker correctness. No candidate-dropdown UX (follow-up per issue discussion). Closes #872 Co-authored-by: you <you@example.com> |
||
|
|
42ff5a291b |
fix(#866): full-page obs-switch — update hex + path + direction per observation (#870)
## Problem On `/#/packets/<hash>?obs=<id>`, clicking a different observation updated summary fields (Observer, SNR/RSSI, Timestamp) but **not** hex payload or path details. Sister bug to #849 (fixed in #851 for the detail dialog). ## Root Causes | Cause | Impact | |-------|--------| | `selectPacket` called `renderDetail` without `selectedObservationId` | Initial render missed observation context on some code paths | | `ObservationResp` missing `direction`, `resolved_path`, `raw_hex` | Frontend obs-switch lost direction and resolved_path context | | `obsPacket` construction omitted `direction` field | Direction not preserved when switching observations | ## Fix - `selectPacket` explicitly passes `selectedObservationId` to `renderDetail` - `ObservationResp` gains `Direction`, `ResolvedPath`, `RawHex` fields - `mapSliceToObservations` copies the three new fields - `obsPacket` spreads include `direction` from the observation ## Tests 7 new tests in `test-frontend-helpers.js`: - Observation switch updates `effectivePkt` path - `raw_hex` preserved from packet when obs has none - `raw_hex` from obs overrides when API provides it - `direction` carried through observation spread - `resolved_path` carried through observation spread - `getPathLenOffset` cross-check for transport routes - URL hash `?obs=` round-trip encoding All 584 frontend + 62 filter + 29 aging tests pass. Go server tests pass. Fixes #866 Co-authored-by: you <you@example.com> |
||
|
|
c99aa1dadf |
fix(#855, #856, #857) + feat(#862): /nodes detail panel + search improvements (#868)
## Summary Four related `/nodes` page fixes batched to avoid merge conflicts (all touch `public/nodes.js`). --- ### #855 — "Show all neighbors" link doesn't expand **Problem:** The "View all N neighbors →" link in the side panel navigated to the full detail page instead of expanding the truncated list inline. **Fix:** Replaced navigation link with an inline "Show all N neighbors ▼" button that re-renders the neighbor table without the limit. **Acceptance:** Click the button → all neighbors appear in the same panel without page navigation. Closes #855 --- ### #856 — "Details" button is a no-op **Problem:** The "🔍 Details" link in the side panel was an `<a>` tag whose `href` matched the current hash (set by `replaceState`), making clicks a same-hash no-op. **Fix:** Changed from `<a>` link to a `<button>` with a direct click handler that sets `location.hash`, ensuring the router always fires. **Acceptance:** Click "🔍 Details" → navigates to full-screen node detail view. Closes #856 --- ### #857 — Recent Packets shows bullets but no content **Problem:** The "Recent Packets (N)" section could render entries with missing `hash` or `timestamp`, producing colored dots with no meaningful content beside them. **Fix:** Added `.filter(a => a.hash && a.timestamp)` before rendering, and updated the count header to reflect filtered entries only. **Acceptance:** Recent Packets section only shows entries with valid data; count matches visible items. Closes #857 --- ### #862 — Pubkey prefix search on /#/nodes **Problem:** Search box only matched node names. Operators couldn't search by pubkey prefix. **Fix:** Extended search to detect hex-only queries (`/^[0-9a-f]+$/i`) and match them against pubkey prefix (`startsWith`). Non-hex queries continue matching name as before. Both are composable in the same input. **Acceptance:** - Typing `3f` filters to nodes whose pubkey starts with `3f` - Typing `foo` still filters by name - Search placeholder updated to indicate pubkey support 5 new unit tests added for the search matching logic. Closes #862 --------- Co-authored-by: you <you@example.com> |
||
|
|
3630a32310 |
fix(#852): transport-route path_len offset + var(--muted) → var(--text-muted) (#853)
## Problem Two pre-existing bugs found during expert review of #851: ### 1. `hashSize` derivation ignores transport route types `public/packets.js` hardcoded path-length byte at offset 1: ```js const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(2, 4), 16) : NaN; ``` For transport routes (`route_type` 0 DIRECT or 3 TRANSPORT_ROUTE_FLOOD), bytes 1–4 are `next_hop` + `last_hop` and path-length is at offset 5. Same bug #846 fixed inside the byte-breakdown function. ### 2. `var(--muted)` CSS variable is undefined Used in 6 places in `public/packets.js`. No `--muted` variable is defined anywhere in `public/*.css` — only `--text-muted` exists. Text styled with `var(--muted)` silently falls through to inherited color, making badges/hints invisible. ## Fix ### Fix 1: transport-route path_len offset ```js const plOff = (pkt.route_type === 0 || pkt.route_type === 3) ? 5 : 1; const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(plOff * 2, plOff * 2 + 2), 16) : NaN; ``` ### Fix 2: `var(--muted)` → `var(--text-muted)` All 6 occurrences replaced. ## Tests (5 new, 572 total) - `hashSize` extraction for flood route (route_type=1, offset 1) - `hashSize` extraction for direct transport route (route_type=0, offset 5) - `hashSize` extraction for transport route flood (route_type=3, offset 5) - `hashSize` returns null for missing raw_hex - Regression guard: no `var(--muted)` in any `public/` JS/CSS file ## Changes - `public/packets.js`: 7 lines changed (1 offset fix + 6 CSS var fixes) - `test-frontend-helpers.js`: 46 lines added (5 tests) Closes #852 --------- Co-authored-by: you <you@example.com> |
||
|
|
441409203e |
feat(#845): bimodal_clock severity — surface flaky-RTC nodes instead of hiding as 'No Clock' (#850)
## Problem Nodes with flaky RTC (firmware emitting interleaved good and nonsense timestamps) were classified as `no_clock` because the broken samples poisoned the recent median. Operators lost visibility into these nodes — they showed "No Clock" even though ~60% of their adverts had valid timestamps. Observed on staging: a node with 31K samples where recent adverts interleave good skew (-6.8s, -13.6s) with firmware nonsense (-56M, -60M seconds). Under the old logic, median of the mixed window → `no_clock`. ## Solution New `bimodal_clock` severity tier that surfaces flaky-RTC nodes with their real (good-sample) skew value. ### Classification order (first match wins) | Severity | Good Fraction | Description | |----------|--------------|-------------| | `no_clock` | < 10% | Essentially no real clock | | `bimodal_clock` | 10–80% (and bad > 0) | Mixed good/bad — flaky RTC | | `ok`/`warn`/`critical`/`absurd` | ≥ 80% | Normal classification | "Good" = `|skew| <= 1 hour`; "bad" = likely uninitialized RTC nonsense. When `bimodal_clock`, `recentMedianSkewSec` is computed from **good samples only**, so the dashboard shows the real working-clock value (e.g. -7s) instead of the broken median. ### Backend changes - New constant `BimodalSkewThresholdSec = 3600` - New severity `bimodal_clock` in classification logic - New API fields: `goodFraction`, `recentBadSampleCount`, `recentSampleCount` ### Frontend changes - Amber `Bimodal` badge with tooltip showing bad-sample percentage - Bimodal nodes render skew value like ok/warn/severe (not the "No Clock" path) - Warning line below sparkline: "⚠️ X of last Y adverts had nonsense timestamps (likely RTC reset)" ### Tests - 3 new Go unit tests: bimodal (60% good → bimodal_clock), all-bad (→ no_clock), 90%-good (→ ok) - 1 new frontend test: bimodal badge rendering with tooltip - Existing `TestReporterScenario_789` passes unchanged Builds on #789 (recent-window severity). Closes #845 --------- Co-authored-by: you <you@example.com> |
||
|
|
7c01a97178 |
fix(#849): Packet Detail dialog — show exact clicked observation, not cross-observer aggregate (#851)
## Problem The Packet Detail dialog summary (Observer, Path, Hops, SNR/RSSI, Timestamp) used the **aggregated cross-observer view** (`_parsedPath` / `getParsedPath(pkt)`), which contradicted the byte breakdown after #844. A packet observed with 2 hops by one observer would show "Path: 7 hops" in the summary because it merged all observers' paths. ## Fix The dialog is now **per-observation**: - `renderDetail` resolves a `currentObservation` from `selectedObservationId` (set when clicking an observation child row) or defaults to `observations[0]` - All summary fields read from the current observation: Observer, SNR/RSSI, Timestamp, Path, Direction - Hop count badge comes from `path_len & 0x3F` of the observation's `raw_hex` (firmware truth, same source as byte breakdown). Cross-checked against `path_json` length — logs a console warning on mismatch - **Observations table** rendered inside the detail panel when multiple observations exist. Clicking a row updates `currentObservation` and re-renders the summary in-place (no dialog close/reopen) - `.observation-current` CSS class highlights the selected observation row ### Cross-observer aggregate (Option B) A read-only "Cross-observer aggregate" section below the observations table shows the longest observed path across all observers. This is **not** the default view — it's always visible as secondary context. ## Tests 8 new tests in `test-frontend-helpers.js`: - Hop count extraction from raw_hex (normal, direct, transport route types) - Inconsistency detection between path_json and raw_hex - Per-observation field override of aggregated packet fields - First observation used when no specific observation selected - Observation row click selects that observation - Null/missing raw_hex handling All 572 tests pass (564 frontend + 62 filter + 29 aging). ## Acceptance - Summary shows per-observation path/hops/SNR/RSSI/timestamp - Switching observations in the detail updates everything - Cross-observer aggregate available as secondary section - Byte breakdown untouched (owned by #846) ## Related - Closes #849 - Related: #844 (#846) — byte breakdown fix (separate PR, different code region) --------- Co-authored-by: you <you@example.com> |
||
|
|
f1eea9ee3c |
fix(#844): Packet Byte Breakdown — derive hop count from path_len, not aggregated _parsedPath (#846)
## Problem The Packet Detail dialog's "Packet Byte Breakdown" section was using the aggregated `_parsedPath` (longest path observed across all observers) to render hop entries, instead of deriving the hop count from the `path_len` byte in `raw_hex`. This caused: - Wrong hop count (e.g., "Path (7 hops)" when `raw_hex` only contains 2) - Hop values from the aggregated path displayed at incorrect byte offsets - Subsequent fields (pubkey, timestamp, signature) rendered at wrong offsets because `off` was advanced by the wrong amount ## Fix In `buildFieldTable()` (packets.js), the Path section now: 1. Derives `hashCountVal` from `path_len & 0x3F` (firmware truth per `Packet.h:79-83`) 2. Derives `hashSize` from `(path_len >> 6) + 1` 3. Reads each hop's hex value directly from `raw_hex` at the correct byte offset 4. Advances `off` by `hashSize * hashCountVal` 5. Skips the Path section entirely when `hashCountVal === 0` (direct advert) The "Path" summary section above the breakdown (which uses the aggregated path for route visualization) is unchanged — only the byte breakdown is fixed. ## Tests 3 new tests in `test-frontend-helpers.js`: - Verifies 2 hops rendered (not 7) when `path_len=0x42` despite 7-hop aggregated path - Verifies pubkey offset is 6 (not 16) after a 2-hop path - Verifies direct advert (`hashCount=0`) skips Path section Also fixed pre-existing `HopDisplay is not defined` failures in the `#765` transport offset test sandbox (added mock). All 559 tests pass. Closes #844 --------- Co-authored-by: you <you@example.com> |
||
|
|
bb0f816a6b |
fix(channels): only show lock for confirmed-encrypted #channel deep links (#825) (#826)
Closes #825 ## Root cause PR #815 added a `#`-prefix branch in `selectChannel` that unconditionally rendered the lock affordance whenever the channel object wasn't in the loaded `channels` list. With the encrypted toggle off, unencrypted channels like `#test` are also absent from the list, so the new branch wrongly locked them instead of falling through to the REST fetch. ## Fix When no stored key matches, refetch `/channels?includeEncrypted=true` and check `ch.encrypted` before locking. Only render the lock when we positively know the channel is encrypted; otherwise fall through to the existing REST messages fetch. This regresses #815's behavior **only for the unencrypted case** (which is the bug). The encrypted-no-key (#811) and encrypted-with-stored-key (#815) paths are preserved. ## Tests 3 new regression tests in `test-frontend-helpers.js`: - `#test` (unencrypted) deep link → REST fetched, no lock - `#private` (encrypted, no key) deep link → lock, no REST (#811 preserved) - `#private` (encrypted, with stored key) deep link → decrypt path (#815 preserved) `node test-frontend-helpers.js` → 556 passed, 0 failed. ## Perf One extra REST call per cold deep link to a `#`-named channel that's not in the toggle-off list — same endpoint already cached via `CLIENT_TTL.channels`, so subsequent navigations are free. --------- Co-authored-by: you <you@example.com> |
||
|
|
b8846c2db2 |
fix: show lock message for encrypted channels without key on deep link (#783)
## Problem Deep-linking to an encrypted channel (e.g. `#/channels/42`) when the user has no client-side decryption key falls through to the plaintext API fetch, displaying gibberish base64/binary content instead of a meaningful message. ## Root Cause In `selectChannel()`, the encrypted channel key-matching loop iterates all stored keys. If none match, execution falls through to the normal plaintext message fetch — which returns raw encrypted data rendered as gibberish. ## Fix After the key-matching loop for encrypted channels, return early with the lock message instead of falling through. **3 lines added** in `public/channels.js`, **108 lines** regression test in `test-frontend-helpers.js`. ## Investigation: Sidebar Display The sidebar filtering is already correct: - DB path: SQL filters out `enc_` prefix channel hashes - In-memory path: Only returns `type: CHAN` (server-decrypted) channels, with `hasGarbageChars` validation - Server-side decryption: MAC verification (2-byte HMAC) + UTF-8 + non-printable character validation prevents false-positive decryptions - Encrypted channels only appear when the toggle is explicitly enabled ## Testing - All existing tests pass - New regression test verifies: lock message shown, messages API NOT called for encrypted channels without key Fixes #781 --------- 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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
77b7c33d0f |
perf: incremental DOM diff in renderVisibleRows (#414) (#596)
## Summary - Replace full \`tbody\` teardown+rebuild on every scroll frame with a range-diff that only adds/removes the delta rows at the edges of the visible window - \`buildFlatRowHtml\` / \`buildGroupRowHtml\` now accept an \`entryIdx\` parameter and emit \`data-entry-idx\` on every \`<tr>\` so the diff can target rows precisely (including expanded group children) - Full rebuild is retained for initial render and large scroll jumps past the buffer (no range overlap) - Also loads \`packet-helpers.js\` in the test sandbox, fixing 7 pre-existing test failures for the builder functions; adds 4 new tests covering \`data-entry-idx\` output Fixes #414 ## Test plan - [x] Open packets page with 500+ packets, scroll rapidly — DOM inspector should show incremental \`<tr>\` adds/removes rather than full \`tbody\` teardown - [x] Expand a grouped packet, scroll away and back — expanded children re-render correctly - [x] Large scroll jump (jump to bottom via scrollbar) — full rebuild fires, no visual glitch - [x] \`node test-packets.js\` — 72 passed, 0 failed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: you <you@example.com> |
||
|
|
7ff89d8607 |
perf(packets): coalesce WS-triggered renders with requestAnimationFrame (#585)
## Summary Coalesce WS-triggered `renderTableRows()` calls using `requestAnimationFrame` instead of `setTimeout` debouncing. Fixes #396 ## Problem During high WebSocket throughput, multiple WS batches could each trigger a `renderTableRows()` call via `setTimeout(..., 200)`. With rapid batches, this caused the 50K-row table to be fully rebuilt every few hundred milliseconds, causing UI jank. ## Solution Replace the `setTimeout`-based debounce with a `requestAnimationFrame` coalescing pattern: 1. **`scheduleWSRender()`** — sets a dirty flag and schedules a single rAF callback 2. **Dirty flag** — multiple WS batches within the same frame just set the flag; only one render fires 3. **Cleanup** — `destroy()` cancels any pending rAF and resets the dirty flag This ensures at most **one `renderTableRows()` per animation frame** (~16ms), regardless of how many WS batches arrive. ## Performance justification - **Before:** Each WS batch → `setTimeout(renderTableRows, 200)` — N batches in <200ms = N renders - **After:** N batches in one frame → 1 render on next rAF (~16ms) - Worst case goes from O(N) renders per second to O(60) renders per second (frame-capped) ## Changes - `public/packets.js`: Add `scheduleWSRender()` with rAF + dirty flag; replace setTimeout in WS handler; clean up in `destroy()` - `test-frontend-helpers.js`: Update tests to verify rAF coalescing pattern instead of setTimeout debounce ## Testing - All existing tests pass (`npm test` — 0 failures) - Updated 2 test cases to verify new rAF coalescing behavior Co-authored-by: you <you@example.com> |
||
|
|
a97fa52f10 |
feat: frontend consumers prefer resolved_path (M4, #555) (#561)
## Summary Implements **M4 (frontend consumers)** from the [resolved-path spec](https://github.com/Kpa-clawbot/CoreScope/blob/resolved-path-spec/docs/specs/resolved-path.md) for #555. The server (PR #556, M1-M3) now returns `resolved_path` on all packet/observation API responses and WebSocket broadcasts. This PR updates all frontend consumers to **prefer `resolved_path`** over client-side HopResolver, with full fallback for old packets. ## What changed ### `hop-resolver.js` - Added `resolveFromServer(hops, resolvedPath)` — takes the short hex prefixes and aligned array of full pubkeys from `resolved_path`, looks up node names from the existing nodesList. Returns the same `{ [hop]: { name, pubkey, ... } }` format as `resolve()`. ### `packet-helpers.js` - Added `getResolvedPath(p)` — cached JSON parser for the new `resolved_path` field (mirrors `getParsedPath`). - Updated `clearParsedCache()` to also clear `_parsedResolvedPath`. ### `packets.js` - **Bulk load** (`loadPackets`): calls `cacheResolvedPaths(packets)` before the existing `resolveHops` fallback. - **WebSocket updates**: pre-populates `hopNameCache` from `resolved_path` on incoming packets before falling back to HopResolver for any remaining unknown hops. - **Group expansion** (`pktToggleGroup`): caches resolved paths from child observations. - **Packet detail** (`selectPacket`): prefers `resolveFromServer` when `resolved_path` is available. - **Show Route button**: uses `resolved_path` pubkeys directly instead of client-side disambiguation. - **Observation spreading**: carries `resolved_path` field when constructing observation packets. ### `live.js` - `resolveHopPositions` accepts optional `resolvedPath` parameter; prefers server-resolved pubkeys, falls back to HopResolver for null entries. - Normalized WS packet objects now carry `resolved_path`. ### Files NOT changed (no resolution changes needed) - **`analytics.js`** — only uses `HopResolver.haversineKm` (a utility function). Topology, subpath, and hop distance data comes pre-resolved from the server API (handled by M2/M3). - **`nodes.js`** — gets pre-resolved path data from `/nodes/:pubkey/paths` API; no client-side hop resolution. - **`map.js`** — `drawPacketRoute` already handles full 64-char pubkeys via exact match. The updated `packets.js` now passes full pubkeys from `resolved_path` to the map. ## Fallback pattern ```javascript // In hop-resolver.js function resolveFromServer(hops, resolvedPath) { // Returns resolved entries for non-null pubkeys // Skips null entries (unresolved) — caller falls back to HopResolver } // In packets.js — bulk load await cacheResolvedPaths(packets); // server-side first await resolveHops([...allHops]); // client-side fallback for remaining ``` Old packets without `resolved_path` continue to work exactly as before via the existing HopResolver. `hop-resolver.js` is NOT removed — it remains the fallback. ## Tests - 10 new tests for `resolveFromServer()` and `getResolvedPath()` - All 445 frontend helper tests pass - All 62 packet filter tests pass - All 29 aging tests pass Closes #555 (M4 milestone) --------- Co-authored-by: you <you@example.com> |
||
|
|
c34744247a |
fix: clean up nodeActivity in pruneStaleNodes to prevent memory leak (#553)
## Summary `nodeActivity` (an object tracking per-node packet counts for heatmap intensity) grows without bound — entries are added on every packet flash but never removed, even when stale nodes are pruned. ## Changes - **Delete `nodeActivity[key]`** alongside `nodeMarkers[key]` and `nodeData[key]` when removing stale WS-only nodes in `pruneStaleNodes()` - **Prune orphaned entries** — after the main prune loop, sweep `nodeActivity` and delete any key that has no corresponding `nodeData` entry (catches edge cases where nodes were removed by other code paths) - Both run every 60s via the existing `pruneStaleNodes` interval timer ## Testing - Added 2 regression tests in `test-frontend-helpers.js` verifying stale node cleanup and orphan removal - All 435 frontend helper tests pass, plus packet-filter (62) and aging (29) Fixes #390 --------- Co-authored-by: you <you@example.com> |
||
|
|
03e384bbc4 |
fix: null guard on pathHops prevents crash on ADVERT detail (#538) (#540)
## Summary Fixes #538 — `null is not an object (evaluating 'pathHops.length')` crash on ADVERT packet detail. ## Root Cause `getParsedPath` caches its result as `p._parsedPath`. If another code path (e.g., object spread, API response) sets `_parsedPath = null`, the cache check (`!== undefined`) passes and returns `null` — causing `.length` to crash. Same pattern exists for `getParsedDecoded`. ## Changes ### `public/packet-helpers.js` - `getParsedPath`: cached return now uses `|| []` to guard against null cache - `getParsedDecoded`: cached return now uses `|| {}` to guard against null cache ### `public/packets.js` - `renderDetail()` (line ~1440): defensive `|| []` / `|| {}` on getParsedPath/getParsedDecoded calls - `buildFlatRowHtml()` (line ~1103): same defensive guards ### `test-frontend-helpers.js` - Added test: cached `_parsedPath = null` returns `[]` - Added test: cached `_parsedDecoded = null` returns `{}` ## Testing All 428 frontend helper tests pass. All 62 packet filter tests pass. Co-authored-by: you <you@example.com> |
||
|
|
bf8c9e72ec |
fix: observer filter checks all observations in grouped mode (#537) (#539)
Fixes #537 ## Problem Observer filter in grouped mode only checked `p.observer_id` (the primary observer), ignoring child observations. Grouped packets seen by multiple observers would be hidden when filtering for a non-primary observer. ## Fix Two filter paths updated to also check `p._children`: 1. **Client-side display filter** (line ~1293): removed the `!groupByHash` guard and added `_children` check so grouped packets are included when any child observation matches 2. **WS real-time filter** (line ~360): added `_children` fallback check The grouped row rendering (line ~1042) already correctly uses `_observerFilterSet` for child filtering — no changes needed there. ## Tests Added 5 tests in `test-frontend-helpers.js`: - Grouped packet with matching child observer is shown - Grouped packet with no matching observers is hidden - WS filter passes/rejects grouped packets correctly - Source code assertions verifying both filter paths check `_children` Co-authored-by: you <you@example.com> |
||
|
|
54fab0551e |
fix: add home defaults to server theme config (#525) (#526)
## Summary Fixes #525 — Customizer v2 home section shows empty fields and adding FAQ kills steps. ## Root Cause Server returned `home: null` from `/api/config/theme` when no home config existed in config.json or theme.json. The customizer had no built-in defaults, so all home fields appeared empty. When a user added a single override (e.g. FAQ), `computeEffective` started from `home: null`, created `home: {}`, and only applied the user's override — wiping steps and everything else. ## Fix ### Server-side (primary) In `handleConfigTheme()`, replaced the conditional `home` assignment with `mergeMap` using built-in defaults matching what `home.js` hardcodes: - `heroTitle`: "CoreScope" - `heroSubtitle`: "Real-time MeshCore LoRa mesh network analyzer" - `steps`: 4 default getting-started steps - `footerLinks`: Packets + Network Map links Config/theme overrides merge on top, so customization still works. ### Client-side (defense-in-depth) Added `DEFAULT_HOME` constant in `customize-v2.js`. `computeEffective()` now falls back to these defaults when server returns `home: null`, ensuring the customizer works even without server defaults. ## Tests - **Go**: `TestConfigThemeHomeDefaults` — verifies `/api/config/theme` returns non-null home with heroTitle, steps, footerLinks when no config is set - **JS**: Two new tests in `test-frontend-helpers.js` — verifies `computeEffective` provides defaults when home is null, and that user overrides merge correctly with defaults Co-authored-by: you <you@example.com> |
||
|
|
64745f89b1 |
feat: customizer v2 — event-driven state management (#502) (#503)
## Summary Implements the customizer v2 per the [approved spec](docs/specs/customizer-rework.md), replacing the v1 customizer's scattered state management with a clean event-driven architecture. Resolves #502. ## What Changed ### New: `public/customize-v2.js` Complete rewrite of the customizer as a self-contained IIFE with: - **Single localStorage key** (`cs-theme-overrides`) replacing 7 scattered keys - **Three state layers:** server defaults (immutable) → user overrides (delta) → effective config (computed) - **Full data flow pipeline:** `write → read-back → merge → atomic SITE_CONFIG assign → apply CSS → dispatch theme-changed` - **Color picker optimistic CSS** (Decision #12): `input` events update CSS directly for responsiveness; `change` events trigger the full pipeline - **Override indicator dots** (●) on each field — click to reset individual values - **Section-level override count badges** on tabs - **Browser-local banner** in panel header: "These settings are saved in your browser only" - **Auto-save status indicator** in footer: "All changes saved" / "Saving..." / "⚠️ Storage full" - **Export/Import** with full shape validation (`validateShape()`) - **Presets** flow through the standard pipeline (`writeOverrides(presetData) → pipeline`) - **One-time migration** from 7 legacy localStorage keys (exact field mapping per spec) - **Validation** on all writes: color format, opacity range, timestamp enum values - **QuotaExceededError handling** with visible user warning ### Modified: `public/app.js` Replaced ~80 lines of inline theme application code with a 15-line `_customizerV2.init(cfg)` call. The customizer v2 handles all merging, CSS application, and global state updates. ### Modified: `public/index.html` Swapped `customize.js` → `customize-v2.js` script tag. ### Added: `docs/specs/customizer-rework.md` The full approved spec, included in the repo for reference. ## Migration On first page load: 1. Checks if `cs-theme-overrides` already exists → skip if yes 2. Reads all 7 legacy keys (`meshcore-user-theme`, `meshcore-timestamp-*`, `meshcore-heatmap-opacity`, `meshcore-live-heatmap-opacity`) 3. Maps them to the new delta format per the spec's field-by-field mapping 4. Writes to `cs-theme-overrides`, removes all legacy keys 5. Continues with normal init Users with existing customizations will see them preserved automatically. ## Dark/Light Mode - `theme` section stores light mode overrides, `themeDark` stores dark mode overrides - `meshcore-theme` localStorage key remains **separate** (view preference, not customization) - Switching modes re-runs the full pipeline with the correct section ## Testing - All existing tests pass (`test-packet-filter.js`, `test-aging.js`, `test-frontend-helpers.js`) - Old `customize.js` is NOT modified — left in place for reference but no longer loaded ## Not in Scope (per spec) - Undo/redo stack - Cross-tab synchronization - Server-side admin import endpoint - Map config / geo-filter overrides --------- Co-authored-by: you <you@example.com> |
||
|
|
ad97c0fdd1 |
fix: clear stale parsed cache on observation packets (#505)
## Summary Fixes #504 — Expanding a packet in the packets UI showed the same path on every observation instead of each observation's unique path. ## Root Cause PR #400 (fixing #387) added caching of `JSON.parse` results as `_parsedPath` and `_parsedDecoded` properties on packet objects. When observation packets are created via object spread (`{...parentPacket, ...obs}`), these cache properties are copied from the parent. Subsequent calls to `getParsedPath(obsPacket)` hit the stale cache and return the parent's path, ignoring the observation's own `path_json`. ## Fix After every object spread that creates an observation packet from a parent packet, delete the cache properties so they get re-parsed from the observation's own data: ```js delete obsPacket._parsedPath; delete obsPacket._parsedDecoded; ``` Applied to all 5 spread sites in `public/packets.js`: - Line 271: detail pane observation selection - Line 504: flat view observation expansion - Line 840: grouped view observation expansion - Line 1012: child observation selection in grouped view - Line 1982: WebSocket live update observation expansion ## Tests Added 2 new tests in `test-frontend-helpers.js`: 1. Verifies observation packets get their own path after cache invalidation (not the parent's) 2. Verifies observation path differs from parent path after cache invalidation All 431 frontend helper tests pass. All 62 packet filter tests pass. --------- Co-authored-by: you <you@example.com> |
||
|
|
c7f655e419 |
perf(frontend): cache JSON.parse results for packet data (#400)
## Problem As described in #387, `JSON.parse()` is called repeatedly on the same packet data across render cycles. With 30K packets, each render cycle parses 60K+ JSON strings unnecessarily. ## Analysis The server sends `decoded_json` and `path_json` as JSON strings. The frontend parses them on-demand in multiple locations: - `renderTableRows()` — for every row, every render - WebSocket handling — when processing filtered packets - `loadPackets()` — during packet loading - Detail view rendering — when showing packet details This creates O(n×m) parsing overhead where n = packet count and m = render cycles. ## Solution Add cached parse helpers that store parsed results on the packet object: ```javascript function getParsedPath(p) { if (p._parsedPath === undefined) { try { p._parsedPath = JSON.parse(p.path_json || '[]'); } catch { p._parsedPath = []; } } return p._parsedPath; } ``` Same pattern for `getParsedDecoded()`. ## Changes - `public/packets.js`: Add helpers + replace 15+ JSON.parse calls - `public/live.js`: Add helpers + replace 5 JSON.parse calls ## Benchmarks Before: 60K+ JSON.parse calls per render cycle (30K packets) After: ~30K parse calls (one per packet, cached thereafter) Memory impact: Negligible (stores parsed objects that were already created temporarily) ## Notes - Cache uses `undefined` check to distinguish "not cached" from "cached empty result" - Property names `_parsedPath` and `_parsedDecoded` prefixed to avoid collision with server fields - No breaking changes to existing code paths Fixes #387 --------- Co-authored-by: P. Clawmogorov <262173731+Alm0stSurely@users.noreply.github.com> Co-authored-by: you <you@example.com> |
||
|
|
5228e67604 |
fix: use packet timestamp in bufferPacket instead of arrival time (#475) (#491)
## Summary - `bufferPacket()` was overwriting `_ts` with `Date.now()` (receive time) for every live WS packet - Packets arriving in the same batch all got identical timestamps, making the message history show the same "Xs ago" for every entry (e.g., all show "5s ago") - Fix: use `pkt.timestamp || pkt.created_at` (mirroring `dbPacketToLive`) so each packet reflects its actual origination time, falling back to `Date.now()` only when the packet has no timestamp ## Root cause ```js // before pkt._ts = Date.now(); // after pkt._ts = new Date(pkt.timestamp || pkt.created_at || Date.now()).getTime(); ``` The WS broadcast includes `timestamp` (= `tx.FirstSeen`) in the packet map (store.go:1182), so the field is always present for real packets. ## Test plan - [x] Open Live page, observe packets arriving — each should show its own relative time, not all the same value - [x] `node test-frontend-helpers.js` passes (235 tests, 0 failures) Closes #475 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: you <you@example.com> |
||
|
|
cf3a383bb2 |
test: comprehensive app.js coverage — 100+ new tests (#490)
## Summary Adds 100+ new tests for previously untested `app.js` functions, significantly improving frontend coverage toward the 90%+ target. ## What's Tested All pure/testable functions from `app.js` that lacked coverage: | Function Group | Tests Added | Description | |---|---|---| | `payloadTypeColor` | 13 | All PAYLOAD_COLORS mappings + unknown/null/undefined fallback | | `pad2` / `pad3` | 10 | Zero-padding for 1-3 digit values, no truncation | | `formatIsoLike` | 5 | UTC/local timezone, with/without milliseconds, zero-padding | | `formatTimestampCustom` | 5 | Token replacement (YYYY/MM/DD/HH/mm/ss/SSS/Z), partial formats, invalid format rejection | | `formatAbsoluteTimestamp` | 3 | Custom format integration, locale+UTC, null/invalid date handling | | `getTimestamp*` getters | 11 | localStorage priority, server config fallback, invalid value rejection for Mode/Timezone/FormatPreset/CustomFormat | | `invalidateApiCache` | 3 | Prefix-based selective invalidation, full clear, cache→invalidate→re-fetch lifecycle | | `formatHex` | 5 | Byte spacing, single byte, null/empty, odd-length hex | | `createColoredHexDump` | 6 | Range-based coloring, override precedence, null/empty hex+ranges | | `buildHexLegend` | 5 | Label deduplication, correct swatch colors per label class, null/empty | | Favorites (`getFavorites`/`isFavorite`/`toggleFavorite`/`favStar`) | 9 | CRUD operations, corrupt JSON resilience, star HTML rendering with custom classes | | `debounce` | 3 | Delay behavior, timer reset on rapid calls, argument forwarding | | `mergeUserHomeConfig` | 5 | Null/missing siteConfig/userTheme, non-object home, missing home creation | | Constants | 2 | Exhaustive ROUTE_TYPES (4) and PAYLOAD_TYPES (13) mapping verification | ## Approach - Tests use the existing `vm.createContext` sandbox pattern from `test-frontend-helpers.js` - Tests the **real code** loaded from `public/app.js` — no copies - No new dependencies - Each `invalidateApiCache` test uses an isolated sandbox to avoid async race conditions ## Test Results ``` Frontend helpers: 343 passed, 0 failed ``` Part of #344 — app.js coverage --------- Co-authored-by: you <you@example.com> |
||
|
|
889107a5e1 |
fix: address PR #487 review feedback (#501)
## Summary Addresses review feedback from PR #487 (nodes.js coverage). ### Changes 1. **Replace fragile `exportInternals` regex source patching with stable test hooks** — `getStatusInfo` and `getStatusTooltip` are now exposed via `window._nodesGetStatusInfo` and `window._nodesGetStatusTooltip`, matching the existing pattern used by all other test-accessible functions. The brittle regex `.replace()` approach that modified source code at runtime has been removed entirely. 2. **Strengthen weak null assertion** — The `renderNodeTimestampHtml handles null` test previously asserted `html.includes('—') || html.length > 0`, which is a near-tautology (any non-empty string passes). Now strictly asserts `html.includes('—')`. ### Files changed - `public/nodes.js` — 2 new test hook lines - `test-frontend-helpers.js` — removed 21-line `exportInternals` branch, updated tests to use hooks ### Testing - All 309 frontend helper tests pass - All 62 packet filter tests pass - All 29 aging tests pass Closes review items from #487. Co-authored-by: you <you@example.com> |
||
|
|
50f94603c1 |
test: P0 coverage for nodes.js — sort, status, timestamps, sync (#487)
## Summary Add 67 new unit tests for `nodes.js`, raising frontend helper test count from 233 to 300. Part of #344 — nodes.js coverage. ## What's Tested ### Sort System (`toggleSort`, `sortNodes`, `sortArrow`) - Direction toggling on same column (asc↔desc) - Default sort directions per column type (name→asc, last_seen→desc, advert_count→desc) - localStorage persistence of sort state - All 5 sort columns: `name`, `public_key`, `role`, `last_seen`, `advert_count` - Both ascending and descending for each column - Case-insensitive name sorting - Unnamed nodes sort last - Timestamp fallback chain: `last_heard` → `last_seen` → 0 - Missing timestamp handling - Empty array edge case - Unknown column graceful handling - `sortArrow` rendering for active (▲/▼) and inactive columns ### Status Calculation (`getStatusInfo`, `getStatusTooltip`) - `_lastHeard` takes priority over `last_heard` - `last_seen` used as fallback when `last_heard` missing - No-timestamp nodes return stale with `lastHeardMs: 0` - Infrastructure threshold (72h) for rooms - Standard threshold (24h) for sensors and companions - Explanation text varies by role and status - Unknown role defaults to gray color `#6b7280` - All role/status tooltip combinations ### Timestamp Rendering (`renderNodeTimestampHtml`, `renderNodeTimestampText`) - HTML output includes tooltip and `timestamp-text` class - Future timestamps show ⚠️ warning icon - Null input produces dash - Text output is plain (no HTML tags) ### Favorites Sync (`syncClaimedToFavorites`) - Claimed pubkeys added to favorites - No-op when all already synced - Empty my-nodes handled - Missing localStorage keys don't crash ## Implementation - Added test hooks on `window` for closure-scoped functions (non-invasive, follows existing pattern) - Tests use `vm.createContext` to load real `nodes.js` code — no copies - No new dependencies ## Test Results ``` Frontend helpers: 300 passed, 0 failed ``` --------- Co-authored-by: you <you@example.com> |
||
|
|
b799f54700 |
perf: bound memory growth and reduce render CPU on packets page (#421)
## Problem On a long-running session the packets page consumed 8 GB of browser memory and 20%+ CPU on an 8-core machine. Root causes: 1. **Unbounded `packets` array growth via WebSocket** — `packets.unshift()` was called for every new unique hash, but nothing ever trimmed the array. After hours of live traffic the array grew well past the initial 50 k load limit. 2. **Unbounded `pauseBuffer`** — all WS messages queued while paused, no cap. 3. **Unbounded `_children` growth** — expanded groups received a `unshift(p)` on every matching WS message with no size limit. 4. **O(n) `observers.find()` inside the O(n) render loop** — with 50 k rows, each render triggered up to 50 k linear scans through the observers list. 5. **Full DOM rebuild on every WS message** — `renderTableRows()` was called synchronously on every WebSocket batch, reconstructing the entire table on each incoming packet. ## Changes - `packets[]` is now trimmed to `PACKET_LIMIT` after each WS batch; evicted entries are also removed from `hashIndex` to prevent stale references. - `pauseBuffer` capped at 2 000 entries (oldest dropped). - `_children` capped at 200 entries on WS prepend. - `renderTableRows()` on the WS path is debounced to 200 ms, batching rapid updates into a single redraw. - `observersById = new Map()` pre-built from the observers array; all `observers.find()` calls in the render loop and WS filter replaced with O(1) `Map.get()`. ## Test plan - [x] Load the packets page and leave it running for several minutes with live WebSocket traffic — memory in DevTools should remain stable rather than growing continuously - [x] Pause live updates, wait for several messages, then resume — buffer replays correctly and display updates - [x] Expand a packet group and leave it open during live traffic — children update but don't grow past 200 - [x] Region filter still works correctly (relies on the observer Map lookup) - [x] Observer name / IATA badge renders correctly in grouped and flat mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
f20431d816 |
fix: implement 'Show direct neighbors' map filter (#480)
## Summary Fixes #457 — The "Show direct neighbors" checkbox on the map was a UI stub that did nothing. This PR implements the full feature. ## What Changed ### `public/map.js` - **New state**: `selectedReferenceNode` (pubkey) and `neighborPubkeys` (Set) track which node is the reference and who its direct neighbors are - **`selectReferenceNode(pubkey, name)`**: Fetches `/api/nodes/{pubkey}/paths`, parses path hops to find all nodes directly adjacent to the reference node in any observed path, then auto-enables the neighbor filter - **Neighbor filter in `_renderMarkersInner()`**: When `filters.neighbors` is on and a reference node is selected, only the reference node and its direct (1-hop) neighbors are shown on the map - **Popup "Show Neighbors" link**: Each node popup now has a "Show Neighbors" action that sets it as the reference node - **Sidebar UI hints**: Shows the reference node name when selected, or a hint to click a node when the filter is enabled without a reference - **Cleanup on `destroy()`**: Clears reference state and global handler ### `test-frontend-helpers.js` - 6 new unit tests covering: - Filter off shows all nodes - Filter on without reference shows all nodes (graceful no-op) - Filter on with reference + neighbors filters correctly - Filter on with empty neighbor set shows only reference - Neighbor filter respects role filters - Neighbor extraction from path data ### `public/index.html` - Cache buster bump ## How It Works 1. User clicks a node marker on the map → popup shows "Show Neighbors" link 2. Clicking "Show Neighbors" fetches that node's paths from `/api/nodes/{pubkey}/paths` 3. Adjacent hops in each path are identified as direct neighbors 4. The map filters to show only the reference node + its neighbors 5. The sidebar shows which node is the reference 6. Unchecking the checkbox restores the full node view ## Test Results ``` Frontend helpers: 250 passed, 0 failed Packet filter: 62 passed, 0 failed ``` --------- Co-authored-by: you <you@example.com> |
||
|
|
96d0bbe487 |
fix: replace Euclidean distance with haversine in analytics hop distances (#478)
## Summary Fixes #433 — Replace the inaccurate Euclidean distance approximation in `analytics.js` hop distances with proper haversine calculation, matching the server-side computation introduced in PR #415. ## Problem PR #415 moved collision analysis server-side and switched from the frontend's Euclidean approximation (`dLat×111, dLon×85`) to proper haversine. However, the **hop distance** calculation in `analytics.js` (subpath detail panel) still used the old Euclidean formula. This caused: - **Inconsistent distances** between hop distances and collision distances - **Significant errors at high latitudes** — e.g., Oslo→Stockholm: Euclidean gives ~627km, haversine gives ~415km (51% error) - The `dLon×85` constant assumes ~40° latitude; at 60° latitude the real scale factor is ~55.5km/degree, not 85 ## Changes | File | Change | |------|--------| | `public/analytics.js` | Replace `dLat*111, dLon*85` Euclidean with `HopResolver.haversineKm()` (with inline fallback) | | `public/hop-resolver.js` | Export `haversineKm` in the public API for reuse | | `test-frontend-helpers.js` | Add 4 tests: export check, zero distance, SF→LA accuracy, Euclidean vs haversine divergence | | `cmd/server/helpers_test.go` | Add `TestHaversineKm`: zero, SF→LA, symmetry, Oslo→Stockholm accuracy | | `public/index.html` | Cache buster bump | ## Performance No performance impact — `haversineKm` replaces an inline arithmetic expression with another inline arithmetic expression of identical O(1) complexity. Only called per hop pair in the subpath detail panel (typically <10 hops). ## Testing - `node test-frontend-helpers.js` — 248 passed, 0 failed - `go test -run TestHaversineKm` — PASS Co-authored-by: you <you@example.com> |
||
|
|
c678555e75 |
fix: display channel hash as hex instead of decimal (#471)
## Summary Fixes #465 — Channel hash was displaying in decimal instead of hexadecimal in `channels.js`. ## Changes - Added `formatHashHex()` helper to `channels.js` that formats numeric hashes as `0x` hex (e.g. `0x0A`) and passes string hashes through unchanged - Applied to both display sites: `renderChannelList` fallback name and `selectChannel` header text - Consistent with `packets.js` and `analytics.js` which already use `.toString(16).padStart(2, '0').toUpperCase()` ## Tests - 3 new tests in `test-frontend-helpers.js` verifying the helper exists, is used at display sites, and produces correct output for numeric and string inputs - All 244 frontend tests pass, plus packet-filter (62) and aging (29) tests Co-authored-by: you <you@example.com> |
||
|
|
0f502370c5 |
fix: VCR timeline and clock respect UTC/local timezone setting (#459)
## Problem Fixes #324. The VCR LCD clock and timeline hover/touch tooltip always showed local time, ignoring the UTC/local timezone setting in the customizer Display tab. ## Root cause Three sites in `live.js` bypassed the shared `getTimestampTimezone()` utility: - `updateVCRClock()` — used `d.getHours()` / `d.getMinutes()` / `d.getSeconds()` (always local) - Timeline mousemove tooltip — used `d.toLocaleTimeString()` (always local) - Timeline touchmove tooltip — same ## Fix Added `vcrFormatTime(tsMs)` helper that checks `getTimestampTimezone()` and uses `getUTC*` methods when set to `'utc'`, otherwise local `get*`. Applied to all three sites. Exposed as `window._vcrFormatTime` for testing. ## Tests 4 new unit tests in `test-frontend-helpers.js` covering UTC mode, local mode, and zero-padding. ## Checklist - [x] Branches from `upstream/master` - [x] No Matomo or local-only commits - [x] Cache busters bumped (`v=1775073838`) - [x] 233 tests pass, 0 fail 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
e47c39ffda |
fix: null-guard animLayer and liveAnimCount in nextHop after destroy (#462)
## Summary - `nextHop()` schedules `setInterval`/`setTimeout` callbacks that can fire after `destroy()` has set `animLayer = null` and removed DOM elements - This caused three console errors on the Live page when navigating away mid-animation: `Cannot read properties of null (reading 'hasLayer')` and `Cannot set properties of null (setting 'textContent')` - Added null guards at each async callback site; no behavioral change when the page is active ## Changes - `public/live.js`: early return if `animLayer` is null at start of `nextHop()`; null-safe `animLayer.hasLayer` checks in `setInterval`/`setTimeout`; null-safe `liveAnimCount` element access - `public/index.html`: cache buster bumped - `test-frontend-helpers.js`: 4 source-inspection tests verifying the null guards are present ## Test plan - [ ] Open Live page, trigger some packet animations, navigate away quickly — no console errors - [ ] `node test-frontend-helpers.js` passes (233 tests, 0 failures) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
1499a55ba7 |
perf: upsert known nodes in-place on ADVERT, skip full reload (#461)
## Problem Fixes #399. On every ADVERT WebSocket batch the nodes page invalidated the entire `_allNodes` cache and triggered a full `/nodes?limit=5000` fetch — even when every advertising node was already cached. The 90s API TTL was actively bypassed. ## Root cause ```js wsHandler = debouncedOnWS(function (msgs) { if (msgs.some(isAdvertMessage)) { _allNodes = null; // wipe cache unconditionally invalidateApiCache('/nodes'); // bust API TTL loadNodes(true); // full 5k fetch } }, 5000); ``` ## Fix ADVERT decoded payloads include `pubKey`, `name`, `lat`, `lon` — enough to update known nodes in place: - **Known node** (pubKey found in `_allNodes`): upsert `name`, `lat`, `lon`, `last_seen` directly — no fetch, no cache bust, just re-render. - **New node** (pubKey not in cache) or **no pubKey** in payload: fall back to full reload as before. This covers the common case on an active mesh: all advertising nodes are already cached. The full reload path is preserved for node discovery. ## Tests 2 new unit tests: known-node upsert (asserts 0 API calls, fields updated) and unknown-node fallback (asserts full reload triggered). All 231 tests pass. ## Checklist - [x] Branches from `upstream/master` - [x] No Matomo or local-only commits - [x] Cache busters bumped - [x] 231 tests pass, 0 fail 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
f71e117cdd |
fix: reset restores home steps after SITE_CONFIG contamination (#460)
## Problem Fixes #325. Removing all home steps and clicking "Reset my theme" did not restore them. ## Root cause Two-part bug: **1. `SITE_CONFIG.home` permanently mutated at page load** `app.js` calls `mergeUserHomeConfig(SITE_CONFIG, userTheme)` which does `SITE_CONFIG.home = Object.assign({}, serverHome, userTheme.home)`. If the user had `steps: []` saved in localStorage, this sets `SITE_CONFIG.home.steps = []` globally — permanently for the lifetime of the page. **2. `initState()` reads the contaminated config** When the customizer opens (or Reset is clicked), `initState()` reads `cfg = window.SITE_CONFIG`. Since `SITE_CONFIG.home.steps` is already `[]`, `state.home.steps` stays `[]` even after `localStorage.removeItem`. `autoSave()` then re-saves `steps: []` straight back. **Secondary issue:** `data-rm-step` / add / move handlers didn't call `autoSave()`, making step persistence non-deterministic (only saved if a text field edit happened to be pending). ## Fix - **`app.js`**: snapshot `SITE_CONFIG.home` before `mergeUserHomeConfig` → `window._SITE_CONFIG_ORIGINAL_HOME` - **`customize.js`**: `initState()` uses `_SITE_CONFIG_ORIGINAL_HOME` instead of the contaminated `cfg.home` - **`customize.js`**: add `autoSave()` to rm/move/add handlers for steps, checklist, and footer links ## Tests 2 new unit tests covering the snapshot bypass and DEFAULTS fallback. 231 tests pass. ## Checklist - [x] Branches from `upstream/master` - [x] No Matomo or local-only commits - [x] Cache busters bumped - [x] 231 tests pass, 0 fail 🤖 Generated with [Claude Code](https://claude.com/claude-code) |