mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-12 07:54:43 +00:00
eaeb65b426a7ac80e7d8e2c32aaa4b5ca0ba1521
167 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
52bb07d6c1 |
feat(#1056): fluid tables + +N hidden pill (packets/nodes/observers) (#1099)
## Summary Implements priority-based responsive column hiding for the three primary data tables (Packets, Nodes, Observers) per the parent task #1050 acceptance criteria, with a clickable **+N hidden** pill in the table header to reveal collapsed columns. ## Approach - New `TableResponsive` helper (defined once at the top of `packets.js`, exposed on `window`) classifies `<th data-priority="N">` cells: - `1` = always visible - `2` = hide when viewport ≤ 1280 - `3` = hide ≤ 1080 - `4` = hide ≤ 900 - `5` = hide ≤ 768 - Higher priority numbers drop first. The matching `<td>` cells in `tbody` are tagged via `.col-hidden` (colspan-aware mapping). - A `.col-hidden-pill` `<button>` is appended to the last visible `<th>`. Clicking it sets a per-table reveal flag and clears all hidden classes. Re-runs on `window.resize` (debounced) and a `ResizeObserver` on the wrapping element. - Each of `packets.js` / `nodes.js` / `observers.js` wraps its primary table in `.table-fluid-wrap` and calls `TableResponsive.register` after initial render. - `style.css` removes legacy `min-width: 720px / 480px` floors on the primary tables (which forced horizontal scroll) and lets columns flex via `table-layout: auto` with `.col-time` switched to `clamp(72px, 8vw, 108px)`. Per-column priorities chosen so identifier columns stay visible (Time/Hash/Type/Name/Status) while numeric/secondary columns collapse first. ## Files changed (matches Hard rules — only these) - `public/packets.js` (`#pktTable` + `TableResponsive` helper) - `public/nodes.js` (`#nodesTable`) - `public/observers.js` (`#obsTable`) - `public/style.css` (table sections only) - `test-table-fluid-e2e.js` (new E2E) ## E2E `BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js` — covers all three tables at 768/1080/1440 viewports, asserting: - No horizontal table overflow within `.table-fluid-wrap` - Visible `+N hidden` pill at narrow widths with the count `N` matching the number of `th.col-hidden` cells - Clicking the pill clears all `.col-hidden` classifiers (reveals every column) ## Manual verification in openclaw browser (local fixture server) | Page | Viewport | Hidden | Pill | |-----------|---------:|-------:|--------------| | observers | 768 | 8 | `+8 hidden` | | packets | 768 | 7 | `+7 hidden` | | packets | 1080 | 4 | `+4 hidden` | | nodes | 768 | 3 | `+3 hidden` | | nodes | 1440 | 0 | (no pill) | Pill click verified to reveal all columns. ## TDD - Red commit: `5ad7573` — failing E2E (no `.col-hidden-pill` exists yet) - Green commit: `7780090` — implementation; test passes manually against fixture server. Fixes #1056 --------- Co-authored-by: openclaw-bot <bot@openclaw.dev> Co-authored-by: meshcore-bot <bot@meshcore.local> Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: corescope-bot <bot@corescope.local> |
||
|
|
85b8c8115a |
feat(channels): fluid sidebar + container-query stacking (#1057) (#1095)
## Summary Makes the channels page sidebar + message area fluid as part of the parent #1050 fluid-layout effort. Replaces the hardcoded `.ch-sidebar { width: 280px; min-width: 280px }` with `width: clamp(220px, 22vw, 320px); min-width: 220px`. Adds an `@container` query (via `container-type: inline-size` on `.ch-layout`) that stacks the sidebar above the message area when the channels page itself is narrow (≤700px container width) — independent of the global viewport, so it adapts even when an outer panel is consuming width. Removes the legacy `@media (max-width: 900px)` fixed 220px override; the clamp + container query handle that range. `.ch-main` already used `flex: 1`, so it absorbs all remaining width including ultrawides. The existing mobile (≤640px) overlay rules and the JS resize handle in `channels.js` are untouched and still work (user drag still wins via inline width). Fixes #1057. ## Scope - `public/style.css` — channels section only - (no `public/channels.js` changes needed) ## Tests TDD: red commit (failing tests) → green commit (implementation). - `test-channel-fluid-layout.js` (new): static CSS assertions - `.ch-sidebar` uses `clamp()` for width (not fixed px) - `.ch-sidebar` keeps a sane `min-width` (200–280px) - `.ch-main` keeps `flex: 1` - `.ch-layout` declares `container-type` (container query root) - `@container` rule scopes channels stacking - legacy `@media (max-width: 900px) .ch-sidebar { width: 220px }` is gone - `test-channel-fluid-e2e.js` (new): Playwright E2E at 768 / 1080 / 1440 / 1920 (wide) and 480 (narrow). Asserts: - no horizontal scroll on the body - sidebar AND message area both visible side-by-side at ≥768px - sidebar consumes ≤45% of viewport, main ≥40% - at 480px the layout stacks (or overlays) — no overflow Wired into `test-all.sh` and the unit + e2e steps of `.github/workflows/deploy.yml`. ## Verification - Static unit test: 6/6 pass on the green commit, 4/6 fail on the red commit (only the two trivially-true assertions pass). - Local Go server boot: `corescope-server` serves the updated `style.css` containing `container-type: inline-size`, `clamp(220px, 22vw, 320px)`, and `@container chlayout (max-width: 700px)`. - Local Chromium on the dev sandbox is musl-incompatible (Playwright fallback build crashes with `Error relocating ...: posix_fallocate64: symbol not found`), so the E2E was not run locally. CI will run it on Ubuntu runners. --------- Co-authored-by: clawbot <clawbot@example.com> Co-authored-by: meshcore-bot <bot@meshcore.local> |
||
|
|
d1e6c733dc |
fix(nav): apply Priority+ at all widths (#1055) (#1097)
## Summary Make the top-nav use the **Priority+ pattern at all widths** (not just 768–1279px), so the nav-right cluster never gets pushed off-screen or visually overlapped by the link strip. Fixes #1055. ## What changed **`public/style.css`** — nav section only (clearly fenced): - Removed the upper bound on the Priority+ media query (`max-width: 1279px`). The rule now applies at any viewport `>= 768px`. Above that breakpoint, only `data-priority="high"` links render inline; the rest collapse into the existing `More ▾` overflow menu. - Swapped nav-only hardcoded spacing/type to the fluid `clamp()` tokens shipped in #1054: - `.top-nav` padding → `var(--gutter)` - `.nav-left` gap → `var(--space-lg)` - `.nav-brand` gap → `var(--space-sm)`, font-size → `var(--fs-md)` - `.nav-links` gap → `var(--space-xs)` - `.nav-link` padding → `clamp(8px, 0.6vw + 4px, 14px)`, font-size → `var(--fs-sm)` - `.nav-right` gap → `var(--space-sm)` - Mobile (<768px) hamburger layout, the More-menu markup, and the JS that builds the menu in `public/app.js` are unchanged — they already supported this pattern. `public/index.html` did not need changes — the `data-priority="high"` markup, `nav-more-wrap`, `navMoreBtn`, and `navMoreMenu` are already in place from earlier work. ## Why the bug existed The previous Priority+ rule was scoped `@media (min-width: 768px) and (max-width: 1279px)`. From 1280px–~1599px the full 11-link strip rendered but didn't fit alongside `.nav-stats` + `.nav-right`. The parent `overflow: hidden` masked the symptom, but the rightmost links physically rendered underneath `.nav-right` and were unreachable. ## E2E assertion added New `test-nav-fluid-1055-e2e.js` — Playwright multi-viewport test (768/1024/1280/1440/1920) that asserts: 1. `.nav-right.right` ≤ `document.documentElement.clientWidth` (no horizontal overflow) 2. Last visible `.nav-link.right` ≤ `.nav-right.left` (no overlap underneath the right cluster) 3. `.top-nav.scrollWidth` ≤ `.top-nav.clientWidth` (no scrolled-off content) Wired into the `e2e-test` job in `.github/workflows/deploy.yml`. **TDD evidence:** - Red commit `466221a`: test passes 3/5 (1024/768/1920) — fails at 1280 (253px overlap) and 1440 (93px overlap). - Green commit `1aa939a`: test passes 5/5. ## Acceptance criteria (from #1055) - [x] Priority+ at ALL widths (not just mobile). - [x] No nav link overflow at 1080px (or any tested width). - [x] Overflow menu accessible via keyboard + touch (existing `navMoreBtn` aria-haspopup wiring; verified by existing app.js handlers). - [x] Active route still highlighted when in overflow (existing logic in `app.js` adds `.active` to the cloned link in `navMoreMenu`). - [x] Tested at 768/1024/1280/1440/1920 — visible link count adapts (5 priority links + More menu at all desktop widths; full 11 inline only on hamburger mobile when expanded). --------- Co-authored-by: bot <bot@corescope> Co-authored-by: clawbot <clawbot@users.noreply.github.com> Co-authored-by: meshcore-bot <bot@meshcore.local> |
||
|
|
b52a938b27 |
fix(#1059): map controls + modals — fluid + safe max-height (#1096)
## Summary Fixes #1059 — Task 6 of #1050. Makes map controls + modals fluid and safely capped so they work across 768px–2560px viewports. ## Changes `public/style.css` only — modal section + map-controls section (per task scope). ### Map controls (`.map-controls`) - `width: clamp(160px, 18vw, 240px)` — fluid, scales with viewport. - `max-width: calc(100vw - 24px)` — never overflows narrow viewports. - Eliminates horizontal scroll on the map page at 768/1024/1440/1920/2560. ### Modal box (`.modal`) - `max-height: 80vh → 90vh` (spec §3). - `width: min(90vw, 500px)` — fluid, drops to 90vw below 555px. - `position: relative` so sticky descendants anchor to the modal box. - `.modal-overlay` gets `padding: clamp(8px, 2vw, 24px)` for edge breathing room. ### BYOP modal sticky close - `.byop-header { position: sticky; top: 0 }` with `var(--card-bg)` backdrop and bottom border — the title bar + ✕ stay reachable while the body scrolls. - `.byop-x` restyled with border, hit area, hover state. ### Untouched (intentional) - `public/map.js` did not need changes — the `.map-controls` element is the only narrow-viewport offender; the markup stays identical. - Channel modals (`.ch-modal*`, `.ch-share-modal*`) already have their own width/max-width tokens from #1034/#1087 and are out of scope for this task. ## TDD - **Red commit** `b69e992`: `test-map-modal-fluid-e2e.js` asserts (a) no horizontal scroll on `/#/map` at 1024/1440/1920/2560, (b) `.map-controls` right edge inside viewport at 768px wide, (c) BYOP modal at 1024×768 has `height ≤ 90vh`, `overflow-y: auto|scroll`, and close button is `position: sticky` and reachable. All assertions fail against the previous CSS (fixed-width 220px controls overflow at narrow widths; modal max-height was 80vh, not 90vh; close button was `position: static`). - **Green commit** `3e6df9d`: CSS changes above; all assertions pass. ## E2E - Wired into `.github/workflows/deploy.yml` after the channel-1087 E2E: ``` BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js ``` ## Acceptance criteria - [x] Map controls do not overlap markers at narrow viewports (fluid clamp width + max-width). - [x] Map fills extra space on ultrawide (panel caps at 240px, leaflet flex:1 takes the rest — already true; controls no longer steal grow room). - [x] Modals: `max-height: 90vh`, internal scroll, sticky close button, max-width via `min()`. - [x] No modal can exceed viewport height at any tested width. - [x] Verified via E2E at 768/1024/1440/1920/2560. ## Out of scope (left for sibling tasks under #1050) - Tab bars / nav (Task 1050-1, blocker). - Filter bars and table chrome (other 1050-N tasks). --------- Co-authored-by: corescope-bot <bot@corescope.local> |
||
|
|
6d17cac40e |
fix(#1058): fluid + container-queried analytics chart grid (#1098)
## Summary Makes the analytics chart grid fluid and auto-stacking based on its **own** width rather than the viewport's. Implements task 5 of #1050. ## What changed - `public/style.css` — `.analytics-charts` section only: - Replaced `grid-template-columns: 1fr 1fr` with `repeat(auto-fit, minmax(min(100%, 380px), 1fr))` so columns wrap when intrinsic space is too narrow. - Added `container-type: inline-size` so the grid is a query container and descendants/future tweaks can size against its own width rather than the viewport. The `auto-fit minmax` already handles the stack-on-narrow case, so the previously-included `@container (max-width: 800px)` rule was redundant and has been dropped to keep one source of truth. - `min-width: 0` on cards and `max-width: 100%; height: auto` on `<svg>`/`<canvas>` (descendant selector, robust to wrapper elements between the card and the chart media) to prevent intrinsic-content overflow. - Switched hardcoded `12px` / `16px` spacing to the #1054 tokens `--space-sm` / `--space-md`. - Removed the redundant `@media (max-width: 768px) { .analytics-charts { grid-template-columns: 1fr; } }` rule (the fluid grid supersedes it). No `analytics.js` / `node-analytics.js` markup changes were required — the existing classes are reused. ## TDD - **Red commit ( |
||
|
|
ac0cf5ac7d |
fix(channels): #1087 QR library + share modal + PSK persistence (#1090)
Red commit:
|
||
|
|
f7d8a7cb8f |
feat(packets): filter UX — in-UI docs + autocomplete + right-click + saved filters (#966) (#1083)
## Summary Implements the full filter-input UX upgrade from #966 — Wireshark-style help, autocomplete, right-click-to-filter, and saved filters. Closes #966. ## Surfaces ### A. Help popover (ⓘ button next to filter input) Auto-generated from `PacketFilter.FIELDS` / `OPERATORS` so it stays in sync with the parser. Includes: - Syntax overview (boolean ops, parens, case-insensitivity, URL-shareable filters) - Full field reference (27 entries: top-level + `payload.*`) - Full operator reference with one example per op - 10 ready-to-paste examples - Tips (right-click, autocomplete, save) ### B. Autocomplete dropdown - Type partial field name → field suggestions (top-level + dynamic `payload.*` keys discovered from visible packets) - Type `field` → operator suggestions - Type `type ==` → list of canonical type values (`ADVERT`, `GRP_TXT`, …) - Type `route ==` → list of route values (`FLOOD`, `DIRECT`, `TRANSPORT_FLOOD`, …) - Keyboard nav: ↑/↓, Tab/Enter to accept, Esc to dismiss ### C. Right-click → filter by this value Right-click any of these cells in the packet table: - `hash`, `size`, `type`, `observer` Context menu offers `==`, `!=`, `contains`. Click → clause appended to filter input (with `&&` if expression already present). ### D. Saved filters - ★ Saved ▾ dropdown next to the input - 7 starter defaults (Adverts only, Channel traffic, Direct messages, Strong signal SNR > 5, Multi-hop, Repeater adverts, Recent < 5m) - "+ Save current expression" prompts for a name and persists to `localStorage` under `corescope_saved_filters_v1` - User filters can be deleted (✕); defaults cannot - User filters with the same name as a default override it ## Implementation **`public/packet-filter.js`** — exposes `FIELDS`, `OPERATORS`, `TYPE_VALUES`, `ROUTE_VALUES`, and a new `suggest(input, cursor, opts)` function that returns ranked autocomplete suggestions with replace-range. Pure function — no DOM, fully unit-tested. **NEW `public/filter-ux.js`** — `window.FilterUX` IIFE owning the help popover, autocomplete dropdown, context menu, and saved-filters store. `init()` is idempotent, called once after the filter input renders. **`public/packets.js`** — calls `FilterUX.init()` after the filter input IIFE; row builders gain `data-filter-field` / `data-filter-value` attrs on hash/size/type/observer cells. `filter-group` wrapper now `position: relative` so dropdowns anchor correctly. **`public/style.css`** — scoped `.fux-*` styles using existing CSS variables (no new theme tokens). ## Tests - `test-packet-filter-ux.js` (19 unit tests, wired into `test-all.sh`): - Metadata exposure (FIELDS / OPERATORS / TYPE_VALUES / ROUTE_VALUES) - `suggest()` for empty input, prefix match, after `==`, dynamic `payload.*` keys - `SavedFilters.list/save/delete` — defaults, persistence, override, dedup - `buildCellFilterClause()` and `appendClauseToExpr()` quoting + appending - `test-filter-ux-e2e.js` (Playwright, wired into `deploy.yml`): - Navigate /packets → metadata exposed - Help popover opens with field reference, operators, examples - Autocomplete shows on focus, filters by prefix, accepts on Enter - Saved-filter dropdown lists defaults, click populates input - Right-click on TYPE cell → context menu → click appends clause - Save current expression persists to localStorage TDD red commit (`bddf1c1`) — assertion failures only, no import errors. Green commit (`0d3f381`) — all 19 unit tests pass. ## Browser validation Spawned local server on :39966 against the e2e fixture DB and exercised every UX surface via the openclaw browser tool. Confirmed: - `window.PacketFilter.FIELDS.length === 27`, `suggest()` available - `FilterUX.SavedFilters.list().length === 7` (defaults seeded) - Help popover renders with `payload.name`, `contains`, `ADVERT` text content - Right-click on a `data-filter-field="type"` / `data-filter-value="Response"` cell → context menu showed three options → clicking == populated the input with `type == "Response"` (and the existing alias resolver matched it to `payload_type === 1`) - Autocomplete on `pay` returned `payload_bytes`, `payload_hex`, `payload.name`, `payload.lat`, `payload.lon`, `payload.text` ## Out of scope (deferred per the issue) - Server-synced saved filters (cross-device) - Visual filter builder - Custom field expressions ## Acceptance criteria - [x] Help icon (ⓘ) next to filter input opens documentation popover - [x] Field reference table + operator reference + 6+ examples in popover - [x] Autocomplete dropdown on field names (top-level + `payload.*`) - [x] Autocomplete dropdown on values for `type` / `route` operators - [x] Right-click on packet cell → "Filter ==" / "Filter !=" / "Filter contains" - [x] Right-click context menu hides when clicking elsewhere / Esc - [x] Saved-filters dropdown with at least 5 default examples (7 shipped) - [x] User-saved filters persist in localStorage - [x] Real-time match count next to filter input (already shipped pre-PR; preserved) - [ ] Improved error messages with token + position — partial: existing parse errors already cite position; not a regression - [x] No regression in existing filter behavior (`test-packet-filter.js`: 69/69 pass) --------- Co-authored-by: meshcore-bot <bot@meshcore.local> |
||
|
|
aa3d26f314 |
fix(nav): stop nav bar from jumping when Live is selected (#1046) (#1078)
## Summary The `🔴 Live` nav link could wrap onto two lines at certain viewport widths once it became the `.active` link, which grew `.nav-link`'s height and made the whole `.top-nav` "hop" the instant Live was selected (issue #1046). Adding `white-space: nowrap` to the base `.nav-link` rule keeps every nav label on a single line at every breakpoint (default desktop + the 768–1279px and <768px responsive overrides), eliminating the jump. ## Changes - `public/style.css` — `white-space: nowrap` on `.nav-link`. - `test-e2e-playwright.js` — new assertion at viewport 1115px (the width in the issue screenshots) that: - computed `white-space` prevents wrapping - the Live link renders on a single line in both states - `.top-nav` height does not change when `.active` is toggled ## TDD - Red commit `ba906a5` — test added, fails because base `.nav-link` has no `white-space` rule (default `normal`). - Green commit `51906cb` — single-line CSS fix makes the test pass. Fixes #1046 --------- Co-authored-by: corescope-bot <bot@corescope.local> |
||
|
|
417b460fa0 |
feat(css): fluid scaffolding — clamp() spacing/type/container tokens (#1054) (#1066)
## Summary Lands the **fluid CSS foundation** for the responsive scaffolding effort (parent #1050). Pure additive change to the top of `public/style.css` — no component CSS touched. ## What changed ### New tokens in `:root` - **Spacing scale** — `--space-xs … --space-2xl` via `clamp()`. 1440px targets match the prior hardcoded `4 / 8 / 16 / 24 / 32 / 48` px values to within ~1px. - **Type scale** — `--fs-sm … --fs-2xl` via `clamp(min, vw-based, max)`. Floors keep text readable at 768px; caps prevent runaway growth at 2560px+. - **Radii** — `--radius-sm/md/lg` via `clamp()`. - **Container layout** — `--gutter` (`clamp()`) and `--content-max` (`min(100% - 2*gutter, 1600px)`) for fluid horizontal layout without media queries. ### Base consumption - `html, body` now sets `font-size: var(--fs-md)`. ### Parallel-work safety - Added `FLUID SCAFFOLDING` section header at the top. - Added `COMPONENT STYLES` section header marking where the rest of the file (nav, tables, charts, map, packets, analytics …) begins. Sibling tasks 1050-3..6 / 1052-* edit inside that region and won't conflict with this PR. ## TDD - **Red:** `2d6f90a` — `test-fluid-scaffolding.js` asserts the new tokens exist with `clamp()`/`min()`, that `html, body` consumes `--fs-md`, and that the section marker is present. Fails on assertions (15 failed, 0 passed). - **Green:** `7b4d59b` — implementation in `public/style.css`. All 15 assertions pass. ## Acceptance criteria - [x] Fluid spacing scale `--space-xs..--space-2xl` via `clamp()` - [x] Fluid type scale `--fs-sm..--fs-2xl` via `clamp()` - [x] Replace base body font-size with the new token - [x] Container layout vars `--content-max`, `--gutter` via `min()`/`clamp()` - [x] No component CSS edits (only `:root`, `html`, `body`) - [x] No visual regression at 1440px (token targets numerically match prior px values) ## Notes for reviewers - Pre-existing `test-frontend-helpers.js` failure on master is unrelated (`nodesContainer.setAttribute is not a function`) and not introduced here. - `--content-max` uses `min(100% - 2*gutter, 1600px)` — the `100% - …` arm wins on small viewports and guarantees a gutter always remains. Fixes #1054 --------- Co-authored-by: clawbot <bot@corescope.local> Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: meshcore-bot <bot@meshcore.local> |
||
|
|
eaf14a61f5 |
fix(css): 48px touch targets, :active states, hover→tap (#1060) (#1067)
## Summary Fixes #1060 — free-win CSS pass for touch usability. - All major interactive controls (`.btn`, `.btn-icon`, `.nav-btn`, `.nav-link`, `.ch-icon-btn`, `.ch-remove-btn`, `.ch-share-btn`, `.ch-gear-btn`, `.panel-close-btn`, `.mc-jump-btn`, `button.ch-item`) now declare `min-height: 48px` / `min-width: 48px`. Hit-area grows; visual padding/icon size unchanged on desktop because the rules use `inline-flex` centering. - Added visible `:active` feedback (background shift + `transform: scale(0.92–0.97)` + opacity) on every button class — touch devices have no hover, so `:active` is the only press signal. - Hover-only `.sort-help` tooltip rule is now wrapped in `@media (hover: hover)`; added a CSS-only `:focus` / `:focus-within` tap-to-reveal path with a visible focus ring so the same content is reachable on touch (and via keyboard). - All changes scoped to the `=== Touch Targets ===` section. No other CSS section modified, no JS touched, no markup edits. ## Acceptance criteria - [x] All interactive controls reach 48×48 CSS-px touch target (verified by `test-touch-targets.js`). - [x] Every button has a visible `:active` state (no hover-only feedback). - [x] Hover tooltip rule is gated behind `@media (hover: hover)`, with `:focus-within` tap-to-reveal fallback. - [x] Desktop visuals preserved (padding-based, not visual-size-based). ## TDD - Red commit `327473b` — `test-touch-targets.js` asserts every required selector/property; it compiles and fails on assertion against pre-change CSS. - Green commit `e319a8f` — Touch Targets section rewrite; test passes. ``` $ node test-touch-targets.js test-touch-targets.js: OK ``` Fixes #1060 --------- Co-authored-by: bot <bot@corescope> |
||
|
|
f9cd43f06f |
fix(analytics): integrate channels list with PSK decrypt UX + add link from Channels page (#1042)
## What Integrates the Analytics → Channels section with the PSK decrypt UX (PRs #1021–#1040). Replaces nonsense `chNNN` placeholders with useful display names and groups the table the same way the Channels sidebar does. ## Before - Encrypted channels showed raw `ch185`, `ch64`, `ch?` placeholders. - Locally-decrypted PSK channels (with stored keys + labels) were not surfaced — every encrypted row looked identical and useless. - Single flat list, sorted by last activity by default. ## After - **My Channels** 🔑 — any analytics row whose hash byte matches a stored PSK key (via `ChannelDecrypt.getStoredKeys()` + `computeChannelHash`). Display name uses the user's label if set, otherwise the key name. - **Network** 📻 — known cleartext channels (server-provided names) and rainbow-table-decoded encrypted channels. - **Encrypted** 🔒 — unknown encrypted, rendered as `🔒 Encrypted (0xNN)` instead of `chNNN`. - Within each group: messages descending (most active first). - New `📊 Channel Analytics →` link in the Channels page sidebar header → `#/analytics`. ## How - Pure `decorateAnalyticsChannels(channels, hashByteToKeyName, labels)` — testable in isolation, sets `displayName` + `group` per row. - `buildHashKeyMap()` — async helper that resolves stored PSK keys to their channel hash bytes via `computeChannelHash`. Used at render time; first paint uses an empty map (best-effort) and re-renders once keys resolve. Graceful fallback when `ChannelDecrypt` is missing or there are no stored keys. - `channelTbodyHtml` gains an `opts.grouped` flag — opt-in so the existing flat sort still works for any other caller. - The analytics API endpoint is **unchanged** — this is purely frontend rendering. ## Tests `test-analytics-channels-integration.js` — 19 assertions covering decoration, grouping, sort order, and the channels-page link. Added to `test-all.sh`. Red commit: `5081b12` (12 assertion failures + stub). Green commit: `6be16d9` (all 19 pass). --------- Co-authored-by: bot <bot@corescope.local> Co-authored-by: meshcore-bot <bot@meshcore.local> |
||
|
|
67da696a42 |
fix(channels): hide raw psk:* in header, label share button, red delete button (#1041)
## Channel UX round 2 (follow-up to #1040) Three UX issues reported after #1040 landed: ### 1. Header shows raw `psk:372a9c93` for PSK channels The selected-channel title rendered `ch.name` directly, which for user-added PSK channels is the synthetic `psk:<hex8>` string. Users see opaque key fragments where they expected the friendly name they typed. **Fix:** new `channelDisplayName(ch)` helper. Returns `ch.userLabel` when set, falls back to `"Private Channel"` for any `psk:*` name, then to the original name, then to `Channel <hash>`. Used in both `selectChannel` (header) and `renderChannelRow` (sidebar). ### 2. Share button `⤴` is unrecognizable Up-arrow glyph carried no meaning — users didn't know it opened the QR/key reshare modal. **Fix:** swap `⤴` for `📤 Share` text label. Same hook, same handler. ### 3. ✕ delete button is a subtle span, not a destructive button Looked like decorative text, not a real action. **Fix:** `.ch-remove-btn` gets `background: var(--statusRed, #b54a4a)`, `color: white`, `border-radius: 4px`, `padding: 4px 8px`, `font-weight: bold`. Now reads as a destructive action. ### TDD - Red commit `2d05bbf`: 9 failing assertions (helper missing, ⤴ still present, CSS rules absent), test compiles + runs to assertion failure. - Green commit `938f3fc`: all 12 assertions pass. Existing `test-channel-ux-followup.js` still 28/28. ### Files - `public/channels.js` — `channelDisplayName` helper, header + row rendering, share button label - `public/style.css` — `.ch-remove-btn` destructive styling - `test-channel-ux-round2.js` — new test (helper behavior + source/CSS assertions) --------- Co-authored-by: openclaw-bot <bot@openclaw.dev> Co-authored-by: corescope-bot <bot@corescope.local> |
||
|
|
c00b585ee5 |
fix(channels): UX follow-ups to #1037 (touch target, '0 messages', share, locality, #meshcore) (#1040)
## Summary Seven UX follow-ups to the channel modal/sidebar redesign in #1037. ## Fixes 1. **✕ touch target** — was 13px font + 0×4 padding, far below WCAG 2.5.5 / Apple HIG 44×44px. Bumped `.ch-remove-btn` to a 44×44 hit area without disturbing desktop layout. 2. **"0 messages" preview** — user-added (PSK) channel rows showed `0 messages` even when dozens were decrypted. `messageCount` only tracks server-known activity, not PSK decrypts. Drop the misleading fallback: when no last message is known and the count is zero/absent, render nothing. 3. **Privacy footer wording** — old copy "Clear browser data to remove stored keys" was misleading after #1037 added per-channel ✕. Reworded to point users at the ✕ button. 4. **Reshare affordance** — each user-added row now exposes a `⤴` Share button that re-opens the QR + key for that channel via `ChannelQR.generate` (with a plain-hex + `meshcore://channel/add?...` URL fallback when the QR vendor lib isn't loaded). Reuses the Add Channel modal; cleared on close. 5. **Drop "(your key)" suffix** from the row preview. The 🔑 badge already conveys ownership; the suffix was noise. The key hex itself is now only revealed on explicit Share, not in the sidebar. 6. **Make browser-local nature obvious** — the prior framing made local-only sound like a feature when it's actually a constraint users need to plan around. Adds: - Prominent `.ch-modal-callout` in the Add Channel modal: *"Channels are saved to **THIS browser only**. They won't appear on other devices or browsers, and clearing browser data will remove them."* - `🖥️ (this browser)` marker in the **My Channels** section header - Remove-confirm prompt now explicitly says *"permanently remove the key from this browser"* 7. **#meshcore, not #LongFast** — `#LongFast` is Meshtastic's default channel name. The meshcore network's analogous default is `#meshcore`. Updated placeholder + case-sensitivity example in the modal. ## TDD - Red commit `878d872` — failing assertions for fixes 1–6. - Green commit `444cf81` — implementation. - Red commit `6cab596` — failing assertions for fix 7. - Green commit `9adc1a3` — `#meshcore` swap. `test-channel-ux-followup.js` (18 assertions) passes. Existing `test-channel-modal-ux.js` (33) and `test-channel-sidebar-layout.js` (8) remain green. ## Files - `public/channels.js` — row template, share handler, modal callout/footer, sidebar header, confirm copy, placeholder swap - `public/style.css` — `.ch-remove-btn` / `.ch-share-btn` 44×44, `.ch-modal-callout`, `.ch-section-locality` - `test-channel-ux-followup.js` — new test file --------- Co-authored-by: clawbot <clawbot@local> |
||
|
|
cea2c70d12 |
feat(#1034): channel UX redesign PR1 — Add Channel modal + sectioned sidebar (#1037)
## Summary PR 1 of 3 for #1034 — channel UX redesign. Replaces the cramped inline "type a name or 32-hex blob" form with a clear modal dialog, and reorganizes the sidebar into three labeled sections. **Scope of this PR:** Modal UI + sectioned sidebar. QR generation/scan is deferred to PR #2 (placeholders are wired and ready). `channel-decrypt.js` crypto is untouched. ## What changed ### New modal: `[+ Add Channel]` Triggered by the new sidebar button. Three sections: 1. **Generate PSK Channel** — name + `[Generate & Show QR]` → `crypto.getRandomValues(16)` → hex → `ChannelDecrypt.storeKey`. QR rendering ships in PR #2; for now `#qr-output` surfaces the hex key as text. 2. **Add Private Channel (PSK)** — 32-hex input (regex-validated), optional display name, `[Add]`. `[📷 Scan QR]` placeholder is present but `disabled` (PR #2 wires it). 3. **Monitor Hashtag Channel** — non-editable `#` prefix + free text + case-sensitivity warning + `[Monitor]`. Reuses `ChannelDecrypt.deriveKey`. Privacy footer: _"🔒 Keys stay in your browser. CoreScope is a passive observer..."_ Close ✕, backdrop click, and Escape all dismiss. ### Sectioned sidebar `renderChannelList()` rewritten to render three sections: - **My Channels** — `userAdded` channels. ✕ always visible. Last sender + relative time. - **Network** — server-known cleartext channels. - **Encrypted (N)** — collapsed by default (toggle persists in `localStorage`). Shows hash byte + packet count. The legacy "🔒 No key" checkbox and `#chShowEncrypted` toggle are removed entirely. Encrypted channels are always fetched; the renderer groups them. ## Tests - **Unit** — `test-channel-modal-ux.js` (33 assertions): added to `test-all.sh`. Covers sidebar button, modal markup, three sections, QR placeholders, privacy footer, sectioned sidebar, modal handlers (incl. `crypto.getRandomValues(16)`). - **E2E** — `test-channel-modal-e2e.js` (Playwright, 14 steps). Covers modal open/close, section rendering, invalid-hex error, valid-hex storage, encrypted-section toggle. Run with: ``` CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:38201 node test-channel-modal-e2e.js ``` - `test-channel-psk-ux.js` — updated to reference `#chPskName` (was `#chKeyLabelInput`). ### Red→green proof - Red commit (`7ee421b`): test added with 31 expected assertion failures, no source change. - Green commit (`897be8f`): implementation lands, test passes 33/33. ## Browser-validated Built `cmd/server/`, ran against `test-fixtures/e2e-fixture.db`, exercised modal open → invalid hex → valid hex → key persisted → modal closes → sectioned sidebar renders + Encrypted toggle expands. All 14 E2E steps pass. ## What's NOT in this PR - QR code rendering (PR #2) - Camera/QR scanning (PR #2) - Migration of legacy localStorage format (PR #3, if needed — current key format is unchanged) - `channel-decrypt.js` changes (none — UI-only PR) ## Acceptance criteria from #1034 - [x] Modal opens on `[+ Add Channel]` click - [x] Three sections clearly separated with labels - [x] Add PSK: accepts 32-hex (QR scan = PR #2) - [x] Monitor Hashtag: derives key, case-sensitivity warning shown - [x] Privacy footer present - [x] Sidebar: three sections (My Channels / Network / Encrypted) - [x] ✕ button visible and functional on My Channels entries - [x] "No key" checkbox removed - [ ] Generate PSK QR display — text fallback only; QR is PR #2 - [ ] Old stored keys migrate seamlessly — no migration needed (storage format unchanged) Refs #1034 --------- Co-authored-by: meshcore-bot <bot@meshcore.local> |
||
|
|
d967170dd3 |
fix(channels): sidebar layout for user-added (PSK) rows — nested <button> bug (#1033)
## Problem Channel sidebar layout broke for user-added (PSK) channels. Visible symptoms in the screenshot: - No ✕ (delete) button on user-added rows - 🔑 emoji floating in the wrong position - Message preview text (e.g. `KpaPocket: Тест`) orphaned **between** channel entries instead of inside the row - Spinner/loading dots misaligned ## Root cause **HTML5 forbids nested `<button>` elements.** The `.ch-item` row is a `<button>`, and #1024 added a `<button class="ch-remove-btn">` inside it. The HTML parser implicitly closes the outer `.ch-item` the moment it sees the inner `<button>`, then re-parents everything after it (✕ and the `.ch-item-preview` line) outside the row. Resulting DOM tree (parser-corrected, simplified): ``` <button class="ch-item">[icon] Levski 🔑</button> <-- closes early <button class="ch-remove-btn">✕</button> <-- orphaned, "floating" <div class="ch-item-preview">KpaPocket: Тест</div> <-- orphaned <button class="ch-item">[icon] #bookclub …</button> ``` Compounded by `.ch-remove-btn { opacity: 0 }` (only visible on row hover), which made the ✕ undiscoverable on touch devices even before the parser bug. ## Fix `public/channels.js` - Replace the inner `<button class="ch-remove-btn">` with `<span class="ch-remove-btn" role="button" tabindex="0">`. Click delegation already keys off `[data-remove-channel]` so behavior is unchanged. - Add `keydown` (Enter / Space) handler on `#chList` so the role=button span stays keyboard-accessible. - Relabel the ambiguous `🔒 No key` toggle to `🔒 Show encrypted (no key)`, with an explanatory `title` ("Show encrypted channels you don't have a key for (locked, can't decrypt)") so users understand it controls visibility of channels they haven't added a PSK for. `public/style.css` - `.ch-remove-btn`: drop `opacity: 0` default. Now `0.55` idle, `0.9` on row hover, `1` on direct hover/focus. Added `:focus` outline removal + `display: inline-flex` so the ✕ centers cleanly. - Add `.ch-user-badge` rule (was unstyled — contributed to the misalignment of the 🔑). ## TDD - Red commit `eeb94ad` — `test-channel-sidebar-layout.js` (7 assertions, 3 failing on master). - Green commit `2959c3d` — fix; all 7 pass. - Wire commit `4d6100d` — added to `test-all.sh`. Existing channel test files still pass (`test-channel-psk-ux.js`, `test-channel-live-decrypt.js`, `test-channel-live-decrypt-userprefix.js`, `test-channel-decrypt-m345.js`, `test-channel-decrypt-insecure-context.js`). ## Files changed - `public/channels.js` - `public/style.css` - `test-channel-sidebar-layout.js` (new) - `test-all.sh` |
||
|
|
2f0c97604b |
feat(map): cluster markers with Leaflet.markercluster (#1036) (#1038)
## Summary Implements map marker clustering for large meshes (500+ nodes) using vendored `Leaflet.markercluster@1.5.3`. Closes the long-standing no-op `Show clusters` checkbox. ## What changed **Vendored library** — `public/vendor/leaflet.markercluster.js` + `MarkerCluster.css` + `MarkerCluster.Default.css`. No CDN: this runs offline on mesh-operator deployments. **`map.js`** - `createClusterGroup()` instantiates `L.markerClusterGroup` with: - `chunkedLoading: true` (no frame drops on initial render) - `removeOutsideVisibleBounds: true` (viewport culling — key win at 2k+ nodes) - `disableClusteringAtZoom: 16` (fully expanded at high zoom) - `spiderfyOnMaxZoom: true` (fan out at max zoom) - `showCoverageOnHover: false` - `animate` disabled on mobile UA for perf - `makeClusterIcon(cluster)` produces a CoreScope-themed `L.divIcon`: - Bold total count, centered - Up to 4 role-color mini-pills (repeater / companion / room / sensor / observer) using `ROLE_COLORS` - Bucketed `mc-sm` / `mc-md` / `mc-lg` background (info / warning / accent CSS vars) - `#mcClusters` checkbox repurposed from no-op `Show clusters` → `Cluster markers`, default **ON**, persisted to `localStorage['meshcore-map-clustering']` - Render branches at the marker-add step: clustering ON → `addLayers()` to `clusterGroup`, skip `deconflictLabels` + `_updateOffsetIndicator` polylines + `_repositionMarkers` on zoom/resize. Clustering OFF → original flow unchanged. - Route polylines (`drawPacketRoute`) already removed both layers — no change needed beyond actually instantiating `clusterGroup`. - `?node=PUBKEY` deep-link lookup now searches both `markerLayer` and `clusterGroup` so it works in either mode. **`style.css`** — cluster bubble + role-pill styles using `--info` / `--warning` / `--accent` CSS variables; hover scale. **`index.html`** — vendor CSS + JS tags after the Leaflet bundle (cache-busted via `__BUST__`). ## TDD - **Red commit** `e10af23` — `test-map-clustering.js` + stub `createClusterGroup`/`makeClusterIcon` returning null/empty divIcon. Compiles, runs, fails 4/5 on assertions. - **Green commit** `482ea2e` — real implementation. 5/5 pass. ``` === map.js: clustering === ✅ exposes test hooks (__meshcoreMapInternals) ✅ createClusterGroup returns an L.MarkerClusterGroup with required options ✅ cluster group accepts markers via addLayer ✅ makeClusterIcon: includes total count and role-pill counts ✅ makeClusterIcon: bucket sm/md/lg by total ``` ## Behavior preserved - Clustering OFF (existing checkbox unchecked) → all original behavior intact: deconfliction spiral, offset-indicator polylines, per-zoom reposition. - Default ON. Operators with small meshes can disable via the checkbox; choice persists. - Spiderfying enabled at max zoom (built-in markercluster behavior). ## Performance target Smooth pan/zoom at 2000 nodes — `chunkedLoading` keeps the main thread responsive during initial add, `removeOutsideVisibleBounds` keeps DOM bounded to the viewport. Per AGENTS.md rule 0: complexity is O(n) for the initial add (chunked across frames), per-zoom re-cluster is internal to markercluster (well-tested at 10k+ scale). ## Out of scope (filed as follow-ups in spec) - Canvas marker renderer — only if 5k+ nodes per viewport materializes - Server-side viewport culling (`/api/nodes?bbox=`) - Cluster-by-role split groups - 2k-node fixture + Playwright DOM assertions — repo doesn't currently ship a `fixture=` query param; the unit test exercises the integration deterministically. Fixes #1036 --------- Co-authored-by: corescope-bot <bot@corescope> |
||
|
|
3290ff1ed5 |
fix(channels): auto-decrypt PSK channels on WebSocket live feed (#1029) (#1030)
Closes #1029. ## Problem PSK-decrypted channels show new messages only after a full page refresh. The WebSocket live feed delivers `GRP_TXT` packets as encrypted blobs and the channel UI has no hook to auto-decrypt them with stored keys. The REST fetch path (used on initial load + on `selectChannel`) already decrypts; the WS path silently dropped on the floor. ## Fix Two new helpers in `public/channel-decrypt.js`: - `buildKeyMap()` → `Map<channelHashByte, { channelName, keyBytes, keyHex }>` built from `getStoredKeys()`. Cached and invalidated on `saveKey` / `removeKey`, so the WS hot path is O(1) per packet after the first build. - `tryDecryptLive(payload, keyMap)` → returns `{ sender, text, channelName, channelHashByte }` when the payload is an encrypted `GRP_TXT` whose channel hash matches a stored key and whose MAC verifies; `null` otherwise. `public/channels.js` wraps `debouncedOnWS` with an async pre-pass (`decryptLivePSKBatch`) that: 1. Skips the work entirely when no encrypted `GRP_TXT` is in the batch or no PSK keys are stored. 2. For each match, rewrites `payload.channel`, `payload.sender`, and `payload.text` so the existing `processWSBatch` consumes the packet exactly the same way it consumes a server-decrypted `CHAN`. 3. Bumps a per-channel `unread` counter for any decrypted message whose channel is not currently selected. The badge renders in the sidebar (`.ch-unread-badge`) and resets on `selectChannel`. `processWSBatch` itself is untouched, so the existing channel-view behavior, dedup-by-packet-hash, region filtering, and timestamp ticker all continue to work as before. ## TDD - **Red** (`2e1ff05`): `test-channel-live-decrypt.js` asserts the new helpers + the channels.js integration contract. With stub `buildKeyMap`/`tryDecryptLive` returning empty/null, the test compiles and runs to completion with **8/14 assertion failures** (no crashes, no missing-symbol errors). - **Green** (`1783658`): real implementation lands; **14/14 pass**. ## Verification (Rule 18) - `node test-channel-live-decrypt.js` → 14/14 pass - All other channel tests still pass: - `test-channel-decrypt-ecb.js` 7/7 - `test-channel-decrypt-insecure-context.js` 8/8 - `test-channel-decrypt-m345.js` 24/24 - `test-channel-psk-ux.js` 19/19 - `cd cmd/server && go build ./...` clean - Booted the server against the fixture DB and curled `/channel-decrypt.js`, `/channels.js`, `/style.css` — all three serve the new code with the auto-injected `__BUST__` cache buster. ## Performance The WS pre-pass is gated by a quick scan: zero-cost when no encrypted `GRP_TXT` is present in the batch. When PSK keys exist, the key map is cached (sig-keyed on the stored-keys snapshot) so `crypto.subtle.digest` runs once per stored key per change, not per packet. Each match costs one MAC verify + one ECB decrypt — the same work `fetchAndDecryptChannel` already does, just amortized over time instead of in a single batch. ## Out of scope - Decoupling the badge from the live feed (server should ideally tag packets with `decryptionStatus` before broadcast). Tracked separately. - Persisting the `unread` counter across reloads (currently in-memory). --------- Co-authored-by: clawbot <bot@corescope.local> |
||
|
|
440bda6244 |
fix(channels): channel color picker UX (closes #681) (#995)
## Summary Fixes the channel color picker UX issues on both Live page and Channels page. Closes #681 ## Repro Evidence (on master at HEAD) - **Live feed dots**: 12px inline — too small to reliably click in a fast-moving feed - **Right-click hijack**: `contextmenu` listener on live feed conflicts with browser context menu - **Channels page**: No way to clear an assigned color without opening the picker popover - **Popover positioning**: 8px edge margin causes overlap with panel borders ## Root Cause | Issue | File:Line | |-------|-----------| | Tiny dots | `public/live.js:2847` — inline `width:12px;height:12px` | | Context menu hijack | `public/channel-color-picker.js:231` — `feed.addEventListener('contextmenu', ...)` | | No clear affordance | `public/channels.js:1101` — dot rendered without adjacent clear button | | Popover overlap | `public/channel-color-picker.js:108-109` — `vw - pw - 8` margin | ## Fix 1. Increased feed color dots to 18px (visible, clickable) 2. Removed contextmenu listener from live feed — dots are the interaction point 3. Added inline `✕` clear button next to colored dots on channels page 4. Increased popover edge margin to 14px ## TDD Evidence - **Red commit:** `2034071` — 6/8 tests fail (dot size, contextmenu, clear affordance, margins) - **Green commit:** `49636e5` — all 8 tests pass ## Verification - `node test-color-picker-ux.js` — 8/8 pass - `node test-channel-color-picker.js` — 17/17 pass (existing tests unbroken) --------- Co-authored-by: you <you@example.com> |
||
|
|
3364eed303 |
feat: separate "Last Status Update" from "Last Packet Observation" for observers (v3 rebase) (#969)
Rebased version of #968 (which was itself a rebase of #905) — resolves merge conflict with #906 (clock-skew UI) that landed on master. ## Conflict resolution **`public/observers.js`** — master (#906) added "Clock Offset" column to observer table; #968 split "Last Seen" into "Last Status" + "Last Packet" columns. Combined both: the table now has Status | Name | Region | Last Status | Last Packet | Packets | Packets/Hour | Clock Offset | Uptime. ## What this PR adds (unchanged from #968/#905) - `last_packet_at` column in observers DB table - Separate "Last Status Update" and "Last Packet Observation" display in observers list and detail page - Server-side migration to add the column automatically - Backfill heuristic for existing data - Tests for ingestor and server ## Verification - All Go tests pass (`cmd/server`, `cmd/ingestor`) - Frontend tests pass (`test-packets.js`, `test-hash-color.js`) - Built server, hit `/api/observers` — `last_packet_at` field present in JSON - Observer table header has all 9 columns including both Last Packet and Clock Offset ## Prior PRs - #905 — original (conflicts with master) - #968 — first rebase (conflicts after #906 landed) - This PR — second rebase, resolves #906 conflict Supersedes #968. Closes #905. --------- Co-authored-by: you <you@example.com> |
||
|
|
54f7f9d35b |
feat: path-prefix candidate inspector with map view (#944) (#945)
## feat: path-prefix candidate inspector with map view (#944) Implements the locked spec from #944: a beam-search-based path prefix inspector that enumerates candidate full-pubkey paths from short hex prefixes and scores them. ### Server (`cmd/server/path_inspect.go`) - **`POST /api/paths/inspect`** — accepts 1-64 hex prefixes (1-3 bytes, uniform length per request) - Beam search (width 20) over cached `prefixMap` + `NeighborGraph` - Per-hop scoring: edge weight (35%), GPS plausibility (20%), recency (15%), prefix selectivity (30%) - Geometric mean aggregation with 0.05 floor per hop - Speculative threshold: score < 0.7 - Score cache: 30s TTL, keyed by (prefixes, observer, window) - Cold-start: synchronous NeighborGraph rebuild with 2s hard timeout → 503 `{retry:true}` - Body limit: 4096 bytes via `http.MaxBytesReader` - Zero SQL queries in handler hot path - Request validation: rejects empty, odd-length, >3 bytes, mixed lengths, >64 hops ### Frontend (`public/path-inspector.js`) - New page under Tools route with input field (comma/space separated hex prefixes) - Client-side validation with error feedback - Results table: rank, score (color-coded speculative), path names, per-hop evidence (collapsed) - "Show on Map" button calls `drawPacketRoute` (one path at a time, clears prior) - Deep link: `#/tools/path-inspector?prefixes=2c,a1,f4` ### Nav reorganization - `Traces` nav item renamed to `Tools` - Backward-compat: `#/traces/<hash>` redirects to `#/tools/trace/<hash>` - Tools sub-routing dispatches to traces or path-inspector ### Store changes - Added `LastSeen time.Time` to `nodeInfo` struct, populated from `nodes.last_seen` - Added `inspectMu` + `inspectCache` fields to `PacketStore` ### Tests - **Go unit tests** (`path_inspect_test.go`): scoreHop components, beam width cap, speculative flag, all validation error cases, valid request integration - **Frontend tests** (`test-path-inspector.js`): parse comma/space/mixed, validation (empty, odd, >3 bytes, mixed lengths, invalid hex, valid) - Anti-tautology gate verified: removing beam pruning fails width test; removing validation fails reject tests ### CSS - `--path-inspector-speculative` variable in both themes (amber, WCAG AA on both dark/light backgrounds) - All colors via CSS variables (no hardcoded hex in production code) Closes #944 --------- 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> |
||
|
|
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> |
||
|
|
31a0a944f9 |
fix(#829): node-detail side panel Recent Packets text invisible (#830)
Closes #829 ## What Add explicit `color: var(--text)` to `.advert-info` (and `var(--accent)` to its links) so the side-panel "Recent Packets" entries stay readable in all themes. ## Why `.advert-info` had only `font-size` + `line-height` rules — text inherited from ancestors. In default light/dark themes the inherited color happens to differ enough from `--card-bg`. Under custom themes where they collide, text becomes invisible — only the colored `.advert-dot` shows. Operator screenshot confirmed the symptom. Same class of bug as the existing fix at `style.css:660` ("Bug 7 fix: neighbor table text inherits accent color — force readable text") which forced `color: var(--text)` on `.node-detail-section .data-table td`. The advert timeline doesn't use a data-table, so it fell through. ## Verified - DOM contains correct text — only the rendered color was wrong - `getComputedStyle(.advert-info).color` previously matched `--card-bg` under affected themes - After fix: `.advert-info` resolves to `var(--text)` regardless of inherited chain - Frontend helpers: 553/0 - Full-screen `node-full-card` view (separate `.node-activity-item` markup) unaffected Co-authored-by: Kpa-clawbot <agent@corescope.local> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
232770a858 |
feat(rf-health): M2 — airtime, error rate, battery charts with delta computation (#605)
## M2: Airtime + Channel Quality + Battery Charts Implements M2 of #600 — server-side delta computation and three new charts in the RF Health detail view. ### Backend Changes **Delta computation** for cumulative counters (`tx_air_secs`, `rx_air_secs`, `recv_errors`): - Computes per-interval deltas between consecutive samples - **Reboot handling:** detects counter reset (current < previous), skips that delta, records reboot timestamp - **Gap handling:** if time between samples > 2× interval, inserts null (no interpolation) - Returns `tx_airtime_pct` and `rx_airtime_pct` as percentages (delta_secs / interval_secs × 100) - Returns `recv_error_rate` as delta_errors / (delta_recv + delta_errors) × 100 **`resolution` query param** on `/api/observers/{id}/metrics`: - `5m` (default) — raw samples - `1h` — hourly aggregates (GROUP BY hour with AVG/MAX) - `1d` — daily aggregates **Schema additions:** - `packets_sent` and `packets_recv` columns added to `observer_metrics` (migration) - Ingestor parses these fields from MQTT stats messages **API response** now includes: - `tx_airtime_pct`, `rx_airtime_pct`, `recv_error_rate` (computed deltas) - `reboots` array with timestamps of detected reboots - `is_reboot_sample` flag on affected samples ### Frontend Changes Three new charts in the RF Health detail view, stacked vertically below noise floor: 1. **Airtime chart** — TX (red) + RX (blue) as separate SVG lines, Y-axis 0-100%, direct labels at endpoints 2. **Error Rate chart** — `recv_error_rate` line, shown only when data exists 3. **Battery chart** — voltage line with 3.3V low reference, shown only when battery_mv > 0 All charts: - Share X-axis and time range (aligned vertically) - Reboot markers as vertical hairlines spanning all charts - Direct labels on data (no legends) - Resolution auto-selected: `1h` for 7d/30d ranges - Charts hidden when no data exists ### Tests - `TestComputeDeltas`: normal deltas, reboot detection, gap detection - `TestGetObserverMetricsResolution`: 5m/1h/1d downsampling verification - Updated `TestGetObserverMetrics` for new API signature --------- Co-authored-by: you <you@example.com> |
||
|
|
968c104e14 |
feat(rf-health): show observer detail in side panel instead of page bottom
- Change RF Health detail view from bottom-of-page to a right-sliding side panel - Grid stays visible and stable when detail is open (no layout shift) - Click another observer updates panel in place; close button (×) dismisses - On mobile (<640px): panel stacks below grid at full width - Filter out observers with insufficient data (<2 sparkline points) from grid entirely - Follows the same split-layout pattern used by the nodes page |
||
|
|
6f35d4d417 |
feat: RF Health Dashboard M1 — observer metrics + small multiples grid (#604)
## RF Health Dashboard — M1: Observer Metrics Storage, API & Small Multiples Grid Implements M1 of #600. ### What this does Adds a complete RF health monitoring pipeline: MQTT stats ingestion → SQLite storage → REST API → interactive dashboard with small multiples grid. ### Backend Changes **Ingestor (`cmd/ingestor/`)** - New `observer_metrics` table via migration system (`_migrations` pattern) - Parse `tx_air_secs`, `rx_air_secs`, `recv_errors` from MQTT status messages (same pattern as existing `noise_floor` and `battery_mv`) - `INSERT OR REPLACE` with timestamps rounded to nearest 5-min interval boundary (using ingestor wall clock, not observer timestamps) - Missing fields stored as NULLs — partial data is always better than no data - Configurable retention pruning: `retention.metricsDays` (default 30), runs on startup + every 24h **Server (`cmd/server/`)** - `GET /api/observers/{id}/metrics?since=...&until=...` — per-observer time-series data - `GET /api/observers/metrics/summary?window=24h` — fleet summary with current NF, avg/max NF, sample count - `parseWindowDuration()` supports `1h`, `24h`, `3d`, `7d`, `30d` etc. - Server-side metrics retention pruning (same config, staggered 2min after packet prune) ### Frontend Changes **RF Health tab (`public/analytics.js`, `public/style.css`)** - Small multiples grid showing all observers simultaneously — anomalies pop out visually - Per-observer cell: name, current NF value, battery voltage, sparkline, avg/max stats - NF status coloring: warning (amber) at ≥-100 dBm, critical (red) at ≥-85 dBm — text color only, no background fills - Click any cell → expanded detail view with full noise floor line chart - Reference lines with direct text labels (`-100 warning`, `-85 critical`) — not color bands - Min/max points labeled directly on the chart - Time range selector: preset buttons (1h/3h/6h/12h/24h/3d/7d/30d) + custom from/to datetime picker - Deep linking: `#/analytics?tab=rf-health&observer=...&range=...` - All charts use SVG, matching existing analytics.js patterns - Responsive: 3-4 columns on desktop, 1 on mobile ### Design Decisions (from spec) - Labels directly on data, not in legends - Reference lines with text labels, not color bands - Small multiples grid, not card+accordion (Tufte: instant visual fleet comparison) - Ingestor wall clock for all timestamps (observer clocks may drift) ### Tests Added **Ingestor tests:** - `TestRoundToInterval` — 5 cases for rounding to 5-min boundaries - `TestInsertMetrics` — basic insertion with all fields - `TestInsertMetricsIdempotent` — INSERT OR REPLACE deduplication - `TestInsertMetricsNullFields` — partial data with NULLs - `TestPruneOldMetrics` — retention pruning - `TestExtractObserverMetaNewFields` — parsing tx_air_secs, rx_air_secs, recv_errors **Server tests:** - `TestGetObserverMetrics` — time-series query with since/until filters, NULL handling - `TestGetMetricsSummary` — fleet summary aggregation - `TestObserverMetricsAPIEndpoints` — DB query verification - `TestMetricsAPIEndpoints` — HTTP endpoint response shape - `TestParseWindowDuration` — duration parsing for h/d formats ### Test Results ``` cd cmd/ingestor && go test ./... → PASS (26s) cd cmd/server && go test ./... → PASS (5s) ``` ### What's NOT in this PR (deferred to M2+) - Server-side delta computation for cumulative counters - Airtime charts (TX/RX percentage lines) - Channel quality chart (recv_error_rate) - Battery voltage chart - Reboot detection and chart annotations - Resolution downsampling (1h, 1d aggregates) - Pattern detection / automated diagnosis --------- Co-authored-by: you <you@example.com> |
||
|
|
10f712f9d7 |
fix: restructure scroll containers for iOS status bar tap-to-scroll (#330) (#554)
## Summary Fixes #330 — iOS status bar tap-to-scroll broken because `#app` had `overflow: hidden`, preventing `<body>` from being the scroll container. ## Approach: Option B from the issue Instead of a JS polyfill, this restructures scroll containers so `<body>` is the primary scroll container by default, which iOS Safari requires for native status-bar tap-to-scroll. ### How it works **`#app` default (body-scroll mode):** Uses `min-height` instead of fixed `height`, no `overflow: hidden`. Content pushes beyond the viewport and body scrolls naturally. **`#app.app-fixed` (fixed-layout mode):** Restores the original `height: calc(100dvh - 52px); overflow: hidden` for pages that need constrained containers. The router in `app.js` toggles this class based on the current page. ### Fixed-layout pages (`.app-fixed`) These pages need fixed-height containers and are unchanged in behavior: - **packets** — virtual scroll requires fixed-height `.panel-left` to calculate visible rows - **nodes** — split-panel layout with independently scrollable panels - **map** — Leaflet requires fixed-dimension container - **live** — Leaflet map (also has its own `#app:has(.live-page)` override in live.css) - **channels** — split-panel chat layout - **audio-lab** — split-panel layout ### Body-scroll pages (no `.app-fixed`) These pages now let the body scroll, enabling iOS tap-to-scroll: - **analytics** — removed `overflow-y: auto; height: 100%` - **observers** — removed `overflow-y: auto; height: calc(100vh - 56px)` - **traces** — removed `overflow-y: auto; height: 100%` - **home** — removed `#app:has(.home-hero)` override (no longer needed) - **compare** — removed inline `overflow-y:auto; height:calc(100vh - 56px)` - **perf** — removed inline `height:100%; overflow-y:auto` - **observer-detail** — removed inline `overflow-y:auto; height:calc(100vh - 56px)` - **node-analytics** — removed inline `height:100%; overflow-y:auto` ### Files changed | File | Change | |------|--------| | `public/style.css` | `#app` default → `min-height`; added `.app-fixed` class | | `public/app.js` | Router toggles `.app-fixed` based on page | | `public/home.css` | Removed `#app:has()` workaround | | `public/compare.js` | Removed inline overflow/height | | `public/perf.js` | Removed inline overflow/height | | `public/observer-detail.js` | Removed inline overflow/height | | `public/node-analytics.js` | Removed inline overflow/height | ### What's preserved - Sticky nav (`position: sticky; top: 0`) — works with body scroll - Split-panel resize handles — unchanged, still in fixed containers - Virtual scroll on packets page — unchanged, `.panel-left` still has fixed height - Leaflet maps — unchanged, containers still have fixed dimensions - Mobile responsive overrides — unchanged Co-authored-by: you <you@example.com> |
||
|
|
29e8e37114 |
fix: mobile filter dropdown specificity prevents expansion (#534) (#541)
## Summary Fixes #534 — mobile filter dropdown doesn't expand on packets page. ## Root Cause CSS specificity battle in the mobile media query. The hide rule uses `:not()` pseudo-classes which add specificity: ```css /* Higher specificity due to :not() */ .filter-bar > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: none; } /* Lower specificity — loses even with .filters-expanded */ .filter-bar.filters-expanded > * { display: inline-flex; } ``` The JS toggle correctly adds/removes `.filters-expanded`, but the CSS expanded rule could never win. ## Fix Match the `:not()` selectors in the expanded rule so `.filters-expanded` makes it strictly more specific: ```css .filter-bar.filters-expanded > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: inline-flex; } ``` Added a comment explaining the specificity dependency so future devs don't repeat this. ## Tests Added Playwright E2E test: mobile viewport (480×800), navigates to packets page, clicks filter toggle, verifies filter inputs become visible. --------- Co-authored-by: you <you@example.com> |
||
|
|
ca95fc46aa |
fix: neighbor UI — show neighbors crash, dark mode contrast (#523) (#527)
## Summary Part of #523 — fixes bugs 5 and 7 (bug 6 was a duplicate of bug 7). ### Bug 5: Show Neighbors button throws `window._mapSelectRefNode is not a function` **Root cause:** Map popup HTML used inline `onclick` calling `window._mapSelectRefNode`, which was deleted on SPA page destroy. If a popup persisted after navigation, clicks would throw. **Fix:** Replaced inline `onclick` with event delegation. A document-level click handler catches all `[data-show-neighbors]` clicks and calls `selectReferenceNode` directly. The global `window._mapSelectRefNode` is still exposed for existing Playwright tests but is no longer relied upon by the UI. ### Bug 7: Blue text on dark blue background (dark mode contrast) **Root cause:** Neighbor table cells inside `.node-detail-section` / `.node-full-card` inherited accent/link color instead of using `var(--text)`, making text unreadable in dark mode. **Fix:** Added explicit `color: var(--text)` on `.node-detail-section .data-table td` and `.node-full-card .data-table td`. Only `<a>` tags within those cells retain `color: var(--accent)`. ### Files changed - `public/map.js` — event delegation for Show Neighbors - `public/style.css` — contrast fix for neighbor table cells --------- Co-authored-by: you <you@example.com> |
||
|
|
0e1beac52f |
fix: neighbor affinity graph empty results + performance + accessibility (#523) (#524)
## Summary Fixes the neighbor affinity graph returning empty results despite abundant ADVERT data in the store. **Root cause:** `extractFromNode()` in `neighbor_graph.go` only checked for `"from_node"` and `"from"` fields in the decoded JSON, but real ADVERT packets store the originator public key as `"pubKey"`. This meant `fromNode` was always empty, so: - Zero-hop edges (originator↔observer) were never created - Originator↔path[0] edges were never created - Only observer↔path[last] edges could be created (and only for non-empty paths) **Fix:** Check `"pubKey"` first in `extractFromNode()`, then fall through to `"from_node"` and `"from"` for other packet types. ## Bugs Fixed | Bug | Issue | Fix | |-----|-------|-----| | Empty graph results | #522 | `extractFromNode()` now reads `pubKey` field from ADVERTs | | 3-4s response time | #523 comment | Graph was rebuilding correctly with 60s TTL cache — the slow response was due to iterating all packets finding zero matches. With edges now being found, the cache works as designed. | | Incomplete visualization | #523 comment | Downstream of bug 1+2 — fixed by fixing the builder | | Accessibility | #523 comment | Added text-based neighbor list, dynamic aria-label, keyboard focus CSS, dashed lines for ambiguous edges, confidence symbols | ## Changes - **`cmd/server/neighbor_graph.go`** — Fixed `extractFromNode()` to check `pubKey` field (real ADVERT format) - **`cmd/server/neighbor_graph_test.go`** — Added 2 new tests: `TestBuildNeighborGraph_AdvertPubKeyField` (real ADVERT format) and `TestBuildNeighborGraph_OneByteHashPrefixes` (1-byte prefix collision scenario) - **`public/analytics.js`** — Added accessible text-based neighbor list, dynamic aria-label, dashed line pattern for ambiguous edges - **`public/style.css`** — Added `:focus-visible` keyboard focus indicator for canvas ## Testing All Go tests pass (`go test ./... -count=1`). New tests verify the fix prevents regression. Fixes #523, Fixes #522 --------- Co-authored-by: you <you@example.com> |
||
|
|
a45ac71508 |
fix: restore color-coded hex breakdown in packet detail (#329) (#500)
## Summary
- `BuildBreakdown` was never ported from the deleted Node.js
`decoder.js` to Go — the server has returned `breakdown: {}` since the
Go migration (commit `742ed865`), so `createColoredHexDump()` and
`buildHexLegend()` in the frontend always received an empty `ranges`
array and rendered everything as monochrome
- Implemented `BuildBreakdown()` in `decoder.go` — computes labeled byte
ranges matching the frontend's `LABEL_CLASS` map: `Header`, `Transport
Codes`, `Path Length`, `Path`, `Payload`; ADVERT packets get sub-ranges:
`PubKey`, `Timestamp`, `Signature`, `Flags`, `Latitude`, `Longitude`,
`Name`
- Wired into `handlePacketDetail` (was `struct{}{}`)
- Also adds per-section color classes to the field breakdown table
(`section-header`, `section-transport`, `section-path`,
`section-payload`) so the table rows get matching background tints
## Test plan
- [x] Open any packet detail pane — hex dump should show color-coded
sections (red header, orange path length, blue transport codes, green
path hops, yellow/colored payload)
- [x] Legend below action buttons should appear with color swatches
- [x] ADVERT packets: PubKey/Timestamp/Signature/Flags each get their
own distinct color
- [x] Field breakdown table section header rows should be tinted per
section
- [x] 8 new Go tests: all pass
Closes #329
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
d1cb84b596 |
feat: Priority+ nav pattern for tablet viewports (768-1023px) (#345)
## Priority+ Navigation Pattern for Tablet Viewports Phase 2 of responsive nav improvements for #322. ### What this does On **tablet viewports (768-1023px)**, implements the [Priority+ navigation pattern](https://css-tricks.com/the-priority-plus-navigation-pattern/): - **5 high-priority tabs** shown inline: Home, Nodes, Packets, Map, Live - **6 low-priority tabs** collapse into a "More ▾" dropdown: Channels, Traces, Observers, Analytics, Perf, Lab - The "More" button highlights when a low-priority page is active **Desktop (>=1024px)** and **mobile (<768px)** behavior is unchanged. ### Changes | File | Change | |------|--------| | `public/index.html` | Added `data-priority="high"` to 5 primary nav links; added More button + dropdown menu | | `public/style.css` | Split ≤1023px hamburger query into tablet Priority+ (768-1023px) and mobile hamburger (<768px); added More dropdown styles | | `public/app.js` | Added `closeMoreMenu()`, More button toggle, outside-click/Escape close, active state on More button | | Cache busters | Bumped in same commit | ### Accessibility - `aria-haspopup="true"` and `aria-expanded` on More button - `role="menu"` / `role="menuitem"` on dropdown - Focus moves to first item on open - Escape key closes dropdown ### Testing - All 308 existing tests pass (217 frontend-helpers + 62 packet-filter + 29 aging) - No new dependencies added - No build step changes ### Breakpoint summary | Viewport | Behavior | |----------|----------| | >= 1024px | Full horizontal nav (unchanged) | | 768-1023px | Priority+ pattern: 5 tabs + More dropdown **← NEW** | | < 768px | Hamburger drawer with all items (unchanged) | --------- Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
2d8203ae17 |
fix: extend responsive nav hamburger breakpoint to 1024px (#343)
## Summary Extends the hamburger menu activation breakpoint from max-width: 640px to max-width: 1023px, making all 11 nav items accessible on tablets and small laptops where they were previously clipped/invisible. Fixes #322 ## Changes ### public/style.css - New @media (max-width: 1023px) block activates the hamburger menu and vertical drawer - Drawer has max-height: calc(100dvh - 52px) with overflow-y: auto for scrollability - z-index set to 1100 (consistent with nav layer) - ody.nav-open locks background scroll when drawer is open - Mobile-only rules (brand-text hidden, tighter nav-right gap) remain at 640px ### public/app.js - Extracted closeNav() helper for consistent drawer close behavior - Hamburger toggle now adds/removes ody.nav-open class - Drawer closes on: nav link click, Escape key, and route change (SPA navigation) ### public/index.html - Cache busters bumped for all CSS/JS assets ## What's NOT changed - Desktop layout (>=1024px) is completely untouched - No Priority+ pattern (Phase 2) - No map layout changes (Phase 3) - No new dependencies ## Testing - All 308 frontend tests pass ( est-frontend-helpers.js, est-packet-filter.js, est-aging.js) - Visual verification: hamburger activates at <=1023px, full bar at >=1024px --------- Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
81bf3b4b12 |
fix: improve side pane contrast in dark mode (#334) (#342)
## Summary Fixes the poor contrast in the node side pane's "Paths through this node" section in dark mode. ## Root Cause .node-detail-section (side pane) had no background or border — it inherited the lighter --detail-bg (#232340) from .panel-right. The same content on the full detail page sits inside .node-full-card which uses the darker --card-bg (#1a1a2e) + a visible border, giving it proper contrast. | Context | Container | Background | Contrast | |---------|-----------|------------|----------| | Full detail page | .node-full-card | --card-bg (darker) | ✅ Good | | Side pane | .node-detail-section | inherited --detail-bg (lighter) | ❌ Poor | ## Fix Give .node-detail-section the same card treatment as .node-full-card: `css .node-detail-section { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; } ` - All colors use CSS variables — no hardcoded hex values - Both light and dark themes benefit from the card treatment - No JS changes needed — CSS-only fix - Cache busters bumped in the same commit Fixes #334 Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
ce6e8d5237 |
feat: show transport code (T_FLOOD) in packets view (#337)
## Summary Surfaces transport route types in the packets view by adding a **"T" badge** next to the payload type badge for packets with `TRANSPORT_FLOOD` (route type 0) or `TRANSPORT_DIRECT` (route type 3) routes. This helps mesh analysis — communities can quickly identify transported packets and gain insights into scope usage adoption. Closes #241 ## What Changed ### Frontend (`public/`) - **app.js**: Added `isTransportRoute(rt)` and `transportBadge(rt)` helper functions that render a `<span class="badge badge-transport">T</span>` badge with the full route type name as a tooltip - **packets.js**: Applied `transportBadge()` in all three packet row render paths: - Flat (ungrouped) packet rows - Grouped packet header rows - Grouped packet child rows - **style.css**: Added `.badge-transport` class with amber styling and CSS variable support (`--transport-badge-bg`, `--transport-badge-fg`) for theme customization ### Backend (`cmd/server/`) - **decoder_test.go**: Added 6 new tests covering: - `TestDecodeHeader_TransportFlood` — verifies route type 0 decodes as TRANSPORT_FLOOD - `TestDecodeHeader_TransportDirect` — verifies route type 3 decodes as TRANSPORT_DIRECT - `TestDecodeHeader_Flood` — verifies route type 1 (non-transport) decodes correctly - `TestIsTransportRoute` — verifies the helper identifies transport vs non-transport routes - `TestDecodePacket_TransportFloodHasCodes` — verifies transport codes are extracted from T_FLOOD packets - `TestDecodePacket_FloodHasNoCodes` — verifies FLOOD packets have no transport codes ## Visual In the packets table Type column, transport packets now show: ``` [Channel Msg] [T] ← transport packet [Channel Msg] ← normal flood packet ``` The "T" badge has an amber color scheme and shows the full route type name on hover. ## Tests - All Go tests pass (`cmd/server` and `cmd/ingestor`) - All frontend tests pass (`test-packet-filter.js`, `test-aging.js`, `test-frontend-helpers.js`) - Cache busters bumped in `index.html` --------- Co-authored-by: you <you@example.com> Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |