mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 21:41:25 +00:00
fe997fefb2b083062ee252f957b147cd6a3de106
435 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
777f77a451 |
feat(#1420): dark-tile provider picker in customizer (4 variants) (#1430)
# feat(#1420): dark-tile provider picker in customizer (4 variants) Closes #1420. ## What Operator pick: don't force a single dark-tile choice on everyone. Wire 4 candidates into the customizer + server config so users can choose which dark basemap they want, with per-browser persistence. ## Providers shipped | ID | Source | Filter | |---|---|---| | `carto-dark` (default) | `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` | none | | `esri-darkgray-labels` | Esri Dark Gray Base + Reference (two stacked layers) | none | | `voyager-inverted` | Carto Voyager + CSS `invert(1) hue-rotate(180deg) brightness(0.9) contrast(1.05)` on `.leaflet-tile-pane` | applied in dark, cleared in light | | `positron-inverted` | Carto Positron + same CSS invert | applied in dark, cleared in light | No new dependencies — all providers are URL-only. ## Architecture - **`public/map-tile-providers.js`** — registry + 5 public helpers (`MC_TILE_PROVIDERS`, `MC_setDarkTileProvider`, `MC_getDarkTileProvider`, `MC_setServerDefaultTileProvider`, `MC_applyTileFilter`). Persists to `localStorage['mc-dark-tile-provider']`. Dispatches `mc-tile-provider-changed` on user pick. - **`public/map.js` / `public/live.js`** — resolve the active dark provider via the registry, manage the Esri labels overlay lifecycle (add when needed, remove cleanly so we don't leak layers on repeated theme toggles), and apply/clear the CSS filter on `.leaflet-tile-pane`. Listen for both `data-theme` mutations AND `mc-tile-provider-changed`. - **`public/customize-v2.js`** — new "Dark Map Tiles" dropdown in the Display tab. On change, calls `MC_setDarkTileProvider(id)`; the maps re-render live without reload. - **`public/roles.js`** — hydrates the server default via `MC_setServerDefaultTileProvider` from `/api/config/client`. - **Server (`cmd/server/`)** — new `mapDarkTileProvider` string on `Config` + surfaced in `ClientConfigResponse`. Default empty → client uses `carto-dark`. - **`config.example.json`** — documents the new field with all allowed values. ## Behavior guarantees (from the acceptance criteria) - ✅ Light mode is **completely unchanged** — `_resolveTileUrl(false)` short-circuits to `TILE_LIGHT` with no filter and no overlay logic. - ✅ Switching dark→light always clears the CSS filter, even if an inverted provider remains selected (`MC_applyTileFilter` is called on every theme change and early-returns to `style.filter = ''` when not dark). - ✅ Switching light→dark with an inverted provider re-applies the filter. - ✅ Attribution is updated per provider (Esri credit for Esri, CartoDB credit for the others); the Leaflet attribution control is refreshed. - ✅ Esri uses two stacked layers (base + reference labels). The reference layer is added/removed cleanly so repeat toggles do not leak. - ✅ Customizer change → immediate re-render, no reload. Uses the same "live setting + persist + dispatch event" pattern as cb-presets (#1361). ## TDD - Red commit: `148b71c3` — `test(#1420): add failing tests for dark-tile provider registry (red)` — 6/7 assertions fail (stub only returns nulls). - Green commit: `49ffb230` — `feat(#1420): dark-tile provider picker — 4 variants wired into customizer` — 7/7 pass. ## Tests `test-issue-1420-tile-providers.js` (wired into `test-all.sh` and `.github/workflows/deploy.yml` JS-unit step): ``` ── #1420 Dark-tile provider registry ── ✅ MC_TILE_PROVIDERS has all 4 IDs with url + attribution ✅ Inverted providers have non-null invertFilter; non-inverted have null ✅ MC_setDarkTileProvider persists to localStorage and dispatches mc-tile-provider-changed ✅ MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch) ✅ MC_getDarkTileProvider falls back to server default, then carto-dark ✅ Apply filter for inverted provider in dark mode; clear when switching to non-inverted ✅ Light mode always clears the CSS filter even if inverted provider is selected 7 passed, 0 failed ``` `cd cmd/server && go build ./... && go vet ./...` — clean. ## CDP verification Not run in this PR — the sandbox does not have a Chrome CDP endpoint reachable, and staging cannot exercise this code path until this branch is deployed. The issue body's "CDP-verified candidate set" table covers prior provider-URL validation; the new code path (registry lookup + filter swap + Esri overlay lifecycle) is covered by the unit tests above. **Recommend operator run a quick manual verification on staging post-deploy:** dark mode → open customizer → cycle through all 4 providers, confirm tiles render and the CSS filter is applied for `voyager-inverted` / `positron-inverted` (verify via `getComputedStyle(document.querySelector('.leaflet-tile-pane')).filter`). ## Files touched - `public/map-tile-providers.js` (new) - `public/map.js`, `public/live.js`, `public/customize-v2.js`, `public/roles.js`, `public/index.html` - `cmd/server/config.go`, `cmd/server/routes.go`, `cmd/server/types.go` - `config.example.json` - `test-issue-1420-tile-providers.js` (new), `test-all.sh`, `.github/workflows/deploy.yml` - `.eslintrc.json` (register new `MC_*` globals) --------- Co-authored-by: openclaw <bot@openclaw.local> |
||
|
|
77d1925f30 |
Route view v2 — Tufte redesign (packet context, multi-path picker, mobile bottom-sheet, CB-preset live colors) (#1423)
# Route view v2 redesign Fixes #1418, Fixes #1419, Fixes #1422 This is the route-view redesign that came out of a long iterative QA cycle. The first commit (`a3c39636`) landed the v1 sidebar timeline + multi-path baseline; this PR's second commit (`0e2e913f`) is the v2 polish covering packet context, multi-path picker, mobile bottom-sheet, CB-preset live colors, and dozens of operator-driven UX fixes. ## The journey, in one line > "The data is a sequence. Geography is annotation. The packet is the cargo, the route is the road — show both." ## New surfaces ### 1. Packet context block (sidebar header) Above the multi-path chip, a per-type fact list explaining **what** is traveling. Operator was tired of "the route view shows the road but not the cargo." | Type | Chip | Facts | |-------------|-----------------|---------------------------------------------------------| | ADVERT | 📡 ADVERT | name · role · sig ✓ · self-reported GPS · pubkey prefix | | TXT_MSG | ✉ DM | src → dst · 🔒 encrypted | | REQ/RESPONSE| 🔒/🔓 REQUEST/…| src → dst · 🔒 encrypted | | GRP_TXT | # CHANNEL MSG | #channel · 🔓 decrypted · "…content preview…" · sender | | TRACE | ⌖ TRACE | Official: N hops · Observed: M | | PATH | 🔀 PATH | src → dst (with "from payload" chip on SRC/DST rows) | Sources merge `pkt.decoded_json` + `obs.decoded_json` (channel data often lives at packet level) and fall back to byte-level `raw_hex` parsing for encrypted DMs and unkeyed channel msgs. ### 2. Multi-path picker The header lists every unique observer-path with `<count>/<total>` chip + hex hop string. Click a path → full-clear and redraw that path only (Tufte v6's "replace + retain subpath weights"). "All" → edge-deduplicated UNION view (each unique edge drawn once, stroke = observer count, single accent color, no seq numbers because there's no single ordering). ### 3. Deep-link URLs `#/map?packet=<hash>&obs=<id>` — bookmarkable, shareable, the single source of truth. sessionStorage flow removed. "Back to packet" preserves the obs id. ### 4. Hop resolution Priority: server `resolved_path` → shared `window.HopResolver` (same resolver as packets page, observer-IATA-aware) → raw prefix. Eliminates a whole class of "route view named hops differently than packet detail" bugs. ### 5. Markers (v5/v6/v7) - All markers same 22 px filled circle, seq number rendered **inside** - SRC + DST get a 2 px hollow endpoint ring - SRC = DST loop → **double concentric ring** (ring grammar extended, no new glyph) - Spider-fan within 14 px collisions (16 px arc, dashed hairline), re-runs on `zoomend` only, debounced ### 6. CB preset live colors - Each preset gets a `routeRamp` (5 stops): default/trit = viridis, deut/prot = plasma, achromat = pure luminance - `cb-presets.js` writes `--mc-rt-ramp-0..4` CSS vars; route reads them via `getComputedStyle` - `cb-preset-changed` + `theme-changed` listeners hot-recolor without re-render ### 7. Desktop chrome - **Resize handle** on right edge of sidebar (drag, persisted to `localStorage["mc-rt-sidebar-width"]`) - **Collapse button** = round chevron **centered on the right edge** (Material/Drive style — not in the top-right corner, doesn't collide with the close X) - Collapsed = 36 px strip with rotated "ROUTE" label, expand on click ### 8. Mobile (bottom sheet) - Anchored above bottom-nav (`bottom: 56px + safe-area-inset`) - Collapsed = thin summary line `TYPE · N hops · X km · M obs` + hex preview, tap chevron to expand to ~75 vh - Drag-grip removed (conflicted with browser pull-to-refresh + CoreScope's own pull-to-reconnect) - Desktop collapse / resize affordances hidden on mobile (sheet is the mobile collapse affordance) - Map controls toggle floats top-right, panel collapses on route entry, reachable via toggle click - All three mobile detail panels (`pktRight`, `.slide-over-panel`, `#mobileDetailSheet`) explicitly closed when entering route view ### 9. Map fit / centering - Manual layer-children walk because `L.LayerGroup.getBounds()` doesn't aggregate (only `FeatureGroup` does) - Mobile padding: `paddingTopLeft: [30, 70]`, `paddingBottomRight: [30, 190]` to clear top-nav + sheet+nav stack - Re-fits on: initial render, isolate, All, `window.resize` (iOS URL-bar collapse) - Staggered timers 0/200/600/1400 ms (and 2800 ms on initial render) to survive layout settles ### 10. Hop drill-in refinements - SNR sparkline suppresses connecting polyline when n < 3 (two points implies a trend across time it can't represent — dots only) - "Node details" link properly chip-styled with aria-label including node name + route count ## Edge weight scales | View | Range | |---------------------------------|----------------| | Single-path | 5 px flat | | Multi-path interior | 3..9 | | Origin→hop1 / last-hop→dest | proxy via max adjacent edge count | | Union overlay | 2..8 | Boundary edges (SRC→first hop, last hop→DST) used to render thin because `edgeCounts` only tracks `path_json` transitions. Now they take the strongest adjacent edge count as proxy (every observer who saw the packet implicitly transited that boundary edge). ## Files - **NEW** `public/route-tufte.js` (~1700 lines) — the route renderer + sidebar - **NEW** `public/route-tufte.css` (~750 lines) — all styling - **MOD** `public/map.js` — async draw functions, deep-link loader, `__mc_nodes` exposure, raw_hex extraction - **MOD** `public/packets.js` — View Route → deep-link URL only, closes all mobile panels - **MOD** `public/cb-presets.js` — `routeRamp` per preset + CSS var write - **MOD** `public/index.html` — script + stylesheet tags ## Testing Manually CDP-validated across desktop and mobile-emulator viewports for every major change. Fixtures cover: - ADVERT (4 hops, single-obs) - DM (TXT_MSG, raw_hex parse) - GRP_TXT (#test channel, decrypted text) - PATH (operator's bug case) - TRACE (3-hop) - 1-hop edge case - Multi-path (75-observer 4-hop with 47 unique paths) - 32-hop stress - Loop (SRC = DST) - Bay Area dense cluster (spider-fan) Per AGENTS.md net-new-UI exemption, no failing-test-first; existing tests stay green. **TODO**: Playwright E2E follow-up PR. ## What's deferred to v2.1 / follow-ups - **Glyph overlay on SRC marker** for packet type (e.g. 📡 corner glyph on ADVERT marker, ⌖ on TRACE) - **Per-hop SNR sparkline for TRACE packets** (their payload contains real per-hop SNR contributions, distinct from observer-derived SNR) - **GRP_TXT full content preview** (currently truncated at 80 chars; could expand inline) - **Playwright E2E test** covering the deep-link → isolate → All flow ## Screenshots (would be useful here — CDP screenshots captured during dev show: desktop with sidebar + multi-path picker, mobile with bottom sheet + overlay toggle, isolated-path view, union view, spider-fan on Bay Area cluster, packet context for each of the 5 main types) ## Operator's frustration patterns (lessons for next time) 1. **Browser-validate every UI change, not just compute state** — CDP-screenshot before claiming a UI fix is done. Verifying `display:none` resolves correctly is necessary but not sufficient; the visual layout matters. 2. **Edge-deduplicated drawing beats per-path overlays** for union views (Tufte v6) — operator's instinct was correct from the start. 3. **Material/Drive UI conventions exist** because they work — center collapse handles on borders, don't pile them in corners. 4. **Mobile = different problem than desktop** — bottom-sheet, no drag-grip near pull-to-refresh zone, asymmetric fitBounds padding, redundant refits to survive iOS URL-bar collapse. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: corescope-bot <bot@corescope.local> |
||
|
|
9a2270168f |
feat(#893): Material Design dark mode toggle — polished version of #893 (#1389)
## Polished version of #893 This PR carries forward @emuehlstein's Material Design dark-mode toggle from #893, rebased onto current `master` and polished for a11y / first-paint / forced-colors / cross-tab sync. Original commits (preserved as `Co-authored-by`): - `feat: replace dark mode button with Material Design toggle switch` (emuehlstein) - `fix: define --shadow CSS var in theme blocks, drop stopPropagation no-op` (emuehlstein, addressing prior review) #893 had been stuck in CONFLICTING state since 2026-05-24 with no CI runs ever. Rebase resolved a single `public/style.css` `:root` conflict (preserved both the `--text-primary`/`--bg-hover`/`--primary` aliases from #1378 and the new `--shadow` definition). ## Polished improvements (on top of #893) 1. **FOUC fix** (`public/index.html`): inline `<head>` script reads `localStorage('meshcore-theme')` (or `prefers-color-scheme`) and sets `data-theme` *before* stylesheet load. Without this, dark-mode users see a light-mode flash on every page load. 2. **ARIA semantics** (`public/index.html`): moved `aria-label` from the wrapping `<label>` onto the actual `<input role="switch">`. Removed `aria-hidden="true"` from the checkbox (which had been hiding it from assistive tech). Added `aria-hidden` to the decorative track instead. 3. **Keyboard focus indicator** (`public/style.css`): `:focus-visible` on the (visually-hidden) checkbox draws an outline on `.theme-toggle-track`. Previously keyboard users could focus the toggle with Tab but had no visible indicator. 4. **Reduced motion** (`public/style.css`): `@media (prefers-reduced-motion: reduce)` disables the slide/fade transitions. 5. **Forced-colors mode** (`public/style.css`): explicit `CanvasText` border on track + thumb so the switch stays visible in Windows High Contrast. Default CSS tokens collapse to `Canvas`/`CanvasText` and the thumb would otherwise disappear. 6. **Cross-tab sync** (`public/app.js`): `storage` event listener for `meshcore-theme` mirrors the cb-presets pattern from #1378 — toggling theme in one tab now syncs all open tabs. 7. **Tightened E2E test** (`test-e2e-playwright.js`): added assertions for `role="switch"`, checkbox-state ↔ theme parity, and theme persistence across a full page reload (was only asserting one toggle). ## Notes - No `map[string]interface{}` (no Go changes). - All colors via existing `--mc-*` / theme tokens; `--shadow` is defined in both light + dark theme blocks. - No layout shift (track is fixed `46x24` inside the `44x44` label container). - Branch scope is exactly the four files from #893: `public/app.js`, `public/index.html`, `public/style.css`, `test-e2e-playwright.js`. Closes #893. Co-authored-by: Eric Muehlstein <muehlbucks@gmail.com> --------- Co-authored-by: Eric Muehlstein <muehlbucks@gmail.com> Co-authored-by: CoreScope Bot <bot@corescope> |
||
|
|
ff0ee50354 |
fix(#1374): packet-route map modernized — role-aware markers, directional edges, WCAG 2.2 AA (#1381)
## What The packet-route map view (`/#/map?route=N`) was a basic ~120-line renderer that pre-dated every recent a11y / UX investment (yellow circle markers, overlapping numeric labels, no directional edges, no aria, no legend). This PR rebuilds it on top of the modern shared helpers so it matches the `/live` + `/map` visual + a11y standard. Acceptance criteria from #1374 — every box checked: - [x] Role-aware shape markers via shared `window.makeRoleMarkerSVG` (post-#1357). - [x] Origin / destination visually + semantically distinct: outer ring + ▶ / ⚑ glyph + aria-label suffix `originator` / `destination`. - [x] Sequence-number badges (`.mc-route-seq-badge`) anchored bottom-right of each marker — separate carrier, NOT inside label text. - [x] Directional edges: per-hop HSL gradient (bright → fading) PLUS svg `<marker>` arrow head referenced via `marker-end`. Color is a *redundant* carrier; the badge stays the primary sequence signal so colorblind + forced-colors users still read the order. - [x] Per-edge `aria-label="Hop N → N+1, ~Xkm"` (haversine computed). - [x] Per-marker `role="img"` + `aria-label="Hop N of M, <name>, <role>"` + `tabindex=0` for keyboard reach + visible focus ring. - [x] Label deconfliction reuses `window.deconflictLabels` (now exposed by `map.js`) PLUS a DOM-measure second pass since the new wider labels overflow the legacy 38×24 collision box. - [x] Collapsible `.mc-route-legend` panel with role swatches, origin/destination glyphs, hop-order gradient sample. Toggle has `aria-expanded`. - [x] Toolbar parity: "Route observed at <timestamp>" context label + existing close-route control. - [x] Partial-route handling: hops with `resolved=false` get the `ch-unresolved` class, a dashed-ring placeholder marker, interpolated position between resolved neighbors, and a "X of N hops resolved" status badge. - [x] Per-marker popup with pubkey prefix, role, last_seen, observation count, coords, "Show on main map →" deep link. - [x] `prefers-reduced-motion: reduce` disables animations/transitions. - [x] `forced-colors: active` graceful degrade: markers, badges, edges fall back to `CanvasText` / `Canvas` (Windows HC safe). ## How Split the renderer into a dedicated `public/route-render.js` exposing `window.MeshRoute.render(map, layer, positions, opts)`. The existing `drawPacketRoute` in `map.js` now owns only short-hash → node resolution (and origin enrichment) and then delegates the entire visual layer. This makes the renderer testable in isolation with synthetic positions — no DB required — and avoids dragging the legacy ~100 LOC of marker / circleMarker / polyline scaffolding into the new design. Visual heritage: - **#1334 / #1347** — outer outline ring weights (origin/dest use the thicker ring; intermediates use the thin ring; unresolved use dashed). - **#1356 / #1357** — `makeRoleMarkerSVG` + Wong palette + per-marker aria-label pattern + `role="img"` on the divIcon. - **#1362 / #1365** — pill/legend visual conventions (collapsible legend matches the `.mc-section` accordion language users already know from `/map`). ### WCAG 2.2 AA — measured contrast (graphics SC 1.4.11, text SC 1.4.3) All ratios sampled with WebAIM contrast formula on the rendered elements against both Carto Positron (`#fafafa` typical) and Carto Dark Matter (`#1a1a1a` typical). | Element | SC | Ratio (Positron) | Ratio (Dark Matter) | Pass | |--------------------------------------------|----------|------------------|---------------------|------| | Sequence badge text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 | 17.1:1 (self-bg) | ✅ | | Sequence badge border `#1a1a1a` | 1.4.11 | 17.6:1 | 12.6:1 | ✅ | | Marker outer ring `#06b6d4` (origin) | 1.4.11 | 3.2:1 | 4.6:1 | ✅ | | Marker outer ring `#ef4444` (destination) | 1.4.11 | 3.8:1 | 4.4:1 | ✅ | | Marker outer ring `#666` (intermediate) | 1.4.11 | 5.7:1 | 3.7:1 | ✅ | | Edge stroke (seq color, mid: `#56c08c`) | 1.4.11 | 3.0:1 (min) | 3.1:1 | ✅ | | Edge arrow head (currentColor) | 1.4.11 | same as edge | same | ✅ | | Label text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 | 17.1:1 (self-bg) | ✅ | | Legend body text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 | 17.1:1 (self-bg) | ✅ | | Resolved badge `#78350f` on `#fef3c7` | 1.4.3 AA | 8.4:1 | 8.4:1 (self-bg) | ✅ | The label/badge/legend backgrounds are intentionally a solid `#f8fafc` panel (with `--mc-route-label-border` outline + `box-shadow`) so the text-color → tile-color path never applies — the readable text always sits on its own opaque panel. For SC 1.3.1 (info-and-relationships): every visual carrier has a redundant text or ARIA carrier — sequence position appears in the badge text AND in each marker's `aria-label`; origin/destination appear in the glyph AND the ring color AND the aria-label suffix; edge direction appears in the arrow head AND the per-edge aria-label. ### TDD - **Red commit:** `9e4f58e5547720ff3fcf8695a6c325958904683a` (CI: https://github.com/Kpa-clawbot/CoreScope/commits/9e4f58e5547720ff3fcf8695a6c325958904683a/checks) — adds `test-issue-1374-route-map-a11y-e2e.js` only. The test calls `window.MeshRoute.render(...)` directly with synthetic Bay-Area positions at mobile (375×800) AND desktop (1920×1080), asserts every acceptance criterion as a DOM grep on the rendered SVG / divIcon HTML, and includes the partial-route fixture. Fails on the assertions because `MeshRoute` doesn't exist on master. - **Green commit:** `1aba5303c5cbae553e1bea46a41754627f676a45` — adds `public/route-render.js`, refactors `drawPacketRoute` to delegate, adds `.mc-route-*` CSS (including reduced-motion + forced-colors media queries), wires the script tag in `index.html`, and wires the test into `.github/workflows/deploy.yml`. ### Visual verification 20/20 assertions pass locally (`CHROMIUM_PATH=/usr/bin/chromium BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-e2e.js`): ``` === Viewport mobile (375x800) === ✓ every hop marker has role="img" and informative aria-label ✓ origin aria-label contains "originator", destination contains "destination" ✓ sequence-number badge present beside each marker (not in label text) ✓ no two label boxes overlap (deconflict reused) ✓ edges have aria-label "Hop N → N+1" ✓ edges carry directionality marker (marker-end arrow) ✓ collapsible legend panel renders with role entries ✓ toolbar shows "Route observed at <timestamp>" context label ✓ partial-route — unresolved marker carries ch-unresolved class ✓ partial-route — "X of N hops resolved" badge present === Viewport desktop (1920x1080) === (same 10 — all ✓) 20 passed, 0 failed ``` Existing related tests (`#1356` `#1360` `#1364` `#1329`) re-run after the refactor — all green. ## Out of scope - Server-side route resolution (already done — this is a pure client rendering refit). - Multi-route view / 3D / globe — explicitly excluded by the issue. - Backend untouched — `cmd/server` + `cmd/ingestor` not modified. Fixes #1374 --------- Co-authored-by: openclaw-bot <bot@openclaw> |
||
|
|
101c11b4b3 |
fix(#1361): theme customizer — colorblind presets [WIP] (#1378)
WIP — draft PR for CI to exercise the RED test commit. Will be promoted
out of draft once the GREEN commit lands.
Red commit:
|
||
|
|
7b36968554 |
fix(nav): add missing nav-drawer.css — drawer rendered inline at page bottom (#1326)
## Summary - `nav-drawer.js` was wired up in `index.html` (issue #1064) but `nav-drawer.css` was never created - Without `position: fixed` and `transform: translateX(-100%)` the `<aside class="nav-drawer">` rendered as a visible inline block at the bottom of every page, showing **"Navigate×"** followed by the route list - Adds the missing stylesheet with proper slide-over layout, backdrop, transition, and `display: none` guard at ≤768px (bottom-nav More tab covers those routes) ## Test plan - [ ] Desktop (>768px): "Navigate×" bar no longer visible at bottom of any page - [ ] Desktop: left-edge swipe/touch still opens the drawer and it slides in from the left - [ ] Mobile (≤768px): nav drawer fully hidden, bottom-nav More tab unchanged - [ ] Dark mode and light mode: drawer uses the correct `--nav-bg` / `--nav-text` tokens - [ ] `prefers-reduced-motion`: transitions disabled 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
317b59ab10 |
feat: area-based visual node filter — attribute packets by transmitter GPS (#804) (#839)
## Summary - Adds configurable GPS polygon areas to `config.json`; nodes are attributed to an area if their last-known position falls inside the polygon - New `Area: …` dropdown filter (matching the existing region filter style) appears on all analytics, nodes, packets, map, and live screens when areas are configured - Backend resolves area membership with a 30s TTL cache; area filter bypasses the 500-node cap on `/api/bulk-health` so all area nodes are always returned - Includes a polygon builder tool (`/area-map.html`) for drawing and exporting area boundaries ## Changes **Backend** - `AreaEntry` type + `Areas` config field - `GetNodePubkeysInArea` DB query + `resolveAreaNodes` (30s TTL, `areaNodeMu` RWMutex) - `PacketQuery.Area` + `filterPackets` polygon check - `?area=` param propagated through all analytics, topology, clock-health, and bulk-health routes - `/api/config/areas` endpoint **Frontend** - `area-filter.js`: single-select dropdown, persists to localStorage, cleans up stale keys on load - Wired into analytics, nodes, packets, channels, map, and live pages - Live map clears node markers on area change **Docs & tools** - `docs/user-guide/area-filter.md` — configuration and usage guide - `docs/api-spec.md` — updated with new endpoint and `?area=` param table - `tools/area-map.html` — polygon builder for defining area boundaries - Demo areas added to `config.example.json` ## Test plan - [x] No areas configured → filter dropdown does not appear on any page - [x] Areas configured → dropdown appears, "All" selected by default - [x] Selecting an area filters nodes/packets/topology/map correctly - [x] Selecting "All" restores unfiltered view - [x] Selection persists across page reloads (localStorage) - [x] Stale localStorage key (area removed from config) is cleared on load - [x] `/api/bulk-health?area=X` returns all nodes in area (no 500-node cap) - [x] `/api/config/areas` returns correct list 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com> Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
03b5d3fe28 |
fix(#1065): first-visit gesture discoverability hints (#1186)
Red commit:
|
||
|
|
b4f186af19 |
fix(#1062): gesture system — swipe rows, tabs, slide-over dismiss (#1185)
Red commit: bbb98cf81aae38bff1ef77a7c8a701813b25bb77 (CI run: pending — see Checks tab) Fixes #1062. Parent: #1052. ## Gesture system Adds touch-gesture handling on phones (≤768px): 1. **Swipe-left on a packets/nodes/observers row** → reveals row-action overlay (trace, filter, copy hash). Threshold: 24% of row width OR 80px. Sub-threshold = visual peek that snaps back. 2. **Horizontal swipe on the bottom-nav strip** → advances tabs in TAB order from `bottom-nav.js`. Packets ↔ Live ↔ Map etc. 3. **Swipe-down on a slide-over panel** → calls `window.SlideOver.close()`. ## Hard constraints met - **Pointer Events ONLY** — no `touchstart`/`touchend` mixing. `setPointerCapture` for tracking continuity. - **Axis-lock** — direction committed in first 8–12px movement. Vertical scroll is never blocked unless we explicitly committed to a horizontal swipe. `body { touch-action: pan-y }` so the browser owns vertical natively. - **Leaflet exclusion** — handlers early-bail on `e.target.closest('.leaflet-container')` so pinch/pan on the map tab are untouched. - **Singleton pattern** — module-scoped `__touchGestures1062InitCount` guard. Document-level pointer listeners registered exactly once even if the script loads multiple times (mirrors the #1180 fix class). - **prefers-reduced-motion** — animations have `transition-duration: 0s` under the media query; gestures still trigger, snaps are instant. ## E2E `test-gestures-1062-e2e.js` — Playwright with synthesized PointerEvents (page.touchscreen unreliable in headless for axis-locked custom handlers). Wired into the deploy.yml matrix. E2E assertion added: test-gestures-1062-e2e.js:120 (overlay-visible after left-swipe), :201 (tab advance), :219 (Leaflet exclusion), :247 (slide-over dismiss). --------- Co-authored-by: openclaw-bot <bot@openclaw> Co-authored-by: OpenClaw Bot <bot@openclaw.dev> Co-authored-by: openclaw-bot <openclaw-bot@users.noreply.github.com> Co-authored-by: clawbot <clawbot@users.noreply.github.com> Co-authored-by: corescope-bot <bot@corescope.local> Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
9b9848611b |
fix(#1064): edge-swipe nav drawer (Option A, wide-only) (#1184)
Red commit:
|
||
|
|
9d1f5d2395 |
fix(#1061): bottom navigation for narrow viewports (#1174)
Red commit: a200704d5e27e47c0b29a4745bf1a1772a8876fe (CI URL added once Actions resolves the run) Fixes #1061 ## What Bottom navigation at ≤768px with 5 tabs in spec order: Home, Packets, Live, Map, Channels. Top-nav suppressed at the same breakpoint — no duplicate nav UX. ## Files - NEW `public/bottom-nav.js` — renders 5 tabs, syncs `.active` on `hashchange`, reuses the existing in-app hash router (`<a href="#/...">`). Stable selector `[data-bottom-nav-tab="<route>"]`. Container `[data-bottom-nav]`. - NEW `public/bottom-nav.css` — styles. Tokens reused: `--nav-bg`, `--nav-text`, `--nav-text-muted`, `--nav-active-bg`, `--accent`, `--border` (all global → resolve in BOTH light and dark themes). - `public/index.html` — one `<link>` for the CSS, one `<script>` after `app.js`. The `<nav>` is appended by JS as a sibling of `<main id="app">` at DOMContentLoaded. - `test-bottom-nav-1061-e2e.js` + `.github/workflows/deploy.yml` — Playwright wiring. ## Decisions - **Breakpoint:** `@media (max-width: 768px)`. No `@container` rules exist anywhere in `style.css` today — media query is consistent. - **Top-nav suppression:** `display:none` at ≤768px. Simpler than a hamburger collapse; long-tail routes (Tools/Lab/Perf) remain reachable by URL; "More"-tab/hamburger fallback deferred per issue body. - **Active indicator:** `var(--nav-active-bg)` + 2px accent top-border. No moving pill. - **Safe-area:** `padding-bottom: env(safe-area-inset-bottom)` on nav + reciprocal `body` reservation. `viewport-fit=cover` already in place. - **Reduced motion:** `prefers-reduced-motion: reduce` disables the transition. ## TDD - Red: `a200704` — assertions fail (no bottom-nav). - Green: `53851a1` — component + styles. E2E assertion added: `test-bottom-nav-1061-e2e.js:71` (case (a) — bottom-nav visible at 360x800). --------- Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: corescope-bot <bot@corescope.local> Co-authored-by: clawbot <clawbot@users.noreply.github.com> Co-authored-by: openclaw-bot <bot@openclaw> |
||
|
|
05876b3a59 |
fix(#1173): replace #liveDot with packet-driven brand-logo node-pulse (#1177)
Red commit: PENDING (will update) Fixes #1173. Replaces the `#liveDot` WebSocket-connected indicator with a packet-driven node-pulse animation on the brand logo's two inner circles. ## Behavior (locked per issue spec) - **Animation curve:** `ease-out` (default per open-question 1). - **Rate cap:** 15/sec (66ms gap; default per open-question 2). Excess triggers are dropped, never queued. - **Direction:** alternates A→B / B→A across messages (aesthetic, not semantic). - **Idle ≥10s:** logo at full brightness, no animation. - **Disconnected:** `.logo-disconnected` applies `filter: grayscale(0.6) opacity(0.7)`. - **`prefers-reduced-motion: reduce`:** single-step `.logo-pulse-blip` on destination only. ## Implementation - WS handler hook lives in `public/app.js` `connectWS()` (`ws.onmessage` triggers `Logo.pulse()`; `ws.onopen`/`ws.onclose` toggle `Logo.setConnected()`). - `Logo` is a small IIFE in `app.js` that exposes `window.__corescopeLogo` for E2E injection. - All animation is pure CSS; JS only toggles `.logo-pulse-active` / `.logo-pulse-blip` / `.logo-disconnected`. Colors come exclusively from `--logo-accent` / `--logo-accent-hi` tokens. - Two new classes (`.logo-node-a`, `.logo-node-b`) attached to inner circles in both `.brand-logo` and `.brand-mark-only` SVGs so the mobile mark animates too. ## `#liveDot` removal proof ``` $ grep -rn liveDot public/ (no output) ``` ## E2E - E2E assertion added: `test-logo-pulse-1173-e2e.js:54` and follows. - Wired into the Playwright matrix in `.github/workflows/deploy.yml` (mirrors PR #1168 pattern from commit `5442652`). - Test injects synthetic pings via `window.__corescopeLogo.pulse({ synthetic: true })`; matches the existing harness style (no new WS-mock pattern invented). Red→green discipline preserved: the test commit lands first and CI fails on assertion; the implementation commit follows. --------- Co-authored-by: Kpa-clawbot <bot@kpa-clawbot> Co-authored-by: corescope-bot <bot@corescope.local> |
||
|
|
dfacfd0f6e |
fix(logo): widen navbar SVG viewBox so CORE/SCOPE wordmark fits (#1141 followup) (#1156)
Fixes #1141 follow-up — the visible-on-staging SCOPE→SCOP clip that the prior PRs (#1137, #1141) intended to address but didn't. ## What was actually broken (ground truth from staging) Staging at `http://20.109.157.39:80/` renders the inline navbar SVG correctly — duotone CORE/SCOPE fills inherit page CSS vars, mobile mark-only swap fires at ≤400px, customizer logo override path works. Those parts of #1137 + #1141 landed cleanly. What did **NOT** land: the SVG `viewBox` was never widened to fit the rendered Aldrich wordmark. At every desktop viewport the SCOPE `<text text-anchor="start" x="773.8">` produces a bbox extending to user-space x≈1112, but the navbar `viewBox="170 10 860 280"` ends at x=1030. Result: SCOPE renders as **SCOP** on every desktop load. CORE also slightly overflows the left edge (bbox.x=153.7 < viewBox.x=170). The original brief premise (mushroom emoji still in `index.html` + `<img>`-loaded SVG monotone fallback on staging) does not match current state — `public/index.html:45` already has the inline SVG, staging renders it, and computed fills are duotone (`rgb(74,158,255)` vs `rgb(109,179,255)`). The visible bug is geometric clipping, not CSS-var inheritance or a mushroom revert. ## Fix (one-liner SVG geometry change) - `public/index.html` — navbar `svg.brand-logo`: `viewBox="170 10 860 280"` → `viewBox="150 10 970 280"`; intrinsic `width="111"` → `width="125"` (preserves ~36px nav row height). - `public/style.css` — `.brand-logo { width }` 111px → 125px (desktop), tablet `@media (max-width:900px)` pin 99px → 112px to keep the new aspect ratio so wordmark still doesn't clip on tablets. - `public/customize-v2.js` — `_setBrandLogoUrl` `<img>` swap dimensions updated to match (when an operator overrides `branding.logoUrl`). The `≤400px` mobile mark-only swap is unchanged — at narrow widths the wordmark still hides entirely and the dedicated `.brand-mark-only` SVG (no `<text>`) renders. ## TDD (red → green) | commit | role | |---|---| | `16b7a60` | **RED** — `test-logo-theme-e2e.js` assertion #7: every `CORE`/`SCOPE` `<text>` bbox must fit inside the SVG `viewBox`. Master fails: `[{text:CORE, bboxX:153.7, bboxRight:426.2, vbX:170}, {text:SCOPE, bboxX:773.8, bboxRight:1111.5, vbRight:1030}]` | | `0db473b` | **GREEN** — widen viewBox + width to fit | Test exercises real `getBBox()` measurement on a headless Chromium DOM with the Aldrich webfont loaded — not a unit-test fill string check. The earlier #1141 tests asserted computed `fill` colors (which were correct) but never measured rendered geometry; that's the gap. ## Visual proof **Before** (master HEAD against staging, viewport 1280): `/tmp/staging-logo-before-1280.png` — SCOPE clearly clipped to "SCOP". **After** (this branch against local server, viewport 1280): `/tmp/local-after-1280-screen.png` — full CORE / SCOPE rendered. **Mobile (after, 375px)**: `/tmp/local-after-mobile.png` — mark-only SVG (no wordmark, no clip). ## Preflight `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` — all hard gates clean (PII, branch-scope, red-commit-genuine, css-vars-defined, css-self-fallback, like-on-json, sync-migration), all warnings clean (img-svg-ratio, themed-img-svg, fixture-coverage). E2E assertion added: `test-logo-theme-e2e.js:286-310` Browser verified: `/tmp/local-after-1280-screen.png` (local server) + `/tmp/staging-logo-before-1280.png` (staging baseline). --------- Co-authored-by: corescope-bot <bot@corescope.local> |
||
|
|
0d8bc7536c |
fix(logo): restore duotone fog/teal split + mobile mark-only swap (#1141)
Two related logo fixes bundled together (small scope each). Cc @user-display-not-by-name. ## 1. Restore duotone (fog/teal) split — the original ask The M2 (light-theme readability) fix-cycle on #1137 collapsed both halves of the inline CoreScope wordmark to `var(--logo-text)` so they would invert correctly on light themes. That restored readability but erased the original side-split palette. This change re-uses the existing `--logo-accent` / `--logo-accent-hi` vars (already driving the left/right node arcs and dots) for the wordmark too: - `CORE` → `fill="var(--logo-accent)"` — matches left arcs + left node dot - `SCOPE` → `fill="var(--logo-accent-hi)"` — matches right arcs + right node dot - chirp polyline + `MESH ANALYZER` tagline → unchanged, `var(--logo-muted)` No hardcoded hex; theme customizer overrides via `--accent` / `--accent-hover` keep working on both themes. ## 2. Fix mobile clipping (SCOPE → "SCOF" at ≤390px) The full inline wordmark SVG has ~111px intrinsic content; the `.brand-logo` mobile pin from #1137 (99px width) was squeezing it and visibly clipping SCOPE. **Approach:** swap the full wordmark SVG for a dedicated mark-only inline SVG at ≤400px (option #1 from the design call). Keeps the duotone arcs, dots, and chirp visible — drops the wordmark cleanly. - `public/index.html`: CORE/SCOPE wrapped in `<g class="brand-wordmark">` (clean grouping); new sibling `<svg class="brand-mark-only">` with tight viewBox `425 15 250 230` covering both nodes + dots only. Same `--logo-accent` / `--logo-accent-hi` vars → duotone preserved on mobile. - `public/style.css`: `.brand-mark-only` defaults `display:none`; new `@media (max-width:400px)` rule hides `.brand-logo` and shows `.brand-mark-only`. ## TDD Three commits, red→green→red→green: | commit | role | |---|---| | `d53d328` | RED — duotone assertions (#4, #5) added; master fails (CORE === SCOPE) | | `3e53031` | GREEN — split CORE/SCOPE fills | | `e6b078f` | RED — mobile mark-only swap assertion (#6) at 360x640; master fails (no `.brand-mark-only`) | | `1a3b5db` | GREEN — add the mark-only SVG + media-query swap | ## Files changed - `test-logo-theme-e2e.js` — assertions expanded from 3/3 to 6/6 - `public/index.html` — duotone fills + brand-wordmark grouping + brand-mark-only sibling SVG - `public/home.js` — duotone fills (hero) - `public/style.css` — `.brand-mark-only` defaults + `@media (max-width:400px)` swap rule ## Verification CI Playwright run on commit `3e53031` (after the duotone fix, before the mobile fix) confirmed assertions 1–5 pass: - `navbar duotone preserved (dark: CORE=rgb(74,158,255) SCOPE=rgb(109,179,255); light: CORE=rgb(74,158,255) SCOPE=rgb(109,179,255))` - `hero duotone preserved (dark: CORE=rgb(74,158,255) SCOPE=rgb(109,179,255); light: CORE=rgb(74,158,255) SCOPE=rgb(109,179,255))` Final CI run on `1a3b5db` will additionally exercise the 6th (mobile mark-only swap at 360×640). --------- Co-authored-by: corescope-bot <bot@corescope.local> |
||
|
|
364c5766fc |
feat(logo): wire new CoreScope SVG logo into navbar + home hero (#1137)
## Adds new logo and home hero Replaces the navbar mushroom emoji + "CoreScope" text spans with the new CoreScope SVG mark, and adds a hero SVG (with the MESH ANALYZER tagline) above the home page H1. ### What changed - `public/img/corescope-logo.svg` — navbar mark, no tagline (locked "aggressive low-amp chirp" variant: facing-arcs + low-amp chirp connector between the two nodes). - `public/img/corescope-hero.svg` — home hero version, includes the MESH ANALYZER tagline. - `public/index.html` — replaces `<span class="brand-icon">🍄</span><span class="brand-text">CoreScope</span>` with `<img class="brand-logo" src="img/corescope-logo.svg?__BUST__" …>`. `.nav-brand` link still routes to `#/`. `.live-dot` retained. - `public/style.css` — adds `.brand-logo { height: 36px }` (32px on tablet ≤900px). Existing 52px nav height unchanged. - `public/home.js` / `public/home.css` — adds `<img class="home-hero-logo">` above the hero `<h1>`, sized `max-width: min(720px, 90vw)` and centered. ### TDD Red→green is visible in the branch: - `3159b82` — `test(logo): add failing E2E …` (red commit). Adds `test-logo-rebrand-e2e.js` and wires it into the `e2e-test` job in `deploy.yml` with `CHROMIUM_REQUIRE=1`. On this commit `index.html` still has the emoji + text spans, `home.js` has no hero img, and the SVG asset files do not exist — the test asserts on each so CI fails on assertion. - `19434e1` — `feat(logo): wire new CoreScope SVG logo …` (green commit). Implements the fix. ### E2E asserts 1. `.nav-brand img` exists with `src` ending `corescope-logo.svg` 2. legacy `.brand-icon` / `.brand-text` are gone 3. `.live-dot` is present, visible, and to the right of the logo (no overlap) 4. `.home-hero img.home-hero-logo` exists with `src` ending `corescope-hero.svg`, positioned BEFORE the `<h1>` 5. both `/img/corescope-{logo,hero}.svg` return 200 with svg content-type ### Customizer compatibility - `customize.js` still does `querySelector('.brand-text')` / `.brand-icon` for live branding updates. Both now return `null`; existing `if (el)` guards make those branches silent no-ops. **No JS errors, but the customizer's `branding.siteName` and `branding.logoUrl` fields no longer rewrite the navbar brand** — the brand is now a fixed SVG asset. - **Theme accent does NOT recolor the SVG.** SVGs loaded via `<img src>` are isolated documents and cannot inherit document CSS variables; the SVG falls back to its embedded brand colors. This is appropriate for a brand mark; if recoloring per theme is desired later, swap to inline SVG (separate PR). ### Browser validation Local Chromium not available in this env; the E2E test soft-skips locally and hard-fails in CI (`CHROMIUM_REQUIRE=1`). Server-side checks done locally: - `curl http://localhost:13581/` → confirmed `<img class="brand-logo" src="img/corescope-logo.svg?<bust>" …>` rendered, no `.brand-icon`/`.brand-text` spans. - `curl -I /img/corescope-logo.svg` and `/img/corescope-hero.svg` → both 200. ### Performance No hot-path changes. Two new static SVG assets (~7.6KB each), served directly by the Go static handler. Cache-busted via `?__BUST__` (auto-replaced server-side). --------- Co-authored-by: OpenClaw Bot <bot@openclaw.local> Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local> |
||
|
|
12d96a9d15 |
fix(#1111): hide My Channels section entirely when empty (#1112)
Fixes #1111. ## Problem When the user has no PSK channels added, `public/channels.js` still renders the "My Channels 🖥️ (this browser)" section header plus an empty-state placeholder ("No channels yet — click [+ Add Channel] to add one."). The section should not exist in the DOM at all when empty. ## Fix Wrap the entire My Channels section render in a `mine.length > 0` guard. When `mine.length === 0`: no section, no header, no placeholder. ## TDD - **Red commit** (`b8bf938`): adds `test-channel-issue-1111-e2e.js`, which fails on the current renderer because the section always emits — the test reproduces the bug. - **Green commit** (`776653d`): the conditional render in `public/channels.js` makes the test pass. ## E2E New test: `test-channel-issue-1111-e2e.js` (wired into the deploy workflow alongside the other channel E2Es). - Case 1: clear `localStorage` → asserts `.ch-section-mychannels` absent and no "My Channels" text in `#chList`. - Case 2: seed `corescope_channel_keys` with one PSK key → asserts `.ch-section-mychannels` exists with the "My Channels" header. ## Acceptance criteria - [x] No "My Channels" section when empty (no header, no placeholder) - [x] Section + header + channel row render with ≥1 stored PSK key - [x] E2E covers both states ## Performance None — single conditional around an existing render path. --------- Co-authored-by: Kpa-clawbot <kpa-clawbot@users.noreply.github.com> Co-authored-by: clawbot <bot@kpabap.invalid> |
||
|
|
36ee71d17e |
feat(#1085): fold Roles page into Analytics tab (#1088)
Red commit:
|
||
|
|
f7d8a7cb8f |
feat(packets): filter UX — in-UI docs + autocomplete + right-click + saved filters (#966) (#1083)
## Summary Implements the full filter-input UX upgrade from #966 — Wireshark-style help, autocomplete, right-click-to-filter, and saved filters. Closes #966. ## Surfaces ### A. Help popover (ⓘ button next to filter input) Auto-generated from `PacketFilter.FIELDS` / `OPERATORS` so it stays in sync with the parser. Includes: - Syntax overview (boolean ops, parens, case-insensitivity, URL-shareable filters) - Full field reference (27 entries: top-level + `payload.*`) - Full operator reference with one example per op - 10 ready-to-paste examples - Tips (right-click, autocomplete, save) ### B. Autocomplete dropdown - Type partial field name → field suggestions (top-level + dynamic `payload.*` keys discovered from visible packets) - Type `field` → operator suggestions - Type `type ==` → list of canonical type values (`ADVERT`, `GRP_TXT`, …) - Type `route ==` → list of route values (`FLOOD`, `DIRECT`, `TRANSPORT_FLOOD`, …) - Keyboard nav: ↑/↓, Tab/Enter to accept, Esc to dismiss ### C. Right-click → filter by this value Right-click any of these cells in the packet table: - `hash`, `size`, `type`, `observer` Context menu offers `==`, `!=`, `contains`. Click → clause appended to filter input (with `&&` if expression already present). ### D. Saved filters - ★ Saved ▾ dropdown next to the input - 7 starter defaults (Adverts only, Channel traffic, Direct messages, Strong signal SNR > 5, Multi-hop, Repeater adverts, Recent < 5m) - "+ Save current expression" prompts for a name and persists to `localStorage` under `corescope_saved_filters_v1` - User filters can be deleted (✕); defaults cannot - User filters with the same name as a default override it ## Implementation **`public/packet-filter.js`** — exposes `FIELDS`, `OPERATORS`, `TYPE_VALUES`, `ROUTE_VALUES`, and a new `suggest(input, cursor, opts)` function that returns ranked autocomplete suggestions with replace-range. Pure function — no DOM, fully unit-tested. **NEW `public/filter-ux.js`** — `window.FilterUX` IIFE owning the help popover, autocomplete dropdown, context menu, and saved-filters store. `init()` is idempotent, called once after the filter input renders. **`public/packets.js`** — calls `FilterUX.init()` after the filter input IIFE; row builders gain `data-filter-field` / `data-filter-value` attrs on hash/size/type/observer cells. `filter-group` wrapper now `position: relative` so dropdowns anchor correctly. **`public/style.css`** — scoped `.fux-*` styles using existing CSS variables (no new theme tokens). ## Tests - `test-packet-filter-ux.js` (19 unit tests, wired into `test-all.sh`): - Metadata exposure (FIELDS / OPERATORS / TYPE_VALUES / ROUTE_VALUES) - `suggest()` for empty input, prefix match, after `==`, dynamic `payload.*` keys - `SavedFilters.list/save/delete` — defaults, persistence, override, dedup - `buildCellFilterClause()` and `appendClauseToExpr()` quoting + appending - `test-filter-ux-e2e.js` (Playwright, wired into `deploy.yml`): - Navigate /packets → metadata exposed - Help popover opens with field reference, operators, examples - Autocomplete shows on focus, filters by prefix, accepts on Enter - Saved-filter dropdown lists defaults, click populates input - Right-click on TYPE cell → context menu → click appends clause - Save current expression persists to localStorage TDD red commit (`bddf1c1`) — assertion failures only, no import errors. Green commit (`0d3f381`) — all 19 unit tests pass. ## Browser validation Spawned local server on :39966 against the e2e fixture DB and exercised every UX surface via the openclaw browser tool. Confirmed: - `window.PacketFilter.FIELDS.length === 27`, `suggest()` available - `FilterUX.SavedFilters.list().length === 7` (defaults seeded) - Help popover renders with `payload.name`, `contains`, `ADVERT` text content - Right-click on a `data-filter-field="type"` / `data-filter-value="Response"` cell → context menu showed three options → clicking == populated the input with `type == "Response"` (and the existing alias resolver matched it to `payload_type === 1`) - Autocomplete on `pay` returned `payload_bytes`, `payload_hex`, `payload.name`, `payload.lat`, `payload.lon`, `payload.text` ## Out of scope (deferred per the issue) - Server-synced saved filters (cross-device) - Visual filter builder - Custom field expressions ## Acceptance criteria - [x] Help icon (ⓘ) next to filter input opens documentation popover - [x] Field reference table + operator reference + 6+ examples in popover - [x] Autocomplete dropdown on field names (top-level + `payload.*`) - [x] Autocomplete dropdown on values for `type` / `route` operators - [x] Right-click on packet cell → "Filter ==" / "Filter !=" / "Filter contains" - [x] Right-click context menu hides when clicking elsewhere / Esc - [x] Saved-filters dropdown with at least 5 default examples (7 shipped) - [x] User-saved filters persist in localStorage - [x] Real-time match count next to filter input (already shipped pre-PR; preserved) - [ ] Improved error messages with token + position — partial: existing parse errors already cite position; not a regression - [x] No regression in existing filter behavior (`test-packet-filter.js`: 69/69 pass) --------- Co-authored-by: meshcore-bot <bot@meshcore.local> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
54f7f9d35b |
feat: path-prefix candidate inspector with map view (#944) (#945)
## feat: path-prefix candidate inspector with map view (#944) Implements the locked spec from #944: a beam-search-based path prefix inspector that enumerates candidate full-pubkey paths from short hex prefixes and scores them. ### Server (`cmd/server/path_inspect.go`) - **`POST /api/paths/inspect`** — accepts 1-64 hex prefixes (1-3 bytes, uniform length per request) - Beam search (width 20) over cached `prefixMap` + `NeighborGraph` - Per-hop scoring: edge weight (35%), GPS plausibility (20%), recency (15%), prefix selectivity (30%) - Geometric mean aggregation with 0.05 floor per hop - Speculative threshold: score < 0.7 - Score cache: 30s TTL, keyed by (prefixes, observer, window) - Cold-start: synchronous NeighborGraph rebuild with 2s hard timeout → 503 `{retry:true}` - Body limit: 4096 bytes via `http.MaxBytesReader` - Zero SQL queries in handler hot path - Request validation: rejects empty, odd-length, >3 bytes, mixed lengths, >64 hops ### Frontend (`public/path-inspector.js`) - New page under Tools route with input field (comma/space separated hex prefixes) - Client-side validation with error feedback - Results table: rank, score (color-coded speculative), path names, per-hop evidence (collapsed) - "Show on Map" button calls `drawPacketRoute` (one path at a time, clears prior) - Deep link: `#/tools/path-inspector?prefixes=2c,a1,f4` ### Nav reorganization - `Traces` nav item renamed to `Tools` - Backward-compat: `#/traces/<hash>` redirects to `#/tools/trace/<hash>` - Tools sub-routing dispatches to traces or path-inspector ### Store changes - Added `LastSeen time.Time` to `nodeInfo` struct, populated from `nodes.last_seen` - Added `inspectMu` + `inspectCache` fields to `PacketStore` ### Tests - **Go unit tests** (`path_inspect_test.go`): scoreHop components, beam width cap, speculative flag, all validation error cases, valid request integration - **Frontend tests** (`test-path-inspector.js`): parse comma/space/mixed, validation (empty, odd, >3 bytes, mixed lengths, invalid hex, valid) - Anti-tautology gate verified: removing beam pruning fails width test; removing validation fails reject tests ### CSS - `--path-inspector-speculative` variable in both themes (amber, WCAG AA on both dark/light backgrounds) - All colors via CSS variables (no hardcoded hex in production code) Closes #944 --------- Co-authored-by: you <you@example.com> |
||
|
|
8158631d02 |
feat: client-side channel decryption — add custom channels in browser (#725 M2) (#733)
## Summary Pure client-side channel decryption. Users can add custom hashtag channels or PSK channels directly in the browser. **The server never sees the keys.** Implements #725 M2 (revised). Does NOT close #725. ## How it works 1. User types `#channelname` or pastes a hex PSK in the channels sidebar 2. Browser derives key (`SHA256("#name")[:16]`) using Web Crypto API 3. Key stored in **localStorage** — never sent to the server 4. Browser fetches encrypted GRP_TXT packets via existing API 5. Browser decrypts client-side: AES-128-ECB + HMAC-SHA256 MAC verification 6. Decrypted messages cached in localStorage 7. Progressive rendering — newest messages first, chunk-based ## Security - Keys never leave the browser - No new API endpoints - No server-side changes whatsoever - Channel interest partially observable via hash-based API requests (documented, acceptable tradeoff) ## Changes - `public/channels.js` — client-side decrypt module + UI integration (+307 lines) - `public/index.html` — no new script (inline in channels.js IIFE) - `public/style.css` — add-channel input styling --------- Co-authored-by: you <you@example.com> |
||
|
|
bc22dbdb14 |
feat: DragManager — core drag mechanics (#608 M1) (#697)
## Summary Implements M1 of the draggable panels spec from #608: the `DragManager` class with core drag mechanics. Fixes #608 (M1: DragManager core drag mechanics) ## What's New ### `public/drag-manager.js` (~215 lines) - **State machine:** `IDLE → PENDING → DRAGGING → IDLE` - **5px dead zone** on `.panel-header` to disambiguate click vs drag — prevents hijacking corner toggle and close button clicks - **Pointer events** with `setPointerCapture` for reliable tracking - **`transform: translate()`** during drag — zero layout reflow - **Snap-to-edge** on release: 20px threshold snaps to 12px margin - **Z-index management** — dragged panel comes to front (counter from 1000) - **`_detachFromCorner()`** — transitions panel from M0 corner CSS to fixed positioning - **Escape key** cancels drag and reverts to pre-drag position - **`restorePositions()`** — applies saved viewport percentages on init - **`handleResize()`** — clamps dragged panels inside viewport on window resize - **`enable()`/`disable()`** — responsive gate control ### `public/live.js` integration - Instantiates `DragManager` after `initPanelPositions()` - Registers `liveFeed`, `liveLegend`, `liveNodeDetail` panels - **Responsive gate:** `matchMedia('(pointer: fine) and (min-width: 768px)')` — disables drag on touch/small screens, reverts to M0 corner toggle - **Resize clamping** debounced at 200ms ### `public/live.css` additions - `cursor: grab/grabbing` on `.panel-header` (desktop only via `@media (pointer: fine)`) - `.is-dragging` class: opacity 0.92, elevated box-shadow, `will-change: transform`, transitions disabled - `[data-dragged="true"]` disables corner transition animations - `prefers-reduced-motion` support ### Persistence - **Format:** `panel-drag-{id}` → `{ xPct, yPct }` (viewport percentages) - **Survives resize:** positions recalculated from percentages - **Corner toggle still works:** clicking corner button after drag clears drag state (handled by existing M0 code) ## Tests 14 new unit tests in `test-drag-manager.js`: - State machine transitions (IDLE → PENDING → DRAGGING → IDLE) - Dead zone enforcement - Button click guard (no drag on button pointerdown) - Snap-to-edge behavior - Position persistence as viewport percentages - Restore from localStorage - Resize clamping - Disable/enable ## Performance - `transform: translate()` during drag — compositor-only, no layout reflow - `will-change: transform` only during active drag (`.is-dragging`), removed on drop - `localStorage` write only on `pointerup`, never during `pointermove` - Resize handler debounced at 200ms - Single `style.transform` assignment per pointermove frame — negligible cost --------- Co-authored-by: you <you@example.com> |
||
|
|
1dd763bf44 |
feat: sortable nodes list + neighbor/observer tables (M2, #620) (#639)
## Summary Implements M2 of the table sorting spec (#620): sortable nodes list + neighbor/observer tables. ### Changes **Shared utility (`public/table-sort.js`)** - IIFE pattern, no dependencies, no build step - DOM-reorder sorting (no innerHTML rebuild) — preserves event listeners - `data-value` attributes for raw sortable values, `data-type` on `<th>` for type detection - Built-in comparators: text (`localeCompare`), number, date, dBm - `aria-sort` attributes, keyboard support (Enter/Space), sort arrows - localStorage persistence with `storageKey` option - `onSort` callback for custom re-render triggers **Nodes list table** - Wired via `TableSort.init` with `onSort` callback that triggers `renderRows()` - Keeps JS-array-level sorting for claimed/favorites pinning (TableSort can't handle pinned rows) - Replaces old `sortState`, `toggleSort()`, `sortArrow()` with TableSort controller - Test hooks preserved for backward compatibility (fallback state for non-DOM tests) **Neighbor table** - Added `data-sort` and `data-value` attributes to all columns (name, role, score, count, last_seen, distance) - Default sort: count descending - `TableSort.init` called after neighbor data renders **Observer table (full detail page)** - Converted from plain `<table>` to sortable table with data attributes - Sortable columns: observer, region, packets, avg SNR, avg RSSI - Default sort: packets descending ### Testing - 18 new unit tests for `table-sort.js` (custom DOM mock, no jsdom dependency) - All 445 existing frontend tests pass unchanged - All packet-filter (62) and aging (29) tests pass ### Note This branch includes `table-sort.js` since M1 hasn't merged yet. The utility code is identical to the M1 spec. --------- Co-authored-by: you <you@example.com> |
||
|
|
6f3e3535c9 |
feat: shared table sort utility + packets table sorting (M1, #620) (#638)
## Summary Implements M1 of the table sorting spec (#620): a shared `TableSort` utility module and integration with the packets table. ### What's included **1. `public/table-sort.js` — Shared sort utility (IIFE, no dependencies)** - `TableSort.init(tableEl, options)` — attaches click-to-sort on `<th data-sort-key="...">` elements - Built-in comparators: text (localeCompare), numeric, date (ISO), dBm (strips suffix) - NaN/null values sort last consistently - Visual: ▲/▼ `<span class="sort-arrow">` appended to active column header - Accessibility: `aria-sort="ascending|descending|none"`, keyboard support (Enter/Space) - DOM reorder via `appendChild` loop (no innerHTML rebuild) - `domReorder: false` option for virtual scroll tables (packets) - `storageKey` option for localStorage persistence - Custom comparator override per column - `onSort(column, direction)` callback - `destroy()` for clean teardown **2. Packets table integration** - All columns sortable: region, time, hash, size, HB, type, observer, path, rpt - Default sort: time descending (matches existing behavior) - Uses `domReorder: false` + `onSort` callback to sort the data array, then re-render via virtual scroll - Works with both grouped and ungrouped views - WebSocket updates respect active sort column - Sort preference persisted in localStorage (`meshcore-packets-sort`) **3. Tests — 22 unit tests (`test-table-sort.js`)** - All 4 built-in comparators (text, numeric, date, dBm) - NaN/null edge cases - Direction toggle on click - aria-sort attribute correctness - Visual indicator (▲/▼) presence and updates - onSort callback - domReorder: false behavior - destroy() cleanup - Custom comparator override ### Performance Packets table sorting works at the data array level (single `Array.sort` call), not DOM level. Virtual scroll then renders only visible rows. No new DOM nodes are created during sort — it's purely a data reorder + re-render of the existing visible window. Expected sort time for 30K packets: ~50-100ms (array sort) + existing virtual scroll render time. Closes #620 (M1) Co-authored-by: you <you@example.com> |
||
|
|
382b3505dc |
feat: channel color quick-assign UI (M2, #271) (#611)
## Summary Implements M2 of channel color highlighting (#271): a right-click context menu popover for quick-assigning colors to hash channels. Builds on M1 (PR #607) which provides `ChannelColors.set/get/remove` storage primitives. ## What's new ### Color picker popover (`channel-color-picker.js`) - **Right-click** any GRP_TXT/CHAN row in the **live feed** or **packets table** → opens a color picker popover at the click point - **Long-press** (500ms) on mobile triggers the same popover - **10 preset swatches** — maximally distinct, ColorBrewer-inspired palette - **Custom hex** — native `<input type="color">` with Apply button - **Clear button** — removes color assignment (hidden when no color assigned) - **Popover positioning** — auto-adjusts to avoid viewport overflow - **Dismiss** — click outside or Escape key ### Immediate feedback - Assigning a color instantly re-styles all visible live feed items with that channel - Packets table triggers `renderVisibleRows()` via exposed `window._packetsRenderVisible` ### Wiring - Feed items store `_ccPkt` packet reference for channel extraction - Picker installed via `registerPage` init hooks in both `live.js` and `packets.js` - Single shared popover DOM element, repositioned on each open ### Styling - Dark card with border, matching existing CoreScope dropdown patterns - CSS in `style.css` under `.cc-picker-*` classes - Uses CSS variables (`--surface-1`, `--border`, `--accent`, etc.) for theme compatibility ## Files changed | File | Change | |------|--------| | `public/channel-color-picker.js` | New — popover component (IIFE, no dependencies except `ChannelColors`) | | `public/index.html` | Script tag for picker | | `public/live.js` | Store `_ccPkt` on feed items, install picker on init | | `public/packets.js` | Install picker on init, expose `_packetsRenderVisible` | | `public/style.css` | Popover CSS | | `test-channel-colors.js` | 2 new tests for picker loading and graceful degradation | ## Testing - All 21 channel-colors tests pass (19 M1 + 2 M2) - All 445 frontend-helpers tests pass - All 62 packet-filter tests pass ## Performance No hot-path impact. The popover is a single shared DOM element created lazily on first use. Context menu handlers use event delegation on the feed/table containers (one listener each, not per-row). The `refreshVisibleRows` function only iterates currently-visible DOM elements. Closes milestone M2 of #271. --------- Co-authored-by: you <you@example.com> |
||
|
|
3328ca4354 |
feat: channel color highlighting M1 — core model + feed row (#271) (#607)
## Summary Implements M1 of the [channel color highlighting spec](docs/specs/channel-color-highlighting.md) for issue #271. Allows users to assign custom highlight colors to specific hash channels. When a `GRP_TXT` packet arrives with an assigned channel color, the feed row and packets table row get: - **4px colored left border** in the assigned color - **Subtle background tint** (color at 10% opacity) ## What's included ### `public/channel-colors.js` — Storage model - `ChannelColors.get(channel)` → hex color or null - `ChannelColors.set(channel, color)` — assign a color - `ChannelColors.remove(channel)` — clear assignment - `ChannelColors.getAll()` → all assignments - `ChannelColors.getRowStyle(typeName, channel)` → inline CSS string for row highlighting - Uses `localStorage` key `live-channel-colors` - Gracefully handles corrupt/missing localStorage data ### Feed row highlighting (`public/live.js`) - Both `addFeedItem` (live WS) and `addFeedItemDOM` (replay/DB load) apply channel color styles - Reads `decoded.payload.channelName` from the packet ### Packets table highlighting (`public/packets.js`) - `buildFlatRowHtml` and `buildGroupRowHtml` apply channel color styles to `<tr>` elements - Reads channel from `getParsedDecoded(p).channel` ### Tests (`test-channel-colors.js`) - 16 unit tests covering storage CRUD, edge cases (null, empty, corrupt data), and style generation - Tests verify only GRP_TXT/CHAN types get coloring, other types are unaffected ## Design decisions - **Only GRP_TXT/CHAN packets** — other types retain default `TYPE_COLORS` styling - **Channel color takes priority** over default type colors for row highlighting - **No UI for assigning colors yet** — that's M2 (right-click context menu + color picker) - **Storage key abstracted** behind functions to ease future migration if customizer rework (#288) lands - **10% opacity tint** (`#hexcolor` + `1a` suffix) ensures readability in both dark/light modes ## Performance - `getRowStyle()` is O(1) — single localStorage read + JSON parse per call - No per-packet API calls; all data is client-side - No impact on hot rendering paths beyond one localStorage read per row render Closes #271 (M1 only — further milestones in separate PRs) --------- Co-authored-by: you <you@example.com> |
||
|
|
64745f89b1 |
feat: customizer v2 — event-driven state management (#502) (#503)
## Summary Implements the customizer v2 per the [approved spec](docs/specs/customizer-rework.md), replacing the v1 customizer's scattered state management with a clean event-driven architecture. Resolves #502. ## What Changed ### New: `public/customize-v2.js` Complete rewrite of the customizer as a self-contained IIFE with: - **Single localStorage key** (`cs-theme-overrides`) replacing 7 scattered keys - **Three state layers:** server defaults (immutable) → user overrides (delta) → effective config (computed) - **Full data flow pipeline:** `write → read-back → merge → atomic SITE_CONFIG assign → apply CSS → dispatch theme-changed` - **Color picker optimistic CSS** (Decision #12): `input` events update CSS directly for responsiveness; `change` events trigger the full pipeline - **Override indicator dots** (●) on each field — click to reset individual values - **Section-level override count badges** on tabs - **Browser-local banner** in panel header: "These settings are saved in your browser only" - **Auto-save status indicator** in footer: "All changes saved" / "Saving..." / "⚠️ Storage full" - **Export/Import** with full shape validation (`validateShape()`) - **Presets** flow through the standard pipeline (`writeOverrides(presetData) → pipeline`) - **One-time migration** from 7 legacy localStorage keys (exact field mapping per spec) - **Validation** on all writes: color format, opacity range, timestamp enum values - **QuotaExceededError handling** with visible user warning ### Modified: `public/app.js` Replaced ~80 lines of inline theme application code with a 15-line `_customizerV2.init(cfg)` call. The customizer v2 handles all merging, CSS application, and global state updates. ### Modified: `public/index.html` Swapped `customize.js` → `customize-v2.js` script tag. ### Added: `docs/specs/customizer-rework.md` The full approved spec, included in the repo for reference. ## Migration On first page load: 1. Checks if `cs-theme-overrides` already exists → skip if yes 2. Reads all 7 legacy keys (`meshcore-user-theme`, `meshcore-timestamp-*`, `meshcore-heatmap-opacity`, `meshcore-live-heatmap-opacity`) 3. Maps them to the new delta format per the spec's field-by-field mapping 4. Writes to `cs-theme-overrides`, removes all legacy keys 5. Continues with normal init Users with existing customizations will see them preserved automatically. ## Dark/Light Mode - `theme` section stores light mode overrides, `themeDark` stores dark mode overrides - `meshcore-theme` localStorage key remains **separate** (view preference, not customization) - Switching modes re-runs the full pipeline with the correct section ## Testing - All existing tests pass (`test-packet-filter.js`, `test-aging.js`, `test-frontend-helpers.js`) - Old `customize.js` is NOT modified — left in place for reference but no longer loaded ## Not in Scope (per spec) - Undo/redo stack - Cross-tab synchronization - Server-side admin import endpoint - Map config / geo-filter overrides --------- Co-authored-by: you <you@example.com> |
||
|
|
c7f655e419 |
perf(frontend): cache JSON.parse results for packet data (#400)
## Problem As described in #387, `JSON.parse()` is called repeatedly on the same packet data across render cycles. With 30K packets, each render cycle parses 60K+ JSON strings unnecessarily. ## Analysis The server sends `decoded_json` and `path_json` as JSON strings. The frontend parses them on-demand in multiple locations: - `renderTableRows()` — for every row, every render - WebSocket handling — when processing filtered packets - `loadPackets()` — during packet loading - Detail view rendering — when showing packet details This creates O(n×m) parsing overhead where n = packet count and m = render cycles. ## Solution Add cached parse helpers that store parsed results on the packet object: ```javascript function getParsedPath(p) { if (p._parsedPath === undefined) { try { p._parsedPath = JSON.parse(p.path_json || '[]'); } catch { p._parsedPath = []; } } return p._parsedPath; } ``` Same pattern for `getParsedDecoded()`. ## Changes - `public/packets.js`: Add helpers + replace 15+ JSON.parse calls - `public/live.js`: Add helpers + replace 5 JSON.parse calls ## Benchmarks Before: 60K+ JSON.parse calls per render cycle (30K packets) After: ~30K parse calls (one per packet, cached thereafter) Memory impact: Negligible (stores parsed objects that were already created temporarily) ## Notes - Cache uses `undefined` check to distinguish "not cached" from "cached empty result" - Property names `_parsedPath` and `_parsedDecoded` prefixed to avoid collision with server fields - No breaking changes to existing code paths Fixes #387 --------- Co-authored-by: P. Clawmogorov <262173731+Alm0stSurely@users.noreply.github.com> Co-authored-by: you <you@example.com> |
||
|
|
bf2e721dd7 |
feat: auto-inject cache busters at server startup — eliminates merge conflicts (#481)
## Problem Every PR that touches `public/` files requires manually bumping cache buster timestamps in `index.html` (e.g. `?v=1775111407`). Since all PRs change the same lines in the same file, this causes **constant merge conflicts** — it's been the #1 source of unnecessary PR friction. ## Solution Replace all hardcoded `?v=TIMESTAMP` values in `index.html` with a `?v=__BUST__` placeholder. The Go server replaces `__BUST__` with the current Unix timestamp **once at startup** when it reads `index.html`, then serves the pre-processed HTML from memory. Every server restart automatically picks up fresh cache busters — no manual intervention needed. ## What changed | File | Change | |------|--------| | `public/index.html` | All `v=1775111407` → `v=__BUST__` (28 occurrences) | | `cmd/server/main.go` | `spaHandler` reads index.html at init, replaces `__BUST__` with Unix timestamp, serves from memory for `/`, `/index.html`, and SPA fallback | | `cmd/server/helpers_test.go` | New `TestSpaHandlerCacheBust` — verifies placeholder replacement works for root, SPA fallback, and direct `/index.html` requests. Also added tests for root `/` and `/index.html` routes | | `AGENTS.md` | Rule 3 updated: cache busters are now automatic, agents should not manually edit them | ## Testing - `go build ./...` — compiles cleanly - `go test ./...` — all tests pass (including new cache-bust tests) - `node test-frontend-helpers.js && node test-packet-filter.js && node test-aging.js` — all frontend tests pass - No hardcoded timestamps remain in `index.html` --------- Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: you <you@example.com> |
||
|
|
f20431d816 |
fix: implement 'Show direct neighbors' map filter (#480)
## Summary Fixes #457 — The "Show direct neighbors" checkbox on the map was a UI stub that did nothing. This PR implements the full feature. ## What Changed ### `public/map.js` - **New state**: `selectedReferenceNode` (pubkey) and `neighborPubkeys` (Set) track which node is the reference and who its direct neighbors are - **`selectReferenceNode(pubkey, name)`**: Fetches `/api/nodes/{pubkey}/paths`, parses path hops to find all nodes directly adjacent to the reference node in any observed path, then auto-enables the neighbor filter - **Neighbor filter in `_renderMarkersInner()`**: When `filters.neighbors` is on and a reference node is selected, only the reference node and its direct (1-hop) neighbors are shown on the map - **Popup "Show Neighbors" link**: Each node popup now has a "Show Neighbors" action that sets it as the reference node - **Sidebar UI hints**: Shows the reference node name when selected, or a hint to click a node when the filter is enabled without a reference - **Cleanup on `destroy()`**: Clears reference state and global handler ### `test-frontend-helpers.js` - 6 new unit tests covering: - Filter off shows all nodes - Filter on without reference shows all nodes (graceful no-op) - Filter on with reference + neighbors filters correctly - Filter on with empty neighbor set shows only reference - Neighbor filter respects role filters - Neighbor extraction from path data ### `public/index.html` - Cache buster bump ## How It Works 1. User clicks a node marker on the map → popup shows "Show Neighbors" link 2. Clicking "Show Neighbors" fetches that node's paths from `/api/nodes/{pubkey}/paths` 3. Adjacent hops in each path are identified as direct neighbors 4. The map filters to show only the reference node + its neighbors 5. The sidebar shows which node is the reference 6. Unchecking the checkbox restores the full node view ## Test Results ``` Frontend helpers: 250 passed, 0 failed Packet filter: 62 passed, 0 failed ``` --------- Co-authored-by: you <you@example.com> |
||
|
|
96d0bbe487 |
fix: replace Euclidean distance with haversine in analytics hop distances (#478)
## Summary Fixes #433 — Replace the inaccurate Euclidean distance approximation in `analytics.js` hop distances with proper haversine calculation, matching the server-side computation introduced in PR #415. ## Problem PR #415 moved collision analysis server-side and switched from the frontend's Euclidean approximation (`dLat×111, dLon×85`) to proper haversine. However, the **hop distance** calculation in `analytics.js` (subpath detail panel) still used the old Euclidean formula. This caused: - **Inconsistent distances** between hop distances and collision distances - **Significant errors at high latitudes** — e.g., Oslo→Stockholm: Euclidean gives ~627km, haversine gives ~415km (51% error) - The `dLon×85` constant assumes ~40° latitude; at 60° latitude the real scale factor is ~55.5km/degree, not 85 ## Changes | File | Change | |------|--------| | `public/analytics.js` | Replace `dLat*111, dLon*85` Euclidean with `HopResolver.haversineKm()` (with inline fallback) | | `public/hop-resolver.js` | Export `haversineKm` in the public API for reuse | | `test-frontend-helpers.js` | Add 4 tests: export check, zero distance, SF→LA accuracy, Euclidean vs haversine divergence | | `cmd/server/helpers_test.go` | Add `TestHaversineKm`: zero, SF→LA, symmetry, Oslo→Stockholm accuracy | | `public/index.html` | Cache buster bump | ## Performance No performance impact — `haversineKm` replaces an inline arithmetic expression with another inline arithmetic expression of identical O(1) complexity. Only called per hop pair in the subpath detail panel (typically <10 hops). ## Testing - `node test-frontend-helpers.js` — 248 passed, 0 failed - `go test -run TestHaversineKm` — PASS Co-authored-by: you <you@example.com> |
||
|
|
6712da7d7c |
fix: add region filtering to hash-collisions endpoint (#477)
## Summary The `/api/analytics/hash-collisions` endpoint always returned global results, ignoring the active region filter. Every other analytics endpoint (RF, topology, hash-sizes, channels, distance, subpaths) respected the `?region=` query parameter — this was the only one that didn't. Fixes #438 ## Changes ### Backend (`cmd/server/`) - **routes.go**: Extract `region` query param and pass to `GetAnalyticsHashCollisions(region)` - **store.go**: - `collisionCache` changed from `*cachedResult` → `map[string]*cachedResult` (keyed by region, `""` = global) — consistent with `rfCache`, `topoCache`, etc. - `GetAnalyticsHashCollisions(region)` and `computeHashCollisions(region)` now accept a region parameter - When region is specified, resolves regional observers, scans packets for nodes seen by those observers, and filters the node list before computing collisions - Cache invalidation updated to clear the map (not set to nil) ### Frontend (`public/`) - **analytics.js**: The hash-collisions fetch was missing `+ sep` (the region query string). All other fetches in the same `Promise.all` block had it — this was simply overlooked in PR #415. - **index.html**: Cache busters bumped ### Tests (`cmd/server/routes_test.go`) - `TestHashCollisionsRegionParamIgnored` → renamed to `TestHashCollisionsRegionParam` with updated comments reflecting that region is now accepted (with no configured regional observers, results match global — which the test verifies) ## Performance No new hot-path work. Region filtering adds one scan of `s.packets` (same as every other region-filtered analytics endpoint) only when `?region=` is provided. Results are cached per-region with the existing 60s TTL. Without `?region=`, behavior is unchanged. Co-authored-by: you <you@example.com> |
||
|
|
6aef83c82a |
fix: remove duplicate return statement in _cumulativeRowOffsets() (#476)
## Summary Removes an unreachable duplicate `return offsets;` statement in the `_cumulativeRowOffsets()` function in `packets.js`. The second return was dead code found during review of PR #402. ## Changes - **`public/packets.js`**: Removed the duplicate `return offsets;` on what was line 1137 (the line immediately after the first, reachable `return offsets;`) - **`public/index.html`**: Cache buster bump ## Testing This is a dead code removal — the duplicate return was unreachable. No behavior change. No new tests needed as existing tests already cover `_cumulativeRowOffsets()` behavior. Fixes #447 Co-authored-by: you <you@example.com> |
||
|
|
0b8b1e91a6 |
perf(live): replace animation setIntervals with requestAnimationFrame and cap concurrency (#470)
## Summary Replace all `setInterval`-based animations in `live.js` with `requestAnimationFrame` loops and add a concurrency cap to prevent unbounded animation accumulation under high packet throughput. Fixes #384 ## Problem Under high throughput (≥5 packets/sec), the live map accumulated unbounded `setInterval` timers: - `pulseNode()`: 26ms interval per pulse ring - `drawAnimatedLine()`: 33ms interval per hop line + 52ms nested interval for fade-out - Ghost hop pulse: 600ms interval per ghost marker At 5 pkts/sec × 3 hops = **15+ concurrent intervals**, climbing without limit. This caused UI jank, rising CPU usage, and potential memory leaks from leaked Leaflet markers. ## Changes ### `public/live.js` | Function | Before | After | |----------|--------|-------| | `pulseNode()` | `setInterval` (26ms) + `setTimeout` safety | `requestAnimationFrame` loop, self-terminates at 2s or opacity ≤ 0 | | `drawAnimatedLine()` | `setInterval` (33ms) for line + nested `setInterval` (52ms) for fade | Two `requestAnimationFrame` loops (line advance + fade-out) | | Ghost hop pulse | `setInterval` (600ms) + `setTimeout` (3s) | `requestAnimationFrame` loop with 3s expiry | | `animatePath()` | No concurrency limit | Returns early when `activeAnims >= MAX_CONCURRENT_ANIMS` (20) | ### `public/index.html` - Cache buster version bump ### `test-live-anims.js` (new) - 7 tests verifying: - No `setInterval` in `pulseNode`, `drawAnimatedLine`, or `animatePath` - `MAX_CONCURRENT_ANIMS` defined and set to 20 - Concurrency check present in `animatePath` - No stale `setInterval` in animation hot paths ## Complexity & Scale - **Time complexity**: O(1) per animation frame (no change in per-frame work) - **Concurrency**: Hard-capped at 20 simultaneous animations (previously unbounded) - **At 5 pkts/sec, 3 hops**: Excess animations silently dropped instead of accumulating timers - **rAF benefit**: Browser coalesces all animations into single paint cycle; paused tabs stop animating automatically ## Test Results ``` === Animation interval elimination === ✅ pulseNode does not use setInterval ✅ drawAnimatedLine does not use setInterval ✅ ghost hop pulse does not use setInterval === Concurrency cap === ✅ MAX_CONCURRENT_ANIMS is defined ✅ MAX_CONCURRENT_ANIMS is set to 20 ✅ animatePath checks MAX_CONCURRENT_ANIMS before proceeding === Safety: no stale setInterval in animation functions === ✅ no setInterval remains in animation hot path 7 passed, 0 failed ``` All existing tests pass (packet-filter: 62, aging: 29, frontend-helpers: 241). ## Performance Proof (Rule 0 compliance) Benchmark: `node test-anim-perf.js` — simulates timer/animation accumulation under realistic throughput. ### Timer count: old (setInterval) vs new (rAF + cap) | Scenario | Old model (peak concurrent timers) | New model (peak concurrent animations) | |----------|-----------------------------------:|---------------------------------------:| | 5 pkt/s × 3 hops, 30s sustained | **123** | **20** | | 5 pkt/s × 3 hops, 5min sustained | **123** | **20** | | 20 pkt/s × 3 hops, 10s burst | **246** | **20** | **Before:** Each hop spawns 3 `setInterval` timers (pulse 26ms, line 33ms, fade 52ms) that live 0.6–2s each. At 5 pkt/s × 3 hops = 15 timers/sec, peak concurrent timers reach **123** (limited only by timer lifetime, not by any cap). Under burst traffic (20 pkt/s), this climbs to **246+**. **After:** `MAX_CONCURRENT_ANIMS = 20` hard-caps active animations. Excess packets are silently dropped. rAF loops replace all `setInterval` calls, coalescing into single paint cycles. Peak concurrent animations: **always ≤ 20**, regardless of throughput or duration. --------- Co-authored-by: you <you@example.com> Co-authored-by: Kpa-clawbot <kpabap+clawdbot@gmail.com> |
||
|
|
c678555e75 |
fix: display channel hash as hex instead of decimal (#471)
## Summary Fixes #465 — Channel hash was displaying in decimal instead of hexadecimal in `channels.js`. ## Changes - Added `formatHashHex()` helper to `channels.js` that formats numeric hashes as `0x` hex (e.g. `0x0A`) and passes string hashes through unchanged - Applied to both display sites: `renderChannelList` fallback name and `selectChannel` header text - Consistent with `packets.js` and `analytics.js` which already use `.toString(16).padStart(2, '0').toUpperCase()` ## Tests - 3 new tests in `test-frontend-helpers.js` verifying the helper exists, is used at display sites, and produces correct output for numeric and string inputs - All 244 frontend tests pass, plus packet-filter (62) and aging (29) tests Co-authored-by: you <you@example.com> |
||
|
|
0b1924d401 |
perf(packets): replace observers.find() linear scans with Map lookups (#468)
## Summary Replace all `observers.find()` linear scans in `packets.js` with O(1) `Map.get()` lookups, eliminating ~300K comparisons per render cycle at 30K+ rows. ## Changes - Added `observerMap` (`Map<id, observer>`) built once when observers load - Replaced all 6 `observers.find()` call sites with `observerMap.get()`: - `obsName()` — called per row for observer name display - Region filter check in packet filtering - Observer dropdown label in filter UI - Group header region lookup - Child row region lookup - Flat row region lookup - Map is cleared on reset and rebuilt on each `loadObservers()` call ## Complexity - **Before:** O(k) per row × 30K rows = O(30K × k) where k = observer count (~10) - **After:** O(1) per row × 30K rows = O(30K) - Map construction: O(k) once, negligible ## Testing - All Go tests pass (`cmd/server`, `cmd/ingestor`) - All frontend tests pass (`test-packet-filter.js`: 62 passed, `test-aging.js`: 29 passed, `test-frontend-helpers.js`: 241 passed) Fixes #383 Co-authored-by: you <you@example.com> |
||
|
|
0f502370c5 |
fix: VCR timeline and clock respect UTC/local timezone setting (#459)
## Problem Fixes #324. The VCR LCD clock and timeline hover/touch tooltip always showed local time, ignoring the UTC/local timezone setting in the customizer Display tab. ## Root cause Three sites in `live.js` bypassed the shared `getTimestampTimezone()` utility: - `updateVCRClock()` — used `d.getHours()` / `d.getMinutes()` / `d.getSeconds()` (always local) - Timeline mousemove tooltip — used `d.toLocaleTimeString()` (always local) - Timeline touchmove tooltip — same ## Fix Added `vcrFormatTime(tsMs)` helper that checks `getTimestampTimezone()` and uses `getUTC*` methods when set to `'utc'`, otherwise local `get*`. Applied to all three sites. Exposed as `window._vcrFormatTime` for testing. ## Tests 4 new unit tests in `test-frontend-helpers.js` covering UTC mode, local mode, and zero-padding. ## Checklist - [x] Branches from `upstream/master` - [x] No Matomo or local-only commits - [x] Cache busters bumped (`v=1775073838`) - [x] 233 tests pass, 0 fail 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
e47c39ffda |
fix: null-guard animLayer and liveAnimCount in nextHop after destroy (#462)
## Summary - `nextHop()` schedules `setInterval`/`setTimeout` callbacks that can fire after `destroy()` has set `animLayer = null` and removed DOM elements - This caused three console errors on the Live page when navigating away mid-animation: `Cannot read properties of null (reading 'hasLayer')` and `Cannot set properties of null (setting 'textContent')` - Added null guards at each async callback site; no behavioral change when the page is active ## Changes - `public/live.js`: early return if `animLayer` is null at start of `nextHop()`; null-safe `animLayer.hasLayer` checks in `setInterval`/`setTimeout`; null-safe `liveAnimCount` element access - `public/index.html`: cache buster bumped - `test-frontend-helpers.js`: 4 source-inspection tests verifying the null guards are present ## Test plan - [ ] Open Live page, trigger some packet animations, navigate away quickly — no console errors - [ ] `node test-frontend-helpers.js` passes (233 tests, 0 failures) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
1499a55ba7 |
perf: upsert known nodes in-place on ADVERT, skip full reload (#461)
## Problem Fixes #399. On every ADVERT WebSocket batch the nodes page invalidated the entire `_allNodes` cache and triggered a full `/nodes?limit=5000` fetch — even when every advertising node was already cached. The 90s API TTL was actively bypassed. ## Root cause ```js wsHandler = debouncedOnWS(function (msgs) { if (msgs.some(isAdvertMessage)) { _allNodes = null; // wipe cache unconditionally invalidateApiCache('/nodes'); // bust API TTL loadNodes(true); // full 5k fetch } }, 5000); ``` ## Fix ADVERT decoded payloads include `pubKey`, `name`, `lat`, `lon` — enough to update known nodes in place: - **Known node** (pubKey found in `_allNodes`): upsert `name`, `lat`, `lon`, `last_seen` directly — no fetch, no cache bust, just re-render. - **New node** (pubKey not in cache) or **no pubKey** in payload: fall back to full reload as before. This covers the common case on an active mesh: all advertising nodes are already cached. The full reload path is preserved for node discovery. ## Tests 2 new unit tests: known-node upsert (asserts 0 API calls, fields updated) and unknown-node fallback (asserts full reload triggered). All 231 tests pass. ## Checklist - [x] Branches from `upstream/master` - [x] No Matomo or local-only commits - [x] Cache busters bumped - [x] 231 tests pass, 0 fail 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
f71e117cdd |
fix: reset restores home steps after SITE_CONFIG contamination (#460)
## Problem Fixes #325. Removing all home steps and clicking "Reset my theme" did not restore them. ## Root cause Two-part bug: **1. `SITE_CONFIG.home` permanently mutated at page load** `app.js` calls `mergeUserHomeConfig(SITE_CONFIG, userTheme)` which does `SITE_CONFIG.home = Object.assign({}, serverHome, userTheme.home)`. If the user had `steps: []` saved in localStorage, this sets `SITE_CONFIG.home.steps = []` globally — permanently for the lifetime of the page. **2. `initState()` reads the contaminated config** When the customizer opens (or Reset is clicked), `initState()` reads `cfg = window.SITE_CONFIG`. Since `SITE_CONFIG.home.steps` is already `[]`, `state.home.steps` stays `[]` even after `localStorage.removeItem`. `autoSave()` then re-saves `steps: []` straight back. **Secondary issue:** `data-rm-step` / add / move handlers didn't call `autoSave()`, making step persistence non-deterministic (only saved if a text field edit happened to be pending). ## Fix - **`app.js`**: snapshot `SITE_CONFIG.home` before `mergeUserHomeConfig` → `window._SITE_CONFIG_ORIGINAL_HOME` - **`customize.js`**: `initState()` uses `_SITE_CONFIG_ORIGINAL_HOME` instead of the contaminated `cfg.home` - **`customize.js`**: add `autoSave()` to rm/move/add handlers for steps, checklist, and footer links ## Tests 2 new unit tests covering the snapshot bypass and DEFAULTS fallback. 231 tests pass. ## Checklist - [x] Branches from `upstream/master` - [x] No Matomo or local-only commits - [x] Cache busters bumped - [x] 231 tests pass, 0 fail 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
ec4dd58cb6 |
fix: null-guard pathHops to prevent detail pane crash (#451) (#454)
## Summary Fixes #451 — packet detail pane crash on direct routed packets where `pathHops` is `null`. ## Root Cause `JSON.parse(pkt.path_json)` can return literal `null` when the DB stores `"null"` for direct routed packets. The existing code only had a catch block for parse errors, but `null` is valid JSON — so the parse succeeds and `pathHops` ends up `null` instead of `[]`. ## Changes - **`public/packets.js`**: Added `|| []` after `JSON.parse(...)` in both `buildFlatRowHtml` (table rows) and the detail pane (`selectPacket`), ensuring `pathHops` is always an array. - **`test-frontend-helpers.js`**: Added 2 regression tests verifying the null guards exist in both code paths. - **`public/index.html`**: Cache buster bump. ## Testing - All 229 frontend helper tests pass - All 62 packet filter tests pass - All 29 aging tests pass Co-authored-by: you <you@example.com> |
||
|
|
044a5387af |
perf(packets): virtual scroll + debounced WS renders for packets table (#402)
## Summary Fixes the critical performance issue where `renderTableRows()` rebuilt the **entire** table innerHTML (up to 50K rows) on every update — WebSocket arrivals, filter changes, group expand/collapse, and theme refreshes. ## Changes ### Lazy Row Generation (`renderVisibleRows`) — fixes #422 - Row HTML strings are **only generated for the visible slice + 30-row buffer** on each render - `_displayPackets` stores the filtered data array; `renderVisibleRows()` calls `buildGroupRowHtml`/`buildFlatRowHtml` lazily for ~60-90 visible entries - Previously, `displayPackets.map(buildGroupRowHtml)` built HTML for ALL 30K+ packets on every render — the expensive work (JSON.parse, observer lookups, template literals) ran for every packet regardless of visibility ### Unified Row Count via `_getRowCount()` — fixes #424 - Single function `_getRowCount(p)` computes DOM row count for any entry (1 for flat/collapsed, 1+children for expanded groups) - Used by BOTH `_rowCounts` computation AND `renderVisibleRows` — eliminates divergence risk between row counting and row building ### Hoisted Observer Filter Set — fixes #427 - `_observerFilterSet` created once in `renderTableRows()`, reused across `buildGroupRowHtml`, `_getRowCount`, and child filtering - Previously, `new Set(filters.observer.split(','))` was created inside `buildGroupRowHtml` for every packet AND again in the row count callback ### Dynamic Colspan — fixes #426 - `_getColCount()` reads column count from the thead instead of hardcoded `colspan="11"` - Spacers and empty-state messages use the actual column count ### Null-Safety in `buildFlatRowHtml` — fixes #430 - `p.decoded_json || '{}'` fallback added, matching `buildGroupRowHtml`'s existing null-safety - Prevents TypeError on null/undefined `decoded_json` in flat (ungrouped) mode ### Behavioral Tests — fixes #428 - Replaced 5 source-grep tests with behavioral unit tests for `_getRowCount`: - Flat mode always returns 1 - Collapsed group returns 1 - Expanded group returns 1 + child count - Observer filter correctly reduces child count - Null `_children` handled gracefully - Retained source-level assertions only where behavioral testing isn't practical (e.g., verifying lazy generation pattern exists) ### Other Improvements - Cumulative row offsets cached in `_cumulativeOffsetsCache`, invalidated on row count changes - Debounced WebSocket renders (200ms) coalesce rapid packet arrivals - `destroy()` properly cleans up all virtual scroll state ## Performance Benchmarks — fixes #423 **Methodology:** Row building cost measured by counting `buildGroupRowHtml` calls per render cycle on 30K grouped packets. | Scenario | Before (eager) | After (lazy) | Improvement | |----------|----------------|--------------|-------------| | Initial render (30K packets) | 30,000 `buildGroupRowHtml` calls | ~90 calls (60 visible + 30 buffer) | **333× fewer calls** | | Scroll event | 0 calls (pre-built) | ~90 calls (rebuild visible slice) | Trades O(1) scroll for O(n) initial savings | | WS packet arrival | 30,000 calls (full rebuild) | ~90 calls (debounced + lazy) | **333× fewer calls** | | Filter change | 30,000 calls | ~90 calls | **333× fewer calls** | | Memory (row HTML cache) | ~2MB string array for 30K packets | 0 (no cache, build on demand) | **~2MB saved** | **Per-call cost of `buildGroupRowHtml`:** Each call performs JSON.parse of `decoded_json`, `path_json`, `observers.find()` lookup, and template literal construction. At 30K packets, the eager approach spent ~400-500ms on row building alone (measured via `performance.now()` on staging data). The lazy approach builds ~90 rows in ~1-2ms. **Net effect:** `renderTableRows()` goes from O(n) string building + O(1) DOM insertion to O(1) data assignment + O(visible) string building + O(visible) DOM insertion. For n=30K and visible≈60, this is ~333× less work per render cycle. **Trade-off:** Scrolling now rebuilds ~90 rows per RAF frame instead of slicing pre-built strings. This costs ~1-2ms per scroll event, well within the 16ms frame budget. The trade-off is overwhelmingly positive since renders happen far more frequently than full-table scrolls. ## Tests - 247 frontend helper tests pass (including 18 virtual scroll tests) - 62 packet filter tests pass - 29 aging tests pass - Go backend tests pass ## Remaining Debt (tracked in issues) - #425: Hardcoded `VSCROLL_ROW_HEIGHT=36` and `theadHeight=40` — should be measured from DOM - #429: 200ms WS debounce delay — value works well in practice but lacks formal justification - #431: No scroll position preservation on filter change or group expand/collapse Fixes #380 --------- Co-authored-by: you <you@example.com> Co-authored-by: Kpa-clawbot <kpabap+clawdbot@gmail.com> |
||
|
|
01ca843309 |
perf: move collision analysis to server-side endpoint (fixes #386) (#415)
## Summary Moves the hash collision analysis from the frontend to a new server-side endpoint, eliminating a major performance bottleneck on the analytics collision tab. Fixes #386 ## Problem The collision tab was: 1. **Downloading all nodes** (`/nodes?limit=2000`) — ~500KB+ of data 2. **Running O(n²) pairwise distance calculations** on the browser main thread (~2M comparisons with 2000 nodes) 3. **Building prefix maps client-side** (`buildOneBytePrefixMap`, `buildTwoBytePrefixInfo`, `buildCollisionHops`) iterating all nodes multiple times ## Solution ### New endpoint: `GET /api/analytics/hash-collisions` Returns pre-computed collision analysis with: - `inconsistent_nodes` — nodes with varying hash sizes - `by_size` — per-byte-size (1, 2, 3) collision data: - `stats` — node counts, space usage, collision counts - `collisions` — pre-computed collisions with pairwise distances and classifications (local/regional/distant/incomplete) - `one_byte_cells` — 256-cell prefix map for 1-byte matrix rendering - `two_byte_cells` — first-byte-grouped data for 2-byte matrix rendering ### Caching Uses the existing `cachedResult` pattern with a new `collisionCache` map. Invalidated on `hasNewTransmissions` (same trigger as the hash-sizes cache) and on eviction. ### Frontend changes - `renderCollisionTab` now accepts pre-fetched `collisionData` from the parallel API load - New `renderHashMatrixFromServer` and `renderCollisionsFromServer` functions consume server-computed data directly - No more `/nodes?limit=2000` fetch from the collision tab - Old client-side functions (`buildOneBytePrefixMap`, etc.) preserved for test helper exports ## Test results - `go test ./...` (server): ✅ pass - `go test ./...` (ingestor): ✅ pass - `test-packet-filter.js`: ✅ 62 passed - `test-aging.js`: ✅ 29 passed - `test-frontend-helpers.js`: ✅ 227 passed ## Performance impact | Metric | Before | After | |--------|--------|-------| | Data transferred | ~500KB (all nodes) | ~50KB (collision data only) | | Client computation | O(n²) distance calc | None (server-cached) | | Main thread blocking | Yes (2000 nodes × pairwise) | No | | Server caching | N/A | 15s TTL, invalidated on new transmissions | --------- Co-authored-by: you <you@example.com> Co-authored-by: Kpa-clawbot <kpabap+clawdbot@gmail.com> |
||
|
|
5f50e80931 |
perf: replace server round-trip with client-side filter for My Nodes toggle (#401)
## Summary Fixes #381 — The "My Nodes" filter in `packets.js` was making a **server API call inside `renderTableRows()`** on every render cycle. With WebSocket updates arriving every few seconds while the toggle was active, this created continuous unnecessary server load. ## What Changed **`public/packets.js`** — Replaced the `api('/packets?nodes=...')` server call with a pure client-side filter: ```js // Before: server round-trip on every render const myData = await api('/packets?nodes=' + allKeys.join(',') + '&limit=500'); displayPackets = myData.packets || []; // After: filter already-loaded packets client-side displayPackets = displayPackets.filter(p => { const dj = p.decoded_json || ''; return allKeys.some(k => dj.includes(k)); }); ``` This uses the exact same matching logic as the server's `QueryMultiNodePackets()` — a string contains check on `decoded_json` for each pubkey — but without the network round-trip. **`test-frontend-helpers.js`** — Added 5 unit tests for the filter logic: - Single and multiple pubkey matching - No matches / empty keys edge case - Null/empty `decoded_json` handled gracefully **`public/index.html`** — Cache busters bumped. ## Test Results - Frontend helpers: **232 passed, 0 failed** (including 5 new tests) - Packet filter: **62 passed, 0 failed** - Aging: **29 passed, 0 failed** Co-authored-by: you <you@example.com> |