mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-25 11:44:02 +00:00
eaeb65b426a7ac80e7d8e2c32aaa4b5ca0ba1521
786 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 ( |
||
|
|
88dca33355 |
fix(touch): tune pull-to-reconnect to require deliberate pull (#1091) (#1092)
## Summary Fixes #1091 — pull-to-reconnect was triggering on normal scrolling because the threshold was too low and `preventDefault` fired too early. ## Changes **`public/app.js`** — `setupPullToReconnect()` gesture tuning: | Behavior | Before | After | |---|---|---| | Threshold | 80px | **140px** (deliberate pull, not bounce) | | `preventDefault` fires at | 16px (kills native scroll feel) | **140px** (only after commit) | | scrollTop check | `> 0` (allowed negative overscroll) | **strict `=== 0`** | | Mid-gesture scroll | continued tracking | **cancels gesture** | | `touchend` scrollTop check | none | **must still be 0** | ## TDD evidence - Red commit: `bcf0d79` — added `test-pull-to-reconnect-1091.js`. The "100px pull at scrollTop=0: NO reconnect" assertion fails on master because the old 80px threshold triggers there. Six other gesture-tuning assertions also gated. - Green commit: `4071dd0` — production fix. All 7 new tests + 6 existing pull-to-reconnect tests pass. ## E2E coverage (per acceptance criteria) - 50px pull → no trigger - 100px pull → no trigger (regression guard against old 80px threshold) - 160px pull → triggers - Pull from non-zero scrollTop → no trigger - Lift before threshold → no trigger - scrollTop changes from 0 mid-pull → cancels - preventDefault not called below threshold E2E assertion added: `test-pull-to-reconnect-1091.js:154` (the 100px regression-guard assertion that demonstrates the bug fix). ## Test results ``` test-pull-to-reconnect-1091.js: 7 passed, 0 failed test-pull-to-reconnect.js: 6 passed, 0 failed ``` Fixes #1091 --------- Co-authored-by: clawbot <bot@openclaw.local> Co-authored-by: meshcore-bot <bot@meshcore.local> |
||
|
|
ac0cf5ac7d |
fix(channels): #1087 QR library + share modal + PSK persistence (#1090)
Red commit:
|
||
|
|
36ee71d17e |
feat(#1085): fold Roles page into Analytics tab (#1088)
Red commit:
|
||
|
|
282074b19d |
feat(#1034): wire QR generate + scan into channel modal (PR 3/3) (#1081)
## Summary **PR 3/3 of #1034** — wires the existing `window.ChannelQR` module (PR2 #1035) into the existing channel modal placeholders (PR1 #1037). ### Changes **`public/channels.js`** - **Generate handler** (`#chGenerateBtn`): replaced the "QR coming in next update" placeholder text with a real call to `window.ChannelQR.generate(label || channelName, keyHex, qrOut)`. Renders QR canvas + `meshcore://channel/add?...` URL + Copy Key inline into `#qr-output`. - **Scan handler** (`#scan-qr-btn`): removed `disabled` attribute, refreshed title, and added a click handler that calls `window.ChannelQR.scan()`. On success it populates `#chPskKey` (from `result.secret`) and `#chPskName` (from `result.name`); on cancel it's a no-op; on error it surfaces the message via `#chPskError`. The Share button on sidebar entries was already wired to `ChannelQR.generate` in PR1 (no change needed). ### TDD 1. **Red commit** (`178020b`): `test-channel-qr-wiring.js` — 12 assertions, 7 failed against the placeholder code (Generate handler still printed "coming in next update", scan button still disabled). 2. **Green commit** (`e708f3f`): wiring added → all 12 assertions pass. ### E2E (rule 18) `test-e2e-playwright.js` gains 3 Playwright tests (run against the live Go server with fixture DB in CI): - Generate → asserts `#qr-output canvas` and the `meshcore://channel/add` URL appear after the click. - Scan button is enabled (no `disabled` attribute). - Stubs `ChannelQR.scan` to return `{name, secret}`, clicks the button, asserts `#chPskKey` + `#chPskName` are populated. ### CI registration Added `node test-channel-qr-wiring.js` and `node test-channel-modal-ux.js` to the JS unit-test step in `.github/workflows/deploy.yml` (and `test-all.sh`). ### Closes Closes #1034 (final PR in the redesign series). --------- Co-authored-by: OpenClaw Bot <bot@openclaw.local> |
||
|
|
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> |
||
|
|
e9c801b41a |
feat(live): filter incoming packets by IATA region (#1045) (#1080)
Closes #1045. ## What Adds an optional region dropdown to the **Live** page that filters incoming packets by observer IATA. When a user selects one or more regions, only packets observed by repeaters in those regions render in the feed/animation/audio. ## How - New `liveRegionFilter` container in the live header toggles row, initialised via the shared `RegionFilter` component in `dropdown` mode (matches packets/nodes/observers pages). - On page init, fetches `/api/observers` once and builds an `observer_id → IATA` map. - `packetMatchesRegion(packets, obsMap, selected)` (pure helper, OR across observations, case-insensitive) gates `renderPacketTree` next to the existing favorite + node filters. - Selection persists in localStorage via the existing `RegionFilter` machinery — no per-page key needed. - Listener cleanup hooked into the existing live-page teardown. ## TDD - Red commit `55097ce`: `test-live-region-filter.js` asserts `_livePacketMatchesRegion` exists and behaves correctly across 9 cases (no-selection passthrough, single match, no-match, OR across observations, multi-region selection, unknown observer, missing observer_id, case-insensitivity, observer-map override). Fails with `_livePacketMatchesRegion must be exposed` against master. - Green commit `fdec7bf`: implements helper + UI wiring + CSS; test passes. Test wired into `.github/workflows/deploy.yml` JS unit-test step. ## Notes - Server-side WS broadcast is unchanged — filtering is purely client-side, as the issue requests ("something a user can activate themselves, and not something that would be server wide"). - Pre-existing `test-live.js` / `test-live-dedup.js` failures on master are not introduced or affected by this PR (verified by running both on master HEAD). --------- Co-authored-by: meshcore-bot <bot@openclaw.local> |
||
|
|
3ab404b545 |
feat(node-battery): voltage trend chart + /api/nodes/{pubkey}/battery (#663) (#1082)
## Summary Closes #663 (Phase 2 + 3 partial — time-series tracking + thresholds for nodes that are also observers). Adds a per-node battery voltage trend chart and `/api/nodes/{pubkey}/battery` endpoint, sourced from the existing `observer_metrics.battery_mv` samples populated by observer status messages. No new ingest or schema changes — purely surfaces data we were already collecting. ## Scope (TDD red→green) **RED commit:** test(node-battery) — DB query, endpoint shape (200/404/no-data), and config getters all asserted. **GREEN commit:** feat(node-battery) — implementation only. ## Changes ### Backend - `cmd/server/node_battery.go` (new): - `DB.GetNodeBatteryHistory(pubkey, since)` — pulls `(timestamp, battery_mv)` rows from `observer_metrics WHERE LOWER(observer_id) = LOWER(public_key) AND battery_mv IS NOT NULL`. Case-insensitive join tolerates historical pubkey casing variation (observers persist uppercase, nodes lowercase in this DB). - `Server.handleNodeBattery` — `GET /api/nodes/{pubkey}/battery?days=N` (default 7, max 365). Returns `{public_key, days, samples[], latest_mv, latest_ts, status, thresholds}`. - `Config.LowBatteryMv()` / `CriticalBatteryMv()` — defaults 3300 / 3000 mV. - `cmd/server/config.go` — `BatteryThresholds *BatteryThresholdsConfig` field. - `cmd/server/routes.go` — route registration alongside existing `/health`, `/analytics`. ### Frontend - `public/node-analytics.js` — new "Battery Voltage" chart card with status badge (🔋 OK / ⚠️ Low / 🪫 Critical / No data). Renders dashed threshold lines at `lowMv` and `criticalMv`. Empty-state message when no samples in window. ### Config - `config.example.json` — `batteryThresholds: { lowMv: 3300, criticalMv: 3000 }` with `_comment` per Config Documentation Rule. ## Status semantics | latest_mv | status | |-----------------------|------------| | no samples in window | `unknown` | | `>= lowMv` | `ok` | | `< lowMv`, `>= critMv`| `low` | | `< criticalMv` | `critical` | ## What this PR does NOT do (deferred) The issue's full Phase 1 (writing decoded sensor advert telemetry into `nodes.battery_mv` / `temperature_c` from server-side decoder) and Phase 4 (firmware/active polling for repeaters without observers) are out of scope here. This PR delivers the requested Phase 2/3 surfacing for the data path that already lands rows: `observer_metrics`. Repeaters that are also observers (i.e. publish status to MQTT) will get a voltage trend immediately; pure passive nodes won't until Phase 1 lands. ## Tests - `TestGetNodeBatteryHistory_FromObserverMetrics` — case-insensitive join, NULL skipping, ordering. - `TestNodeBatteryEndpoint` — full happy path with thresholds + status. - `TestNodeBatteryEndpoint_NoData` — 200 + status=unknown. - `TestNodeBatteryEndpoint_404` — unknown node. - `TestBatteryThresholds_ConfigOverride` — config getters + defaults. `cd cmd/server && go test ./...` — green. ## Performance Endpoint is per-pubkey (called once on analytics page open), indexed by `(observer_id, timestamp)` PK on `observer_metrics`. No hot-path impact. --------- Co-authored-by: bot <bot@corescope> |
||
|
|
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> |
||
|
|
5f6c5af0cf |
fix(observers): correct column headings after Last Packet (#1039) (#1075)
## Summary Fixes #1039 — the Observers page table had 10 `<td>` cells per row but only 9 `<th>` headings, so labels drifted starting at the Packet Health badge cell. The headings `Packets`, `Packets/Hour`, `Clock Offset`, `Uptime` were each one column to the left of their data. ## Changes - `public/observers.js`: added missing `Packet Health` heading (over the `packetBadge()` cell) and renamed the count column header from `Packets` to `Total Packets` to disambiguate from `Packets/Hour`. ## TDD - **Red commit** (`7cae61c`): `test-observers-headings.js` asserts `<th>` count equals `<td>` count and verifies the expected header order. Both assertions fail on master (9 vs 10; `Packets` vs `Packet Health`/`Total Packets`). - **Green commit** (`8ed7f7c`): heading row updated; both assertions pass. ## Test ``` $ node test-observers-headings.js ── Observers table headings (#1039) ── ✓ thead column count equals tbody row column count ✓ expected headings present and ordered 2 passed, 0 failed ``` Wired into `test-all.sh`. ## Risk Frontend-only, static template change. No data flow / perf impact. Fixes #1039 --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
f33801ecb4 |
feat(repeater): usefulness score — traffic axis (#672) (#1079)
## Summary Implements the **Traffic axis** of the repeater usefulness score (#672). Does NOT close #672 — Bridge, Coverage, and Redundancy axes are deferred to follow-up PRs. Adds `usefulness_score` (0..1) to repeater/room node API responses representing what fraction of non-advert traffic passes through this repeater as a relay hop. ## Why traffic-axis-first The issue proposes a 4-axis composite (Bridge, Coverage, Traffic, Redundancy). Bridge/Coverage/Redundancy require betweenness centrality and neighbor graph infrastructure (#773 Neighbor Graph V2). Traffic axis can ship independently using existing path-hop data. ## Remaining work for #672 - Bridge axis (betweenness centrality — depends on #773) - Coverage axis (observer reach comparison) - Redundancy axis (node-removal simulation — depends on #687) - Composite score combining all 4 axes Partial fix for #672. --------- Co-authored-by: meshcore-bot <bot@meshcore.local> |
||
|
|
d192330bdc |
feat(compare): asymmetric overlap stats for reference observer comparison (#671) (#1076)
## Summary Adds asymmetric overlap percentages to the existing observer compare page so it can be used as a **reference observer comparison** tool (Uncle Lit's request, #671). ## What changed `public/compare.js` (frontend only — no backend changes) - New `computeOverlapStats(cmp)` helper that turns a `comparePacketSets()` result into two-way coverage: - `aSeesOfB` — % of B's packets that A also saw - `bSeesOfA` — % of A's packets that B also saw - plus shared / onlyA / onlyB / totalA / totalB - Two callout cards on the compare summary view: - `<A> saw N of <B>'s X packets` (Y%) - `<B> saw N of <A>'s X packets` (Y%) - Existing "Only A / Only B / Both" tabs already identify unique packets; that's the second half of the issue and is left intact. ## Operator workflow Pick a known-good observer (LOS to key nodes) as the reference. Pair it with a candidate. If the candidate's overlap with the reference is high → healthy. If low → investigate antenna, obstruction, or RF deafness. ## Out of scope (future work) Issue lists several follow-on milestones — full Analytics sub-tab with reference-vs-many table, SNR delta, geographic proximity filter, server-side `/api/analytics/observer-comparison` endpoint. Those are larger and tracked by the issue's M1-M4 milestones; this PR closes the core ask (asymmetric overlap on the existing compare page) and leaves the rest for follow-ups. ## Tests `test-compare-overlap.js` — 6 unit tests via vm sandbox: - exposes `computeOverlapStats` on `window` - basic asymmetric scenario (8/10 vs 8/12) - zero packets — no division by zero - one observer empty — both percentages 0 - perfect overlap — 100% both ways - disjoint observers — 0% both ways TDD: red commit landed first with stub returning zeros (assertions failed), green commit added the math. Closes #671 --------- Co-authored-by: bot <bot@corescope.local> |
||
|
|
45f30fcadc |
feat(repeater): liveness detection — distinguish actively relaying from advert-only (#662) (#1073)
## Summary Implements repeater liveness detection per #662 — distinguishes a repeater that is **actively relaying traffic** from one that is **alive but idle** (only sending its own adverts). ## Approach The backend already maintains a `byPathHop` index keyed by lowercase hop/pubkey for every transmission. Decode-window writes also key it by **resolved pubkey** for relay hops. We just weren't surfacing it. `GetRepeaterRelayInfo(pubkey, windowHours)`: - Reads `byPathHop[pubkey]`. - Skips packets whose `payload_type == 4` (advert) — a self-advert proves liveness, not relaying. - Returns the most recent `FirstSeen` as `lastRelayed`, plus `relayActive` (within window) and the `windowHours` actually used. ## Three states (per issue) | State | Indicator | Condition | |---|---|---| | 🟢 Relaying | green | `last_relayed` within `relayActiveHours` | | 🟡 Alive (idle) | yellow | repeater is in the DB but `relay_active=false` (no recent path-hop appearance, or none ever) | | ⚪ Stale | existing | falls out of the existing `getNodeStatus` logic | ## API - `GET /api/nodes` — repeater/room rows now include `last_relayed` (omitted if never observed) and `relay_active`. - `GET /api/nodes/{pubkey}` — same fields plus `relay_window_hours`. ## Config New optional field under `healthThresholds`: ```json "healthThresholds": { ..., "relayActiveHours": 24 } ``` Default 24h. Documented in `config.example.json`. ## Frontend Node detail page gains a **Last Relayed** row for repeaters/rooms with the 🟢/🟡 state badge. Tooltip explains the distinction from "Last Heard". ## TDD - **Red commit** `4445f91`: `repeater_liveness_test.go` + stub `GetRepeaterRelayInfo` returning zero. Active and Stale tests fail on assertion (LastRelayed empty / mismatched). Idle and IgnoresAdverts already match the desired behavior under the stub. Compiles, runs, fails on assertions — not on imports. - **Green commit** `5fcfb57`: Implementation. All four tests pass. Full `cmd/server` suite green (~22s). ## Performance `O(N)` over `byPathHop[pubkey]` per call. The index is bounded by store eviction; a single repeater has at most a few hundred entries on real data. The `/api/nodes` loop adds one map read + scan per repeater row — negligible against the existing enrichment work. ## Limitations (per issue body) 1. Observer coverage gaps — if no observer hears a repeater's relay, it'll show as idle even when actively relaying. This is inherent to passive observation. 2. Low-traffic networks — a repeater in a quiet area legitimately shows idle. The 🟡 indicator copy makes that explicit ("alive (idle)"). 3. Hash collisions are mitigated by the existing `resolveWithContext` path before pubkeys land in `byPathHop`. Fixes #662 --------- Co-authored-by: clawbot <bot@corescope.local> |
||
|
|
8b924cd217 |
feat(ui): encode view & filter state in URL hash (#749) (#1072)
## Summary Encodes view + filter state in the URL hash so deep links restore the exact page state (issue #749). ## Changes New shared helper `public/url-state.js` exposing `URLState`: - `parseSort('col:asc')` → `{column, direction}` (defaults to `desc`) - `serializeSort('col', 'desc')` → `'col'` (omits default direction) - `parseHash('#/nodes/abc?tab=x')` → `{route: 'nodes/abc', params: {tab:'x'}}` - `buildHash(route, params)` and `updateHashParams(updates, currentHash)` for round-tripping while preserving subpaths. Wired into: - **packets.js** — sort column/direction now in `#/packets?sort=col[:asc]`, restored on init (overrides localStorage). Subpath `#/packets/<hash>` preserved. - **nodes.js** — sort encoded as `#/nodes?sort=col[:asc]`, restored on init. Subpath `#/nodes/<pubkey>` preserved. - **analytics.js** — both selected tab (`tab=topology`) AND time-window picker value (`window=7d`) now round-trip via URL. Subview keys used by rf-health (`range/observer/from/to`) cleared when switching tabs to keep URLs clean. Existing deep links (`#/nodes/<pubkey>`, `#/packets/<hash>`, `?filter=…`, `?node=…`, `?observer=…`, `?channel=…`, `?timeWindow=…`, `?region=…`) all keep working — additive change only. ## Tests TDD red→green: - Red: `5e1482e` (stub throws "not implemented"; 18/18 tests fail on assertions) - Green: `512940e` (helper implemented; 18/18 pass) Wired `test-url-state.js` into `test-all.sh`. Fixes #749 --------- Co-authored-by: clawbot <clawbot@users.noreply.github.com> |
||
|
|
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> |
||
|
|
78dabd5bda |
feat(filter): timestamp predicates (after/before/between/age) — #289 (#1070)
Fixes #289. Adds Wireshark-style timestamp predicates to the client-side packet filter engine (`public/packet-filter.js`). ## New syntax | Form | Meaning | | --- | --- | | `time after "2024-01-01"` | packets with timestamp strictly after the given datetime | | `time before "2024-12-31T23:59:59Z"` | packets strictly before | | `time between "2024-01-01" "2024-02-01"` | inclusive range (order-insensitive) | | `age < 1h` | packets newer than 1 hour | | `age > 24h` | packets older than 24 hours | | `age < 7d && type == ADVERT` | composes with existing predicates | Duration units: `s` / `m` / `h` / `d` / `w`. Datetime values use `Date.parse` (ISO 8601 + bare `YYYY-MM-DD`). `time` is also accepted as `timestamp`. ## Implementation - `OP_WORDS` extended with `after`, `before`, `between`. - New `TK.DURATION` token: lexer recognises `<number><unit>` and pre-converts to seconds at lex time (no per-evaluation parsing cost). - `between` is a two-value op handled in `parseComparison`. - Field resolver: - `time` / `timestamp` → epoch-ms; falls back to `first_seen` then `latest` so grouped rows from `/api/packets?groupByHash=true` work. - `age` → seconds since `Date.now()`. - Parse-time validation rejects invalid datetimes and unknown duration units (silent-fail would have been a footgun — every packet would just disappear). - Null/missing timestamps → predicate returns `false`, consistent with the existing null-field behaviour for `snr` / `rssi`. ## Open questions from the issue - **UTC vs local**: defaults to whatever `Date.parse` returns. Bare dates like `"2024-01-01"` are interpreted as UTC midnight by the spec. Tying this to the #286 timestamp display setting can be a follow-up. - **URL query string**: out of scope for this PR. ## Tests - New `test-packet-filter-time.js`: 20 tests covering `after`/`before`/`between`, ISO datetimes, all duration units, composition with `&&`, null-timestamp safety, invalid-datetime / invalid-unit errors, and `first_seen` fallback. - Wired into `.github/workflows/deploy.yml` JS unit-test step. - Existing `test-packet-filter.js` (69 tests) and inline self-tests still pass. ## Commits - Red: `5ccfad3` — failing tests + lexer-only stub (compiles, asserts fail) - Green: `976d50f` — implementation --------- Co-authored-by: OpenClaw Bot <bot@openclaw.local> |
||
|
|
cbfd159f8e |
feat(ws): pull-to-reconnect on touch devices (Fixes #1063) (#1068)
## Summary Reframes the browser's native pull-to-refresh on touch devices as a **WebSocket reconnect** instead of a full page reload. On data pages (Packets, Nodes, Channels — and globally, since the WS is shared) a downward pull at `scrollTop=0` cycles the WS, which is what users actually want when they reach for that gesture. Fixes #1063. ## Behavior - **Touch-only**: gated by `('ontouchstart' in window) || navigator.maxTouchPoints > 0`. Desktop is untouched. - **Scroll-safe**: every handler re-checks `scrollTop > 0` and bails out — never hijacks normal scroll. - **Visual affordance**: a fixed chip slides down from the top with a rotating ⟳ icon; opacity and rotation scale with pull progress (0 → `PULL_THRESHOLD_PX = 80px`). - **`preventDefault` is conservative**: only after `dy > 16px` and only on `touchmove`, so taps and short swipes are not affected. - **Result feedback**: a brief toast — green `Connected ✓` if WS was already OPEN, `Reconnecting…` otherwise. Both auto-dismiss after ~1.8s. - **Reconnect path**: closes the existing WS so the existing `onclose` auto-reconnect fires immediately; an explicit `connectWS()` is also called as a safety net when `ws` is null. - **No regression** to existing WS auto-reconnect — same `connectWS` / `setTimeout(connectWS, 3000)` chain, just kicked manually. ## TDD - **Red commit** `f90f5e9` — adds `test-pull-to-reconnect.js` with 6 assertions; stub functions added to `app.js` so tests reach assertion failures (not ReferenceError). 3/6 fail on behavior. - **Green commit** `53adbd9` — full implementation; 6/6 pass. ## Files - `public/app.js` — `pullReconnect()`, `setupPullToReconnect()`, `_ensurePullIndicator()`, `_showPullToast()`, `_isTouchDevice()`. Wired into `DOMContentLoaded` next to `connectWS()`. Touched the WS section only. - `test-pull-to-reconnect.js` — vm sandbox suite covering exposure, WS-close, listener wiring, threshold trigger, scroll-position gate. ## Acceptance criteria check - ✅ Pull-down at scroll-top triggers WS reconnect + data refetch (debounced cache invalidate fires on next WS message) - ✅ Visible affordance during pull (rotating chip) - ✅ Resolves on success (toast), shows status toast on disconnect path - ✅ Disabled when not at `scrollTop=0` - ✅ No regression to existing WS auto-reconnect --------- Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: Kpa-clawbot <bot@kpa-clawbot> |
||
|
|
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> |
||
|
|
c1d0daf200 |
feat(#1034): channel QR generate + scan module (PR 2/3) (#1035)
## PR #2 of channel UX redesign (#1034) — QR generation + scanning Self-contained QR module for MeshCore channel sharing. Wirable but **not wired** — PR #3 wires this into the modal placeholders shipped by PR #1. ### What's in - **`public/channel-qr.js`** — new module exporting `window.ChannelQR`: - `buildUrl(name, secretHex)` → `meshcore://channel/add?name=<urlencoded>&secret=<32hex>` - `parseChannelUrl(url)` → `{name, secret}` or `null` (strict: scheme, path, hex32 secret) - `generate(name, secretHex, target)` — renders QR (via vendored qrcode.js) + the URL string + a "Copy Key" button into `target` - `scan()` → `Promise<{name, secret} | null>` — opens a camera overlay, decodes with jsQR, parses, auto-closes on first valid match. Graceful no-camera/permission-denied fallback ("Camera not available — paste key manually"). - **`public/vendor/jsqr.min.js`** — vendored jsQR 1.4.0 - **`public/index.html`** — loads `vendor/jsqr.min.js` + `channel-qr.js` after `channel-decrypt.js` - **`test-channel-qr.js`** + wired into `test-all.sh` — 16 assertions on `buildUrl` / `parseChannelUrl` (DOM/camera paths covered by Playwright in #3) ### TDD - Red commit `d6ba89e` — stub module + failing assertions on `buildUrl` / `parseChannelUrl` (compiles, runs, fails on assertion) - Green commit `25328ac` — real impl, 16/16 pass ### License note Brief specified jsQR as MIT — it's actually **Apache-2.0** (https://github.com/cozmo/jsQR/blob/master/package.json). Apache-2.0 is permissive and compatible with the repo's ISC license; flagging here so reviewers can confirm. Cited in the file header. ### Independence guarantees - Does **not** touch `channels.js` or `channel-decrypt.js` - Does not call any UI from `channels.js`; PR #3 will call `ChannelQR.generate(...)` into `#qr-output` and wire `#scan-qr-btn` to `ChannelQR.scan()` Refs #1034 --------- Co-authored-by: openclaw-bot <bot@openclaw.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> |
||
|
|
26daa760cd |
fix(channels): live PSK decrypt for user-added channels (#1029 follow-up) (#1031)
## Problem PR #1030 added live PSK decrypt for GRP_TXT WS packets, but in production it still didn't work for **user-added** PSK channels. New messages never appeared in real time on a channel added via the sidebar key form — users had to refresh the page to see them via the REST fetch path (regression #1029). ## Root cause `decryptLivePSKBatch` rewrites the payload with the raw channel name: ```js payload.channel = dec.channelName; // e.g. "medusa" ``` But user-added channels live in `channels[]` under the key produced by `addUserChannel`: ```js hash: 'user:' + name, // e.g. "user:medusa" ``` `selectedHash` also uses the `user:`-prefixed key while a user-added channel is open. Downstream in `processWSBatch`: | Line | Check | Result | |---|---|---| | 962 | `c.hash === channelName` | `"medusa" !== "user:medusa"` → user channel never matched | | 982 | `channelName === selectedHash` | `"medusa" !== "user:medusa"` → message never appended to open chat | | 974 | `channels.push({ hash: channelName, ... })` | duplicate plain `"medusa"` entry pushed into sidebar | The unread bumper (`channels.js:1086`) compared `chName === prior` with the same mismatch, so it bumped an unread badge on the channel currently being viewed. Verified end to end against staging WS traffic (live `decryption_status: "decrypted"` packets observed; user-added channel never updated, duplicate entry created). ## Fix `decryptLivePSKBatch` now also stamps a canonical sidebar key on the payload: ```js payload.channelKey = hasUserCh ? ('user:' + dec.channelName) : dec.channelName; ``` `processWSBatch` and the unread bumper route on `payload.channelKey` (falling back to `payload.channel` for server-known CHAN packets — no behavior change there). After the fix: - ✅ live message appends to the open user-added chat - ✅ sidebar row's `lastMessage` / `messageCount` / `lastActivityMs` update - ✅ no duplicate non-prefixed sidebar entry - ✅ unread bumped only on channels NOT being viewed ## TDD Red commit `f1719a8` — `test-channel-live-decrypt-userprefix.js`, fails 6/9 on assertions (NOT build error) on pristine `channels.js`. Green commit `da87018` — minimal fix in `channels.js`, all 9/9 pass. Verified red gates the change: stashed `public/channels.js`, re-ran test on red commit alone → 6 assertion failures (open channel got 0 messages, duplicate sidebar entry, unread bumped on viewed channel). ## Files changed - `public/channels.js` — stamp/route on `channelKey` - `test-channel-live-decrypt-userprefix.js` (new) — red-then-green regression test --------- 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> |
||
|
|
3aaa21bbc0 |
fix(channel-decrypt): pure-JS SHA-256/HMAC fallback for HTTP context (P0 follow-up to #1021) (#1027)
## P0: PSK channel decryption silently failed on HTTP origins User reported PSK key `372a9c93260507adcbf36a84bec0f33d` "still doesn't work" after PRs #1021 (AES-ECB pure-JS) and #1024 (PSK UX) merged. Reproduced end-to-end and found the actual remaining bug. ### Root cause PR #1021 fixed the AES-ECB path by vendoring a pure-JS core, but **SHA-256 and HMAC-SHA256 in `public/channel-decrypt.js` are still pinned to `crypto.subtle`**. `SubtleCrypto` is exposed **only in secure contexts** (HTTPS / localhost); when CoreScope is served over plain HTTP — common for self-hosted instances — `crypto.subtle` is `undefined`, and: - `computeChannelHash(key)` → `Cannot read properties of undefined (reading 'digest')` - `verifyMAC(...)` → `Cannot read properties of undefined (reading 'importKey')` Both throws are swallowed by `addUserChannel`'s `try/catch`, so the only user-visible signal is the toast `"Failed to decrypt"` with no console-friendly explanation. Verdict: PR #1021 only fixed half of the crypto-in-insecure-context problem. ### Reproduction (no browser required) `test-channel-decrypt-insecure-context.js` loads the production `public/channel-decrypt.js` in a `vm` sandbox where `crypto.subtle` is undefined (mirrors HTTP browser). Pre-fix it failed 8/8 with the exact error above; post-fix it passes 8/8. ### Fix - New `public/vendor/sha256-hmac.js`: minimal pure-JS SHA-256 + HMAC-SHA256 (FIPS-180-4 + RFC 2104, ~120 LOC, MIT). Verified against Node `crypto` for SHA-256 (empty / "abc" / 1000 bytes) and RFC 4231 HMAC-SHA256 TC1. - `public/channel-decrypt.js`: `hasSubtle()` guard. `deriveKey`, `computeChannelHash`, and `verifyMAC` use `crypto.subtle` when available and fall back to `window.PureCrypto` otherwise. Same API, same return types, same async signatures. - `public/index.html`: load `vendor/sha256-hmac.js` immediately before `channel-decrypt.js` (mirrors the `vendor/aes-ecb.js` wiring from #1021). ### TDD - **Red** (`8075b55`): `test-channel-decrypt-insecure-context.js` — runs the **unmodified** prod module in a no-`subtle` sandbox, asserts on the known PSK key (hash byte `0xb7`) and synthetic encrypted packet round-trip. Compiles, runs, **fails 8/8 on assertions** (not on import errors). - **Green** (`232add6`): vendor + delegate. Test passes 8/8. - Wired into `test-all.sh` and `.github/workflows/deploy.yml` so CI gates the regression. ### Validation (all green post-fix) | Test | Result | |---|---| | `test-channel-decrypt-insecure-context.js` | 8/8 | | `test-channel-decrypt-ecb.js` (#1021 KAT) | 7/7 | | `test-channel-decrypt-m345.js` (existing) | 24/24 | | `test-channel-psk-ux.js` (#1024) | 19/19 | | `test-packet-filter.js` | 69/69 | ### Files changed - `public/vendor/sha256-hmac.js` — **new** (~150 LOC, MIT, decrypt-side only) - `public/channel-decrypt.js` — `hasSubtle()` guard + fallback in `deriveKey`/`computeChannelHash`/`verifyMAC` - `public/index.html` — script tag for `vendor/sha256-hmac.js` - `test-channel-decrypt-insecure-context.js` — **new** (8 assertions, pure Node, no browser) - `test-all.sh` + `.github/workflows/deploy.yml` — wire the test ### Risk / scope - Frontend-only, decrypt-side only. No server, schema, or config changes (Config Documentation Rule N/A). - Secure-context behaviour unchanged (still uses Web Crypto when present). - HMAC `secret` building, MAC truncation (2 bytes), and AES-ECB delegation untouched. - Hash vector for the user's PSK key matches: `SHA-256(372a9c93260507adcbf36a84bec0f33d) = b7ce04…`, channel hash byte `0xb7` (183) — confirmed against Node `crypto` and against the new pure-JS path. ### Note on the FIPS test data in the new test The PSK `372a9c93260507adcbf36a84bec0f33d` is shared test data from the bug report, not a real channel secret. --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
a1f4cb9b5d |
fix(channels): PSK channel UX — delete, label, badge, toast (#1020) (#1024)
## Problem The PSK channel decrypt UX was unusable (#1020): 1. ✕ button only appeared when a `userAdded` flag happened to be set, which wasn't reliable for keys matching server-known hashes. 2. PSK channels visually indistinguishable from server-known encrypted channels — both rendered with 🔒. 3. No way to give a PSK channel a friendly name; sidebar always showed `psk:<hex8>`. 4. "Decrypt count" toast was scraped from `#chMessages .ch-msg` after a race, so it often reported zero or stale numbers. ## Changes ### `public/channel-decrypt.js` - **New API**: `saveLabel(name, label)`, `getLabel(name)`, `getLabels()`. - `storeKey(name, hex, label?)` — third optional `label` argument persists alongside the key under a separate `corescope_channel_labels` localStorage namespace. - `removeKey` now also clears the stored label. ### `public/channels.js` - Add-channel form gets a second row with `#chKeyLabelInput` ("optional name (e.g. My Crew)"). - `addUserChannel(val, label)` — passes the label through to `storeKey`. - `mergeUserChannels()` reads `getLabels()` and propagates `userLabel` onto channel objects (both new ones and ones that match an existing server-known hash). - `renderChannelList()` distinguishes user-added rows: - `.ch-user-added` class + `data-user-added="true"` attribute. - 🔓 badge icon (vs 🔒 for server-known no-key) and a 🔑 marker next to the name. - Display name uses the user-supplied label when present. - ✕ remove button is now keyed off `userAdded` (which `mergeUserChannels` always sets for stored keys). - `selectChannel` now returns `{ messageCount, wrongKey?, error?, stale? }`. `addUserChannel` uses that for the toast instead of scraping the DOM, and surfaces `wrongKey` explicitly: "Key does not match any packets for …". ## Acceptance criteria - [x] ✕ (delete) button on all user-added PSK channels in sidebar - [x] Clicking ✕ removes key + label + cache from localStorage and removes from sidebar - [x] Visual badge/icon distinguishing "my keys" (🔓 + 🔑 + `.ch-user-added`) from "unknown encrypted" (🔒 + `.ch-encrypted`) - [x] Optional name field in the add-channel form (`#chKeyLabelInput`), stored alongside key in localStorage - [x] Name displayed in sidebar instead of `psk:<hex>` - [x] Toast shows decrypt result count after adding (and reports `wrongKey` explicitly) ## Tests `test-channel-psk-ux.js` (added to `test-all.sh`) — 19 assertions: - ChannelDecrypt label storage + retrieval + `removeKey` cascade. - E2E DOM contract for `channels.js`: `#chKeyLabelInput`, `.ch-user-added`, 🔓 icon, `addUserChannel` accepts label, no DOM scraping for decrypt count. - End-to-end `mergeUserChannels` label propagation through a sandbox-loaded `ChannelDecrypt`. Red commit (`da6d477`) failed 8/15 assertions; green commit (`542bb1d`) — all 19 pass. Existing channel tests still green: ``` node test-channel-decrypt-ecb.js → 7/7 node test-channel-decrypt-m345.js → 24/24 node test-channel-psk-ux.js → 19/19 ``` (The pre-existing `test-frontend-helpers.js` failure on `nodes.js` `loadNodes` reproduces on `origin/master` — unrelated.) ## Notes - Decrypt logic untouched (PR #1021 already fixed it). - No config fields added. - Keys + labels stay in the user's browser; nothing transmitted. Fixes #1020 --------- Co-authored-by: corescope-bot <bot@corescope.local> |
||
|
|
51b9fed15e |
feat(roles): /#/roles page + /api/analytics/roles endpoint (Fixes #818) (#1023)
## Summary Implements `/#/roles` per QA #809 §5.4 / issue #818. The page previously showed "Page not yet implemented." ### Backend - New `GET /api/analytics/roles` returns `{ totalNodes, roles: [{ role, nodeCount, withSkew, meanAbsSkewSec, medianAbsSkewSec, okCount, warningCount, criticalCount, absurdCount, noClockCount }] }`. - Pure `computeRoleAnalytics(nodesByPubkey, skewByPubkey)` does the bucketing/aggregation — no store/lock dependency, fully unit-testable. - Roles are normalised (lowercased + trimmed; empty bucketed as `unknown`). ### Frontend - New `public/roles-page.js` renders a distribution table: count, share, distribution bar, w/ skew, median |skew|, mean |skew|, severity breakdown (OK / Warning / Critical / Absurd / No-clock). - Registered as the `roles` page in the SPA router and linked from the main nav. - Auto-refreshes every 60 s, with a manual refresh button. ### Tests (TDD) - **Red commit** (`9726d5b`): two assertion-failing tests against a stub `computeRoleAnalytics` that returns an empty result. Compiles, runs, fails on `TotalNodes = 0, want 5` and `len(Roles) = 0, want 1`. - **Green commit** (`7efb76a`): full implementation, route wiring, frontend page + nav, plus E2E test in `test-e2e-playwright.js` covering both the empty-state contract (no "Page not yet implemented" placeholder) and the populated-table case (header columns, body rows, API response shape). ### Verification - `go test ./cmd/server/...` green. - Local server with the e2e fixture: `GET /api/analytics/roles` returns `{"totalNodes":200,"roles":[{"role":"repeater","nodeCount":168,...},{"role":"room","nodeCount":23,...},{"role":"companion","nodeCount":9,...}]}`. Fixes #818 --------- Co-authored-by: corescope-bot <bot@corescope> |
||
|
|
cb21305dc4 |
fix(channel-decrypt): replace AES-CBC ECB hack with pure-JS AES-128 ECB (P0) (#1021)
## P0: channel decryption broken on prod (`OperationError` in
`decryptECB`)
### Symptom
```
Uncaught (in promise) OperationError
at decryptECB (channel-decrypt.js:89)
at async Object.decrypt (channel-decrypt.js:181)
at async decryptCandidates (channels.js:568)
```
Channel message decryption fails for most ciphertext blocks in the
browser console on `analyzer.00id.net`.
### Root cause
The original `decryptECB()` simulated AES-128-ECB via Web Crypto AES-CBC
with a zero IV plus an appended dummy PKCS7 padding block (16 × `0x10`).
Web Crypto **always** validates PKCS7 padding on the decrypted output,
and after CBC-decrypting the dummy padding block it almost never
produces a valid PKCS7 sequence, so Chrome/Firefox throw
`OperationError`. There is no Web Crypto knob to disable that check —
and Web Crypto doesn't expose raw ECB at all.
This is a well-known dead end: every project that needs ECB in browsers
ends up with a small pure-JS AES core.
### Fix
- Vendor a minimal pure-JS **AES-128 ECB decrypt-only** core into
`public/vendor/aes-ecb.js`.
- **Source:** [aes-js](https://github.com/ricmoo/aes-js) by Richard
Moore — MIT License (cited in the header comment).
- **Trimmed to:** S-boxes, key expansion (FIPS-197 §5.2), inverse cipher
(FIPS-197 §5.3). No encrypt path. No other modes. No padding logic. ~150
lines.
- `decryptECB(key, ciphertext)` keeps the same API surface:
`Promise<Uint8Array | null>`. It now delegates to
`window.AES_ECB.decrypt(...)`.
- `verifyMAC` and `computeChannelHash` keep using Web Crypto
(HMAC-SHA256 / SHA-256 — no padding pathology).
- Wired `vendor/aes-ecb.js` into `public/index.html` immediately before
`channel-decrypt.js`.
### TDD
- **Red commit (`36f6882`)** — adds `test-channel-decrypt-ecb.js` pinned
to the **FIPS-197 Appendix C.1** AES-128 known-answer vector. Compiles,
runs, and fails on assertion (`OperationError`) against the existing
implementation.
- **Green commit (`bbbd2d1`)** — vendors the pure-JS AES core and
rewires `decryptECB`. Test now passes (7/7), including a multi-block
assertion that two identical ciphertext blocks decrypt to two identical
plaintext blocks (true ECB, no chaining).
- Existing `test-channel-decrypt-m345.js` still passes (24/24).
### Files changed
- `public/vendor/aes-ecb.js` — **new** (vendored AES-128 ECB decrypt,
MIT, ~150 LOC)
- `public/channel-decrypt.js` — `decryptECB()` rewritten to delegate to
vendor
- `public/index.html` — script tag added for `vendor/aes-ecb.js`
- `test-channel-decrypt-ecb.js` — **new** TDD test (FIPS-197 KAT +
multi-block + edge cases)
### Risk / scope
- Decrypt-only, client-side, no server changes, no schema changes, no
config changes (Config Documentation Rule N/A).
- ECB is a single 16-byte block per packet for MeshCore channel traffic,
so the perf delta vs Web Crypto is negligible (a single `decryptBlock`
is ~10 round transforms on 16 bytes).
- HTTP-context safe (no Web Crypto required for ECB anymore).
### Validation
- All 7 FIPS-197 KAT + multi-block tests pass.
- Existing channel-decrypt M3/M4/M5 tests still pass (24/24).
- `test-packet-filter.js` (62/62), `test-aging.js` (18/18) unaffected.
- `test-frontend-helpers.js` has a pre-existing failure on master
unrelated to this PR (verified by stashing the patch).
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
|
||
|
|
a56ee5c4fe |
feat(analytics): selectable timeframes via ?window/?from/?to (#842) (#1018)
## Summary Selectable analytics timeframes (#842). Adds backend support for `?window=1h|24h|7d|30d` and `?from=&to=` on the three main analytics endpoints (`/api/analytics/rf`, `/api/analytics/topology`, `/api/analytics/channels`), and a time-window picker in the Analytics page UI that drives them. Default behavior with no query params is unchanged. ## TDD trail - Red: `bbab04d` — adds `TimeWindow` + `ParseTimeWindow` stub and tests; tests fail on assertions because the stub returns the zero window. - Green: `75d27f9` — implements `ParseTimeWindow`, threads `TimeWindow` through `compute*` loops + caches, wires HTTP handlers, adds frontend picker + E2E. ## Backend changes - `cmd/server/time_window.go` — full `ParseTimeWindow` (`?window=` aliases + `?from=/&to=` RFC3339 absolute range; invalid input → zero window for backwards compatibility). - `cmd/server/store.go` — new `GetAnalytics{RF,Topology,Channels}WithWindow` wrappers; `compute*` loops skip transmissions whose `FirstSeen` (or per-obs `Timestamp` for the region+observer slice) falls outside the window. Cache key composes `region|window` so different windows do not poison each other. - `cmd/server/routes.go` — handlers call `ParseTimeWindow(r)` and dispatch to the `*WithWindow` methods. ## Frontend changes - `public/analytics.js` — new `<select id="analyticsTimeWindow">` rendered under the region filter (All / 1h / 24h / 7d / 30d). Selecting an option triggers `loadAnalytics()` which appends `&window=…` to every analytics fetch. ## Tests - `cmd/server/time_window_test.go` — covers all aliases, absolute range, no-params backwards compatibility, `Includes()` bounds, and `CacheKey()` distinctness. - `cmd/server/topology_dedup_test.go`, `cmd/server/channel_analytics_test.go` — updated callers to pass `TimeWindow{}`. ## E2E (rule 18) `test-e2e-playwright.js:592-611` — opens `/#/analytics`, asserts the picker is rendered with a `24h` option, then asserts that selecting `24h` triggers a network request to `/api/analytics/rf?…window=24h`. ## Backwards compatibility No params → zero `TimeWindow` → original code paths (no filter, region-only cache key). Verified by `TestParseTimeWindow_NoParams_BackwardsCompatible` and by the existing analytics tests still passing unchanged on `_wt-fix-842`. Fixes #842 --------- Co-authored-by: you <you@example.com> Co-authored-by: corescope-bot <bot@corescope> |
||
|
|
df69a17718 |
feat(#772): short pubkey-prefix URLs for mesh sharing (#1016)
## Summary Fixes #772 — adds a short-URL form for node detail pages so operators can paste node links into a mesh chat without bringing along a 64-hex-char public key. ## Approach **Pubkey-prefix resolution** (no allocator, no lookup table). - The SPA hash route `#/nodes/<key>` already accepts whatever pubkey-shaped string the user pastes; the front end forwards it to `GET /api/nodes/<key>`. - When that lookup misses **and** the path is 8..63 hex chars, the backend now calls `DB.GetNodeByPrefix` and: - returns the matching node when exactly one node has that prefix, - returns **409 Conflict** when multiple nodes share the prefix (with a "use a longer prefix" hint), - falls through to the existing 404 otherwise. - 8 hex chars = 32 bits of entropy, which is enough for fleets in the low thousands. Operators can extend to 10–12 chars if collisions become common. - The full-screen node detail card gets a new **📡 Copy short URL** button that copies `…/#/nodes/<first 8 hex chars>`. ### Why not an opaque ID table (`/s/<id>`)? Considered and rejected: - Needs persistence + an allocator + cleanup story. - IDs aren't self-describing — operators can't sanity-check them. - IDs don't survive a DB rebuild. - 32 bits of pubkey already buys us collision resistance with zero moving parts. If the directory grows past the point where 8-char prefixes routinely collide, we can extend the minimum length without changing the URL shape. ## Changes - `cmd/server/db.go` — new `GetNodeByPrefix(prefix)` returning `(node, ambiguous, error)`. Validates hex; rejects <8 chars; `LIMIT 2` to detect collisions cheaply. - `cmd/server/routes.go` — `handleNodeDetail` falls back to prefix resolution; canonicalizes pubkey downstream; emits 409 on ambiguity; honors blacklist on the resolved pubkey. - `public/nodes.js` — adds **📡 Copy short URL** button + handler on the full-screen node detail card. - `cmd/server/short_url_test.go` — Go tests (red-then-green). - `test-e2e-playwright.js` — E2E: navigates via prefix-only URL and asserts the new button surfaces. ## TDD evidence - Red commit: `2dea97a` — tests added with a stub `GetNodeByPrefix` returning `(nil, false, nil)`. All four assertions failed (assertion failures, not build errors): expected node got nil; expected ambiguous=true got false; route 404 vs expected 200/409. - Green commit: `9b8f146` — implementation lands; `go test ./...` passes locally in `cmd/server`. ## Compatibility - Existing 64-char pubkey URLs are untouched (exact lookup runs first). - Blacklist is enforced both on the raw input and on the resolved pubkey. - No new config knobs. ## What I did **not** touch - `cmd/server/db_test.go`, other route tests — unchanged. - Packet-detail short URLs (issue scopes nodes; revisit in a follow-up if asked). Fixes #772 --------- Co-authored-by: clawbot <bot@corescope.local> |
||
|
|
f229e15869 |
feat(packet-filter): transport boolean + T_FLOOD/T_DIRECT route aliases (#339) (#1014)
## Summary Adds Wireshark-style filter support for transport route type to the packets-page filter engine, per #339. ## New filter syntax | Filter | Matches | |---|---| | `transport == true` | route_type 0 (TRANSPORT_FLOOD) or 3 (TRANSPORT_DIRECT) | | `transport == false` | route_type 1 (FLOOD) or 2 (DIRECT) | | `transport` | bare truthy — same as `transport == true` | | `route == T_FLOOD` | alias for `route == TRANSPORT_FLOOD` | | `route == T_DIRECT` | alias for `route == TRANSPORT_DIRECT` | | `route == TRANSPORT_FLOOD` / `TRANSPORT_DIRECT` | already worked — canonical names | Aliases are case-insensitive (`route == t_flood` works). ## Implementation - `public/packet-filter.js`: new `transport` virtual boolean field driven by `isTransportRouteType(rt)` which returns `rt === 0 || rt === 3`, mirroring `isTransportRoute()` in `cmd/server/decoder.go`. - `ROUTE_ALIASES = { t_flood: 'TRANSPORT_FLOOD', t_direct: 'TRANSPORT_DIRECT' }` resolved in the equality comparator, same pattern as the existing `TYPE_ALIASES`. - All client-side; no backend changes (issue noted this). ## Tests / TDD Red commit: `9d8fdf0` — five new assertion-failing test cases + wires `test-packet-filter.js` into CI (it existed but wasn't being executed). Green commit: `c67612b` — implementation makes all 69 tests pass. The CI wiring is part of the red commit on purpose: previously `test-packet-filter.js` was never run by CI, so a frontend filter regression couldn't fail the build. Now it can. ## CI gating proof Run `git revert c67612b` locally → `node test-packet-filter.js` reports 5 assertion failures (not build/import errors). Re-applying the green commit returns all tests to passing. Fixes #339 --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
4d043579f8 |
feat: geofilter draft save (localStorage) + downloadable config snippet (#1006)
## Issue Closes #819 ## Summary Adds Save Draft / Load Draft / Download buttons to `/geofilter-builder.html` so operators can: - Persist their work-in-progress polygon across sessions (localStorage) - Reload it later to continue editing - Download a ready-to-paste `geo_filter` JSON snippet for `config.json` ## Implementation - New module `public/geofilter-draft.js` exposes `GeofilterDraft` global with `saveDraft / loadDraft / clearDraft / buildConfigSnippet / downloadConfig`. - Builder HTML wires three new buttons; updates the help text to document the new flow. ## TDD - Red commit: `b0a1a4c` (tests fail — module doesn't exist) - Green commit: `a717f33` (implementation added, all tests pass) ## How to test 1. Open `/geofilter-builder.html` 2. Click 3+ points on the map 3. Click "Save Draft" — reload page — click "Load Draft" → polygon restored 4. Click "Download" → `geofilter-config-snippet.json` downloaded with correct format --- E2E assertion added: test-e2e-playwright.js:2264 --------- Co-authored-by: you <you@example.com> Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
c186129d47 |
feat: parse and display per-hop SNR values for TRACE packets (#1007)
## Summary Parse and display per-hop SNR values from TRACE packets in the Packet Byte Breakdown panel. ## Changes ### Backend (`cmd/server/decoder.go`) - Added `SNRValues []float64` field to Payload struct (`json:"snrValues,omitempty"`) - In the TRACE-specific block, extract SNR from header path bytes before they're overwritten with route hops - Each header path byte is `int8(SNR_dB * 4.0)` per firmware — decode by dividing by 4.0 ### Frontend (`public/packets.js`) - Added "SNR Path" section in `buildFieldTable()` showing per-hop SNR values in dB when packet type is TRACE - Added TRACE-specific payload rendering (trace tag, auth code, flags with hash_size, route hops) ## TDD - Red commit: `4dba4e8` — test asserts `Payload.SNRValues` field (compile fails, field doesn't exist) - Green commit: `5a496bd` — implementation passes all tests ## Testing - `go test ./...` passes (all existing + 2 new TRACE SNR tests) - No frontend test changes needed (no existing TRACE UI tests; rendering is additive) Fixes #979 --------- Co-authored-by: you <you@example.com> |
||
|
|
23d1e8d328 |
feat: add flood/direct packet filter to observer comparison page (#1000)
## Summary Adds a **Flood / Direct packet filter** dropdown to the observer comparison page. This addresses the issue that direct packets (heard by only one observer) skew the comparison percentages. ## Changes - **`public/compare.js`**: Added `filterPacketsByRoute(packets, mode)` function and a "Packet Type" dropdown (All / Flood only / Direct only) to the comparison controls. Changing the filter re-runs the comparison with filtered packets. - **`test-compare-flood-filter.js`**: Unit tests for the filter function. ## Route type mapping (from firmware) | Route Type | Value | Filter | |---|---|---| | TransportFlood | 0 | Flood | | Flood | 1 | Flood | | Direct | 2 | Direct | | TransportDirect | 3 | Direct | ## TDD - Red commit: `484fa72` (test only, fails) - Green commit: `5661f71` (implementation, tests pass) Fixes #928 --------- Co-authored-by: you <you@example.com> |
||
|
|
e86b5a3a0c |
feat: show multi-byte hash support indicator on map markers (#1002)
## Summary Show 2-byte hash support indicator on map markers. Fixes #903. ## What changed ### Backend (`cmd/server/store.go`, `cmd/server/routes.go`) - **`EnrichNodeWithMultiByte()`** — new enrichment function that adds `multi_byte_status` (confirmed/suspected/unknown), `multi_byte_evidence` (advert/path), and `multi_byte_max_hash_size` fields to node API responses - **`GetMultiByteCapMap()`** — cached (15s TTL) map of pubkey → `MultiByteCapEntry`, reusing the existing `computeMultiByteCapability()` logic that combines advert-based and path-hop-based evidence - Wired into both `/api/nodes` (list) and `/api/nodes/{pubkey}` (detail) endpoints ### Frontend (`public/map.js`) - Added **"Multi-byte support"** checkbox in the map Display controls section - When toggled on, repeater markers change color: - 🟢 Green (`#27ae60`) — **confirmed** (advertised with hash_size ≥ 2) - 🟡 Yellow (`#f39c12`) — **suspected** (seen as hop in multi-byte path) - 🔴 Red (`#e74c3c`) — **unknown** (no multi-byte evidence) - Popup tooltip shows multi-byte status and evidence for repeaters - State persisted in localStorage (`meshcore-map-multibyte-overlay`) ## TDD - Red commit: `2f49cbc` — failing test for `EnrichNodeWithMultiByte` - Green commit: `4957782` — implementation + passing tests ## Performance - `GetMultiByteCapMap()` uses a 15s TTL cache (same pattern as `GetNodeHashSizeInfo`) - Enrichment is O(n) over nodes, no per-item API calls - Frontend color override is computed inline during existing marker render loop — no additional DOM rebuilds --------- Co-authored-by: you <you@example.com> |
||
|
|
8dfcec2ff3 |
feat: include favorites and claimed nodes in export/import JSON (#1003)
## Summary
Extends the customizer v2 export/import to include favorite nodes and
claimed ("My Mesh") nodes, so users can transfer their full setup
between browsers/devices.
## Changes
### `public/customize-v2.js`
- `readOverrides()` now merges `favorites` (from `meshcore-favorites`)
and `myNodes` (from `meshcore-my-nodes`) into the exported JSON
- `writeOverrides()` extracts `favorites`/`myNodes` arrays and writes
them to their respective localStorage keys, keeping theme overrides
separate
- `validateShape()` validates both new keys as arrays, rejecting
non-array values
- `VALID_SECTIONS` updated to include `favorites` and `myNodes`
### `test-customizer-v2.js`
- 8 new tests covering read/write/validate for both favorites and
myNodes
## TDD
- Red commit: `0405fb7` (failing tests)
- Green commit: `bb9dc34` (implementation)
Fixes #895
---------
Co-authored-by: you <you@example.com>
|
||
|
|
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> |
||
|
|
aea0a9caee |
fix(packets): preserve scroll position on filter change + group expand/collapse (closes #431) (#996)
## Summary Closes #431. Preserves scroll position on the packets page when filters change or groups are expanded/collapsed. ## Problem When an operator scrolls down through packet history then changes a filter (type, observer, packet-filter expression) or expands/collapses a group, `renderTableRows()` rebuilds the DOM which resets `scrollTop` to 0. This forces the user back to the top — frustrating when digging through hundreds of packets. ## Fix Save `scrollContainer.scrollTop` at the start of `renderTableRows()`, restore it after DOM rebuild completes. Two restore points: 1. **Empty-results path** (line ~1821): after `tbody.innerHTML = ...` 2. **Normal virtual-scroll path** (line ~1840): after `renderVisibleRows()` ### Key lines changed - `public/packets.js` lines 1748–1749: save scrollTop - `public/packets.js` line 1821: restore after empty-state DOM write - `public/packets.js` line 1840: restore after renderVisibleRows ## TDD evidence - **Red commit:** |
||
|
|
736b09697d |
fix(analytics): apply customizer timestamp format to chart axes (closes #756) (#981)
## Summary Fixes #756 — the customizer timestamp format setting (ISO/ISO+ms/locale) and timezone (UTC/local) were not applied to chart X-axis labels, tooltips, or certain inline timestamps in the analytics pages. ## Changes ### `public/app.js` - Added `formatChartAxisLabel(date, shortForm)` — a shared helper that reads the customizer's `timestampFormat` and `timestampTimezone` preferences and formats dates for chart axes accordingly. `shortForm=true` returns time-only (for intra-day charts), `shortForm=false` returns date+time (for multi-day ranges). ### `public/analytics.js` - `rfXAxisLabels()`: now calls `formatChartAxisLabel()` instead of hardcoded `toLocaleTimeString()` - `rfTooltipCircles()`: tooltip timestamps now use `formatAbsoluteTimestamp()` instead of raw ISO - Subpath detail first/last seen: now uses `formatAbsoluteTimestamp()` - Neighbor graph last_seen: now uses `formatAbsoluteTimestamp()` ### `public/node-analytics.js` - Packet timeline chart labels: now use `formatChartAxisLabel()` (respects short vs long form based on time range) - SNR over time chart labels: now use `formatChartAxisLabel()` ## Behavior by setting | Setting | Chart axis (short) | Chart axis (long) | |---------|-------------------|-------------------| | ISO | `14:30` | `05-03 14:30` | | ISO+ms | `14:30:05` | `05-03 14:30:05` | | Locale | `2:30 PM` | `May 3, 2:30 PM` | All respect the UTC/local timezone toggle. ## Testing - Server builds cleanly (`go build`) - Served `app.js` contains `formatChartAxisLabel` (verified via curl) - Graceful fallback: all callsites check `typeof formatChartAxisLabel === 'function'` before calling, preserving backward compat if script load order changes --------- Co-authored-by: you <you@example.com> |
||
|
|
53ab302dd6 |
fix(packets): clear-filters button (rebased + addresses greybeard) (closes #964) (#975)
Rebased version of #973 onto current master, with greybeard review fixes. ## Changes from #973 - **Stowaway revert dropped**: The original PR branched from older master and inadvertently reverted PR #926's MQTT connect-retry fix (`cmd/ingestor/main.go` + `cmd/ingestor/main_test.go`). After rebasing onto current master (which includes #926 + #970), these files no longer appear in the diff. - **Greybeard M1 fixed**: Time-window filter (`savedTimeWindowMin`, `fTimeWindow` dropdown, `localStorage 'meshcore-time-window'`) is now reset by the clear-filters button. The clear-button visibility predicate also accounts for non-default time window. - **Greybeard m1 fixed**: Replaced 7 tautological source-grep tests with 8 behavioral vm-sandbox tests that extract and execute the actual clear handler + `updatePacketsUrl`, asserting real state transitions. ## Original feature (from #973) Clear-filters button for the packets page — resets all filter state (hash, node, observer, channel, type, expression, myNodes, time window, region) and refreshes. Button visibility auto-toggles based on active filter state. Closes #964 Supersedes #973 ## Tests - `node test-clear-filters.js` — 8 behavioral tests ✅ - `node test-packets.js` — 82 tests ✅ - `cd cmd/ingestor && go test ./...` — ✅ --------- 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> |
||
|
|
4f0f7bc6dd |
fix(ui): fill remaining gaps in payload-type lookup tables (10/11/15) (#967)
## Summary Fill the remaining gaps in payload-type lookup tables noted out-of-scope on #965. Every firmware-defined payload type (0–11, 15) now has entries in all four frontend tables. ## Changes Three types were missing from one or more tables: | Type | Name | `PAYLOAD_COLORS` (app.js) | `TYPE_NAMES` (packets.js) | `TYPE_COLORS` (roles.js) | `TYPE_BADGE_MAP` (roles.js) | |------|------|--------------------------|--------------------------|-------------------------|---------------------------| | 10 | Multipart | added | added | added `#0d9488` | added | | 11 | Control | added | ✅ (already) | added `#b45309` | added | | 15 | Raw Custom | added | added | added `#c026d3` | added | ## Color choices - **MULTIPART** `#0d9488` (teal) — multi-fragment stitching, distinct from PATH's `#14b8a6` - **CONTROL** `#b45309` (amber) — warm brown, distinct hue from ACK's grey `#6b7280` - **RAW_CUSTOM** `#c026d3` (fuchsia) — magenta, distinct from TRACE's pink `#ec4899` All pass WCAG 3:1 contrast against both white and dark (#1e1e1e) backgrounds. ## Tests - `test-packets.js`: 82/82 ✅ - `test-hash-color.js`: 32/32 ✅ Badge CSS auto-generation: `syncBadgeColors()` in `roles.js` iterates `TYPE_BADGE_MAP` keyed against `TYPE_COLORS`, so the three new entries automatically get `.type-badge.multipart`, `.type-badge.control`, and `.type-badge.raw-custom` CSS rules injected at page load. Firmware source: `firmware/src/Packet.h:19-32` — types 0x00–0x0B and 0x0F. Types 0x0C–0x0E are not defined. Follows up on #965. --------- Co-authored-by: you <you@example.com> |
||
|
|
b47587f031 |
feat(#690): expose observer skew + per-hash evidence in clock UI (#906)
## Summary UI completion of #690 — surfaces observer clock skew and per-hash evidence that the backend already computes but wasn't exposed in the frontend. **Not related to #845/PR #894** (bimodal detection) — this is the UI surface for the original #690 scope. ## Changes ### Backend: per-hash evidence in node clock-skew API (commit 1) - Extended `GET /api/nodes/{pubkey}/clock-skew` to return `recentHashEvidence` (most recent 10 hashes with per-observer raw/corrected skew and observer offset) and `calibrationSummary` (total/calibrated/uncalibrated counts). - Evidence is cached during `ClockSkewEngine.Recompute()` — route handler is cheap. - Fleet endpoint omits evidence to keep payload small. ### Frontend: observer list page — clock offset column (commit 2) - Added "Clock Offset" column to observers table. - Fetches `/api/observers/clock-skew` once on page load, joins by ObserverID. - Color-coded severity badge + sample count tooltip. - Singleton observers show "—" not "0". ### Frontend: observer-detail clock card (commit 3) - Added clock offset card mirroring node clock card style. - Shows: offset value, sample count, severity badge. - Inline explainer describing how offset is computed from multi-observer packets. ### Frontend: node clock card evidence panel (commit 4) - Collapsible "Evidence" section in existing node clock skew card. - Per-hash breakdown: observer count, median corrected skew, per-observer raw/corrected/offset. - Calibration summary line and plain-English severity reason at top. ## Test Results ``` go test ./... (cmd/server) — PASS (19.3s) go test ./... (cmd/ingestor) — PASS (31.6s) Frontend helpers: 610 passed, 0 failed ``` New test: `TestNodeClockSkew_EvidencePayload` — 3-observer scenario verifying per-hash array shape, corrected = raw + offset math, and median. No frontend JS smoke test added — no existing test harness for clock/observer rendering. Noted for future. ## Screenshots Screenshots TBD ## Perf justification Evidence is computed inside the existing `Recompute()` cycle (already O(n) on samples). The `hashEvidence` map adds ~32 bytes per sample of memory. Evidence is stripped from fleet responses. Per-node endpoint returns at most 10 evidence entries — bounded payload. --------- Co-authored-by: you <you@example.com> |