mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-13 07:55:41 +00:00
2ee09142d1631927dbcc33bfdf696e485faa748e
780 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
c67f3347ce |
fix(ui): add GRP_DATA (type 6) to filter dropdown + color tables (#965)
## Bug Packet type 6 (`PAYLOAD_TYPE_GRP_DATA` per `firmware/src/Packet.h:25`) was missing from three frontend lookup tables: - `public/app.js:7` — `PAYLOAD_COLORS` had no entry for 6 → badge color fell back to `unknown` (grey) - `public/packets.js:29` — `TYPE_NAMES` (used by the Packets page type-filter dropdown) had no entry for 6 → "Group Data" missing from the menu - `public/roles.js:17,24` — `TYPE_COLORS` and `TYPE_BADGE_MAP` had no `GRP_DATA` entry → no dedicated CSS class The packet detail page already handled it (via `PAYLOAD_TYPES` in `app.js:6` which had `6: 'Group Data'`) so individual GRP_DATA packets render correctly. The gap was only in the filter UI + badge styling. ## Fix Add the missing entry in each table. 4 lines across 3 files. - `app.js`: add `6: 'grp-data'` to `PAYLOAD_COLORS` - `packets.js`: add `6:'Group Data'` to `TYPE_NAMES` - `roles.js`: add `GRP_DATA: '#8b5cf6'` to `TYPE_COLORS` and `GRP_DATA: 'grp-data'` to `TYPE_BADGE_MAP` Color choice `#8b5cf6` (violet) — distinct from GRP_TXT's blue but visually adjacent so operators read them as related types. ## Verification (rule 18 + 19) Built server locally, served the JS files, grepped the rendered output: ``` $ curl -s http://localhost:13900/packets.js | grep TYPE_NAMES const TYPE_NAMES = { ... 5:'Channel Msg', 6:'Group Data', 7:'Anon Req' ... }; $ curl -s http://localhost:13900/app.js | grep PAYLOAD_TYPES const PAYLOAD_TYPES = { ... 5: 'Channel Msg', 6: 'Group Data', 7: 'Anon Req' ... }; $ curl -s http://localhost:13900/roles.js | grep GRP_DATA ADVERT: '#22c55e', GRP_TXT: '#3b82f6', GRP_DATA: '#8b5cf6', ... ADVERT: 'advert', GRP_TXT: 'grp-txt', GRP_DATA: 'grp-data', ... ``` Frontend tests pass: `test-packets.js` 82/82, `test-hash-color.js` 32/32. ## Out of scope Consolidating the duplicated PAYLOAD_TYPES / TYPE_NAMES tables into a single source of truth is a separate cleanup. Two parallel name maps continues to be a footgun (this is the second time a new type's been added to one but not the other). Co-authored-by: Kpa-clawbot <bot@example.invalid> |
||
|
|
04c8558768 |
fix(spa): data-loaded setAttribute in finally so it fires on errors (#959)
## Bug PR #958 added `data-loaded="true"` attributes for E2E sync, but placed the `setAttribute` call inside the `try` block of `loadNodes()` / `loadPackets()` / `loadNodes()` (map). When the API call failed (e.g. `/api/observers` returns 500, or any other exception), the `catch` swallowed the error and `setAttribute` was never reached. E2E tests then waited 15s for `[data-loaded="true"]` and timed out. This blocked PR #954 CI repeatedly with `Map page loads with markers: page.waitForSelector: Timeout 15000ms exceeded`. ## Fix Move `setAttribute('data-loaded', 'true')` to a `finally` block in all three handlers (`map.js`, `nodes.js`, `packets.js`). The attribute now fires on both success and error paths, so E2E tests proceed (test still asserts on the actual rendered state — markers, rows, etc — so an empty page still fails the right assertion, just much faster). Removed the duplicate setAttribute calls inside the try blocks (the finally is the single source of truth now). ## Verification - `node test-packets.js` 82/82 ✅ - `node test-hash-color.js` 32/32 ✅ - Code reading: each `finally` runs after either success or catch, sets the same attribute on the same container element. ## Why CI didn't catch this on #958 The PR #958 tests passed because the staging fixture happened to load successfully when those tests ran. The flake only manifests when an upstream fetch fails (e.g. observer API returning unexpected shape, network blip, server still warming). Co-authored-by: Kpa-clawbot <bot@example.invalid> |
||
|
|
053aef1994 |
fix(spa): decouple navigate() from theme fetch + add data-loaded sync attributes (#955) (#958)
## Summary Fixes the chained async init race identified in RCA #3 of #955. `navigate()` (which dispatches page handlers and fetches data) was gated behind `/api/config/theme` resolving via `.finally()`. Tests use `waitUntil: 'domcontentloaded'` which returns BEFORE theme fetch resolves, creating a race condition where 3+ serial network requests must complete before any DOM rows appear. ## Changes ### Decouple navigate() from theme fetch (public/app.js) - Move `navigate()` call out of the theme fetch `.finally()` block - Call it immediately on DOMContentLoaded — theme is purely cosmetic and applies in parallel ### Add data-loaded sync attributes (public/nodes.js, map.js, packets.js) - Set `data-loaded="true"` on the container element after each page's data fetch resolves and DOM renders - Nodes: set on `#nodesLeft` after `loadNodes()` renders rows - Map: set on `#leaflet-map` after `renderMarkers()` completes - Packets: set on `#pktLeft` after `loadPackets()` renders rows ### Update E2E tests (test-e2e-playwright.js) - Add `await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 })` before row/marker assertions - Increase map marker timeout from 3s to 8s as additional safety margin - Tests now synchronize on data readiness rather than racing DOM appearance ## Verification - Spun up local server on port 13586 with e2e-fixture.db - Confirmed navigate() is called immediately (not gated on theme) - Confirmed data-loaded attributes are present in served JS - API returns data correctly (2 nodes from fixture) Closes #955 (RCA #3) Co-authored-by: you <you@example.com> |
||
|
|
7bb5ff9a7f |
fix(e2e): tag flying-packet polyline so test selector doesn't pick up geofilter polygons (#953)
## Bug Master CI failing on `Map trace polyline uses hash-derived color when toggle ON`. The test selector `path.leaflet-interactive` was too broad — it matched **geofilter region polygons** (`L.polygon` calls in `live.js:1052`/`map.js:327`), which are styled with theme variables, not `hsl()`. None of those polygons have an `hsl(` stroke, so the assertion failed even though the actual flying-packet polylines DO use hash colors correctly. ## Fix 1. Tag flying-packet polylines with a dedicated class `live-packet-trace` (`public/live.js:2728`). 2. Update the test selector to target that class specifically. 3. Treat "no flying-packet polylines drawn in the test window" as SKIP (not fail) — animation may not trigger in 3s. ## Verification (rule 18) - Read implementation at `live.js:2724-2729`: polyline color IS set from `hashFill` when toggle is ON. The implementation is correct. - Read polygon callers at `live.js:1052` (geofilter regions) — confirmed they share the same `path.leaflet-interactive` class. - The test was selecting wrong DOM nodes; fix narrows to dedicated class. No code logic changed — only DOM tagging + test selector. Co-authored-by: Kpa-clawbot <bot@example.invalid> |
||
|
|
b9758111b0 |
feat(hash-color): bright vivid fill + dark outline + live feed/polyline surfaces (#951)
## Hash-Color: Bright Vivid Fill + Dark Outline + Extended Surfaces Follow-up to #948 (merged). Revises the hash-color algorithm for better perceptual discrimination and extends hash coloring to additional Live page surfaces. ### Algorithm Changes (`public/hash-color.js`) - **Hue**: bytes 0-1 (16-bit → 0-360°) — unchanged - **Saturation**: byte 2 (55-95%) — NEW, was fixed 70% - **Lightness**: byte 3 (light 50-65%, dark 55-72%) — NEW, was fixed L=30/38/65 - **Outline** (`hashToOutline`): same-hue dark color (L=25% light, L=15% dark) — NEW - Sentinel threshold raised to 8 hex chars (need 4 bytes of entropy) - Drops WCAG fill-darkening approach — outline carries contrast instead ### Live Page Updates (`public/live.js`) - **Dot marker**: uses `hashToOutline()` for stroke (was TYPE_COLOR) - **Polyline trace**: uses hash fill color (unified dot + trace by hash) - **Feed items**: 4px `border-left` stripe matching packets table ### Test Updates - `test-hash-color.js`: 32 tests (S variability, L variability, outline < fill, same hue, pairwise distance) - `test-e2e-playwright.js`: 2 new assertions (feed stripe, polyline hsl stroke) ### Verification - 20 real advert hashes from fixture DB: all produce unique hues (20/20) - Pairwise HSL distance: avg=0.51, min=0.04 - Go server built and run against fixture DB — HTML serves updated module - VM sandbox render-check confirms distinct vivid fills with darker outlines Closes #946 §2.10/§2.11 scope extension. --------- Co-authored-by: you <you@example.com> Co-authored-by: Kpa-clawbot <bot@example.invalid> |
||
|
|
0a9a4c4223 |
feat(live + packets): color packet markers by hash (#946) (#948)
## Summary Implements #946 — deterministic HSL coloring of packet markers by hash for visual propagation tracing. ### What's new 1. **`public/hash-color.js`** — Pure IIFE (`window.HashColor.hashToHsl(hashHex, theme)`) deriving hue from first 2 bytes of packet hash. Theme-aware lightness with WCAG ≥3.0 contrast against `--content-bg` (`#f4f5f7` light / `#0f0f23` dark, `style.css:32,55`). Green/yellow zone (hue 45°-195°) uses L=30% in light theme to maintain contrast. 2. **Live page dots + contrails** — `drawAnimatedLine` fills the flying dot and tints the contrail polyline with the hash-derived HSL when toggle is ON. Ghost-hop dots remain grey (`#94a3b8`). Matrix mode path (`drawMatrixLine`) is untouched. 3. **Packets table stripe** — `border-left: 4px solid <hsl>` on `<tr>` in both `buildGroupRowHtml` (group + child rows) and `buildFlatRowHtml`. Absent when toggle OFF. 4. **Toggle UI** — "Color by hash" checkbox in `#liveControls` between Realistic and Favorites. Default ON. Persisted to `localStorage('meshcore-color-packets-by-hash')`. Dispatches `storage` event for cross-tab sync. Packets page listens and re-renders. ### Performance - `hashToHsl` is O(1) — two `parseInt` calls + arithmetic. No allocation beyond the result string. - Called once per `drawAnimatedLine` invocation (not per animation frame). - Packets table: called once per visible row during render (existing virtualization applies). ### Tests - `test-hash-color.js`: 16 unit tests — purity, theme split, yellow-zone clamp, sentinel, variability (anti-tautology gate), WCAG sweep (step 15° both themes). - `test-packets.js`: 82 tests still passing (no regression). - `test-e2e-playwright.js`: 4 new E2E tests — toggle presence/default, persistence across reload, table stripe present when ON, absent when OFF. ### Acceptance criteria addressed All items from spec §6 implemented. TYPE_COLORS retained on borders/lines. Ghost hops stay grey. Matrix mode suppressed. Cross-tab storage event dispatched. Closes #946 --------- Co-authored-by: you <you@example.com> Co-authored-by: Kpa-clawbot <bot@example.invalid> |