Commit Graph

780 Commits

Author SHA1 Message Date
Kpa-clawbot ac0cf5ac7d fix(channels): #1087 QR library + share modal + PSK persistence (#1090)
Red commit: 5def4d073c (CI run pending —
see Checks tab)

Fixes #1087

## What's broken (4 bugs)
1. **"QR library not loaded"** — `channel-qr.js` checked `root.QRCode`
(capital), but the vendored library exports lowercase `qrcode` (Kazuhiko
Arase API). Generate & Show QR always fell into the "library not loaded"
branch.
2. **QR encodes `name=psk:hex`** — the Share button (and parts of the
Generate path) passed the internal `psk:<hex8>` lookup key to
`ChannelQR.generate`, ignoring the user's display label stored in
`LABELS_KEY`.
3. **PSK channel doesn't persist on refresh** — the persistence path was
scattered, and the read-back wasn't verified. Added channels disappeared
on refresh and "reappeared" only when a later add ran the persist hook.
4. **Share button reuses the Add Channel modal** — wrong intent reuse
(Add = INPUT, Share = OUTPUT). Replaced with a dedicated `#chShareModal`
(separate DOM id, separate title, share-only affordances, privacy
warning).

## TDD
Red commit (this) lands ONLY the failing tests:
- `test-channel-issue-1087.js` — source-string contract assertions for
all 4 bugs
- `test-channel-issue-1087-e2e.js` — Playwright E2E covering generate →
QR render, QR display name, persistence across refresh, Share opens
dedicated modal

Green commit (follow-up) lands the production fixes.

## E2E assertion added
E2E assertion added: test-channel-issue-1087-e2e.js:55

## CI wiring
- `test-channel-issue-1087.js` added to `.github/workflows/deploy.yml`
(go-test JS unit step) + `test-all.sh`
- `test-channel-issue-1087-e2e.js` added to
`.github/workflows/deploy.yml` (e2e-test step)

---------

Co-authored-by: bot <bot@corescope>
Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-05-05 03:24:52 -07:00
Kpa-clawbot 36ee71d17e feat(#1085): fold Roles page into Analytics tab (#1088)
Red commit: 35e1f46b36 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/25367951904)

Fixes #1085

## What changed

The "Roles" page is a stats slice — counts + breakdown by node role. It
belongs in Analytics, not as a top-level nav peer of Map / Channels /
Nodes. This PR folds it in and frees nav space.

### Frontend
- `public/index.html` — drop the `<a data-route="roles">` from the top
nav and the legacy `<script src="roles-page.js">` tag.
- `public/app.js` — backward-compat redirect added at the top of
`navigate()`: `#/roles` (and `#/roles?…`, `#/roles/…`) →
`#/analytics?tab=roles`. Old bookmarks keep working.
- `public/analytics.js` — new `<button data-tab="roles">Roles</button>`
in the tab strip + `case 'roles': await renderRolesTab(el)` in
`renderTab()`. The render function (distribution table + per-role
clock-skew posture) is moved over verbatim from the old standalone page.
- `public/roles-page.js` — deleted; its only consumer was the
now-removed route.

The Analytics tab strip already supports deep-linking via `?tab=…`, so
the redirect target is reached and the Roles tab activates on initial
load with no extra wiring.

## Acceptance criteria (from #1085)

- [x] No "Roles" link in top nav
- [x] Analytics page has a "Roles" tab with the same content
- [x] Old `#/roles` URLs redirect (don't 404)
- [x] Frees nav space for higher-priority pages

## Tests

E2E assertion added: test-e2e-playwright.js:2386 (3 assertions covering
all 3 acceptance criteria).

Also replaces the legacy "Roles page renders distribution table" E2E
test (added for issue #818), which assumed a standalone `/#/roles` SPA
page. The replacement assertions exercise the new fold-in path: nav
scan, Analytics tab click, redirect verification.

## TDD trail

- Red commit `35e1f46` — adds the three failing E2E assertions before
any production change. CI run on the red branch (linked above) shows the
assertions fail when production code hasn't been updated.
- Green commit `2b5715d` — minimal production change to satisfy the
assertions: nav link removed, redirect added, Roles tab + render
function moved into Analytics.

---------

Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-05-05 02:43:41 -07:00
Kpa-clawbot 282074b19d feat(#1034): wire QR generate + scan into channel modal (PR 3/3) (#1081)
## Summary

**PR 3/3 of #1034** — wires the existing `window.ChannelQR` module (PR2
#1035) into the existing channel modal placeholders (PR1 #1037).

### Changes

**`public/channels.js`**
- **Generate handler** (`#chGenerateBtn`): replaced the "QR coming in
next update" placeholder text with a real call to
`window.ChannelQR.generate(label || channelName, keyHex, qrOut)`.
Renders QR canvas + `meshcore://channel/add?...` URL + Copy Key inline
into `#qr-output`.
- **Scan handler** (`#scan-qr-btn`): removed `disabled` attribute,
refreshed title, and added a click handler that calls
`window.ChannelQR.scan()`. On success it populates `#chPskKey` (from
`result.secret`) and `#chPskName` (from `result.name`); on cancel it's a
no-op; on error it surfaces the message via `#chPskError`.

The Share button on sidebar entries was already wired to
`ChannelQR.generate` in PR1 (no change needed).

### TDD

1. **Red commit** (`178020b`): `test-channel-qr-wiring.js` — 12
assertions, 7 failed against the placeholder code (Generate handler
still printed "coming in next update", scan button still disabled).
2. **Green commit** (`e708f3f`): wiring added → all 12 assertions pass.

### E2E (rule 18)

`test-e2e-playwright.js` gains 3 Playwright tests (run against the live
Go server with fixture DB in CI):

- Generate → asserts `#qr-output canvas` and the
`meshcore://channel/add` URL appear after the click.
- Scan button is enabled (no `disabled` attribute).
- Stubs `ChannelQR.scan` to return `{name, secret}`, clicks the button,
asserts `#chPskKey` + `#chPskName` are populated.

### CI registration

Added `node test-channel-qr-wiring.js` and `node
test-channel-modal-ux.js` to the JS unit-test step in
`.github/workflows/deploy.yml` (and `test-all.sh`).

### Closes

Closes #1034 (final PR in the redesign series).

---------

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-05 01:59:17 -07:00
Kpa-clawbot 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>
2026-05-05 01:50:12 -07:00
Kpa-clawbot e9c801b41a feat(live): filter incoming packets by IATA region (#1045) (#1080)
Closes #1045.

## What
Adds an optional region dropdown to the **Live** page that filters
incoming packets by observer IATA. When a user selects one or more
regions, only packets observed by repeaters in those regions render in
the feed/animation/audio.

## How
- New `liveRegionFilter` container in the live header toggles row,
initialised via the shared `RegionFilter` component in `dropdown` mode
(matches packets/nodes/observers pages).
- On page init, fetches `/api/observers` once and builds an `observer_id
→ IATA` map.
- `packetMatchesRegion(packets, obsMap, selected)` (pure helper, OR
across observations, case-insensitive) gates `renderPacketTree` next to
the existing favorite + node filters.
- Selection persists in localStorage via the existing `RegionFilter`
machinery — no per-page key needed.
- Listener cleanup hooked into the existing live-page teardown.

## TDD
- Red commit `55097ce`: `test-live-region-filter.js` asserts
`_livePacketMatchesRegion` exists and behaves correctly across 9 cases
(no-selection passthrough, single match, no-match, OR across
observations, multi-region selection, unknown observer, missing
observer_id, case-insensitivity, observer-map override). Fails with
`_livePacketMatchesRegion must be exposed` against master.
- Green commit `fdec7bf`: implements helper + UI wiring + CSS; test
passes.

Test wired into `.github/workflows/deploy.yml` JS unit-test step.

## Notes
- Server-side WS broadcast is unchanged — filtering is purely
client-side, as the issue requests ("something a user can activate
themselves, and not something that would be server wide").
- Pre-existing `test-live.js` / `test-live-dedup.js` failures on master
are not introduced or affected by this PR (verified by running both on
master HEAD).

---------

Co-authored-by: meshcore-bot <bot@openclaw.local>
2026-05-05 01:43:05 -07:00
Kpa-clawbot 3ab404b545 feat(node-battery): voltage trend chart + /api/nodes/{pubkey}/battery (#663) (#1082)
## Summary

Closes #663 (Phase 2 + 3 partial — time-series tracking + thresholds for
nodes that are also observers).

Adds a per-node battery voltage trend chart and
`/api/nodes/{pubkey}/battery` endpoint, sourced from the existing
`observer_metrics.battery_mv` samples populated by observer status
messages. No new ingest or schema changes — purely surfaces data we were
already collecting.

## Scope (TDD red→green)

**RED commit:** test(node-battery) — DB query, endpoint shape
(200/404/no-data), and config getters all asserted.
**GREEN commit:** feat(node-battery) — implementation only.

## Changes

### Backend
- `cmd/server/node_battery.go` (new):
- `DB.GetNodeBatteryHistory(pubkey, since)` — pulls `(timestamp,
battery_mv)` rows from `observer_metrics WHERE LOWER(observer_id) =
LOWER(public_key) AND battery_mv IS NOT NULL`. Case-insensitive join
tolerates historical pubkey casing variation (observers persist
uppercase, nodes lowercase in this DB).
- `Server.handleNodeBattery` — `GET /api/nodes/{pubkey}/battery?days=N`
(default 7, max 365). Returns `{public_key, days, samples[], latest_mv,
latest_ts, status, thresholds}`.
- `Config.LowBatteryMv()` / `CriticalBatteryMv()` — defaults 3300 / 3000
mV.
- `cmd/server/config.go` — `BatteryThresholds *BatteryThresholdsConfig`
field.
- `cmd/server/routes.go` — route registration alongside existing
`/health`, `/analytics`.

### Frontend
- `public/node-analytics.js` — new "Battery Voltage" chart card with
status badge (🔋 OK / ⚠️ Low / 🪫 Critical / No data). Renders dashed
threshold lines at `lowMv` and `criticalMv`. Empty-state message when no
samples in window.

### Config
- `config.example.json` — `batteryThresholds: { lowMv: 3300, criticalMv:
3000 }` with `_comment` per Config Documentation Rule.

## Status semantics

| latest_mv             | status     |
|-----------------------|------------|
| no samples in window  | `unknown`  |
| `>= lowMv`            | `ok`       |
| `< lowMv`, `>= critMv`| `low`      |
| `< criticalMv`        | `critical` |

## What this PR does NOT do (deferred)

The issue's full Phase 1 (writing decoded sensor advert telemetry into
`nodes.battery_mv` / `temperature_c` from server-side decoder) and Phase
4 (firmware/active polling for repeaters without observers) are out of
scope here. This PR delivers the requested Phase 2/3 surfacing for the
data path that already lands rows: `observer_metrics`. Repeaters that
are also observers (i.e. publish status to MQTT) will get a voltage
trend immediately; pure passive nodes won't until Phase 1 lands.

## Tests

- `TestGetNodeBatteryHistory_FromObserverMetrics` — case-insensitive
join, NULL skipping, ordering.
- `TestNodeBatteryEndpoint` — full happy path with thresholds + status.
- `TestNodeBatteryEndpoint_NoData` — 200 + status=unknown.
- `TestNodeBatteryEndpoint_404` — unknown node.
- `TestBatteryThresholds_ConfigOverride` — config getters + defaults.

`cd cmd/server && go test ./...` — green.

## Performance

Endpoint is per-pubkey (called once on analytics page open), indexed by
`(observer_id, timestamp)` PK on `observer_metrics`. No hot-path impact.

---------

Co-authored-by: bot <bot@corescope>
2026-05-05 01:41:00 -07:00
Kpa-clawbot aa3d26f314 fix(nav): stop nav bar from jumping when Live is selected (#1046) (#1078)
## Summary

The `🔴 Live` nav link could wrap onto two lines at certain viewport
widths once it became the `.active` link, which grew `.nav-link`'s
height and made the whole `.top-nav` "hop" the instant Live was selected
(issue #1046).

Adding `white-space: nowrap` to the base `.nav-link` rule keeps every
nav label on a single line at every breakpoint (default desktop + the
768–1279px and <768px responsive overrides), eliminating the jump.

## Changes

- `public/style.css` — `white-space: nowrap` on `.nav-link`.
- `test-e2e-playwright.js` — new assertion at viewport 1115px (the width
in the issue screenshots) that:
  - computed `white-space` prevents wrapping
  - the Live link renders on a single line in both states
  - `.top-nav` height does not change when `.active` is toggled

## TDD

- Red commit `ba906a5` — test added, fails because base `.nav-link` has
no `white-space` rule (default `normal`).
- Green commit `51906cb` — single-line CSS fix makes the test pass.

Fixes #1046

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-05 01:36:08 -07:00
Kpa-clawbot 5f6c5af0cf fix(observers): correct column headings after Last Packet (#1039) (#1075)
## Summary

Fixes #1039 — the Observers page table had 10 `<td>` cells per row but
only 9 `<th>` headings, so labels drifted starting at the Packet Health
badge cell. The headings `Packets`, `Packets/Hour`, `Clock Offset`,
`Uptime` were each one column to the left of their data.

## Changes

- `public/observers.js`: added missing `Packet Health` heading (over the
`packetBadge()` cell) and renamed the count column header from `Packets`
to `Total Packets` to disambiguate from `Packets/Hour`.

## TDD

- **Red commit** (`7cae61c`): `test-observers-headings.js` asserts
`<th>` count equals `<td>` count and verifies the expected header order.
Both assertions fail on master (9 vs 10; `Packets` vs `Packet
Health`/`Total Packets`).
- **Green commit** (`8ed7f7c`): heading row updated; both assertions
pass.

## Test

```
$ node test-observers-headings.js
── Observers table headings (#1039) ──
  ✓ thead column count equals tbody row column count
  ✓ expected headings present and ordered
2 passed, 0 failed
```

Wired into `test-all.sh`.

## Risk

Frontend-only, static template change. No data flow / perf impact.

Fixes #1039

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-05 01:35:09 -07:00
Kpa-clawbot f33801ecb4 feat(repeater): usefulness score — traffic axis (#672) (#1079)
## Summary

Implements the **Traffic axis** of the repeater usefulness score (#672).
Does NOT close #672 — Bridge, Coverage, and Redundancy axes are deferred
to follow-up PRs.

Adds `usefulness_score` (0..1) to repeater/room node API responses
representing what fraction of non-advert traffic passes through this
repeater as a relay hop.

## Why traffic-axis-first

The issue proposes a 4-axis composite (Bridge, Coverage, Traffic,
Redundancy). Bridge/Coverage/Redundancy require betweenness centrality
and neighbor graph infrastructure (#773 Neighbor Graph V2). Traffic axis
can ship independently using existing path-hop data.

## Remaining work for #672

- Bridge axis (betweenness centrality — depends on #773)
- Coverage axis (observer reach comparison)
- Redundancy axis (node-removal simulation — depends on #687)
- Composite score combining all 4 axes

Partial fix for #672.

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 01:34:08 -07:00
Kpa-clawbot d192330bdc feat(compare): asymmetric overlap stats for reference observer comparison (#671) (#1076)
## Summary

Adds asymmetric overlap percentages to the existing observer compare
page so it can be used as a **reference observer comparison** tool
(Uncle Lit's request, #671).

## What changed

`public/compare.js` (frontend only — no backend changes)

- New `computeOverlapStats(cmp)` helper that turns a
`comparePacketSets()` result into two-way coverage:
  - `aSeesOfB` — % of B's packets that A also saw
  - `bSeesOfA` — % of A's packets that B also saw
  - plus shared / onlyA / onlyB / totalA / totalB
- Two callout cards on the compare summary view:
  - `<A> saw N of <B>'s X packets` (Y%)
  - `<B> saw N of <A>'s X packets` (Y%)
- Existing "Only A / Only B / Both" tabs already identify unique
packets; that's the second half of the issue and is left intact.

## Operator workflow

Pick a known-good observer (LOS to key nodes) as the reference. Pair it
with a candidate. If the candidate's overlap with the reference is high
→ healthy. If low → investigate antenna, obstruction, or RF deafness.

## Out of scope (future work)

Issue lists several follow-on milestones — full Analytics sub-tab with
reference-vs-many table, SNR delta, geographic proximity filter,
server-side `/api/analytics/observer-comparison` endpoint. Those are
larger and tracked by the issue's M1-M4 milestones; this PR closes the
core ask (asymmetric overlap on the existing compare page) and leaves
the rest for follow-ups.

## Tests

`test-compare-overlap.js` — 6 unit tests via vm sandbox:

- exposes `computeOverlapStats` on `window`
- basic asymmetric scenario (8/10 vs 8/12)
- zero packets — no division by zero
- one observer empty — both percentages 0
- perfect overlap — 100% both ways
- disjoint observers — 0% both ways

TDD: red commit landed first with stub returning zeros (assertions
failed), green commit added the math.

Closes #671

---------

Co-authored-by: bot <bot@corescope.local>
2026-05-05 01:33:04 -07:00
Kpa-clawbot 45f30fcadc feat(repeater): liveness detection — distinguish actively relaying from advert-only (#662) (#1073)
## Summary

Implements repeater liveness detection per #662 — distinguishes a
repeater that is **actively relaying traffic** from one that is **alive
but idle** (only sending its own adverts).

## Approach

The backend already maintains a `byPathHop` index keyed by lowercase
hop/pubkey for every transmission. Decode-window writes also key it by
**resolved pubkey** for relay hops. We just weren't surfacing it.

`GetRepeaterRelayInfo(pubkey, windowHours)`:
- Reads `byPathHop[pubkey]`.
- Skips packets whose `payload_type == 4` (advert) — a self-advert
proves liveness, not relaying.
- Returns the most recent `FirstSeen` as `lastRelayed`, plus
`relayActive` (within window) and the `windowHours` actually used.

## Three states (per issue)

| State | Indicator | Condition |
|---|---|---|
| 🟢 Relaying | green | `last_relayed` within `relayActiveHours` |
| 🟡 Alive (idle) | yellow | repeater is in the DB but
`relay_active=false` (no recent path-hop appearance, or none ever) |
|  Stale | existing | falls out of the existing `getNodeStatus` logic |

## API

- `GET /api/nodes` — repeater/room rows now include `last_relayed`
(omitted if never observed) and `relay_active`.
- `GET /api/nodes/{pubkey}` — same fields plus `relay_window_hours`.

## Config

New optional field under `healthThresholds`:

```json
"healthThresholds": {
  ...,
  "relayActiveHours": 24
}
```

Default 24h. Documented in `config.example.json`.

## Frontend

Node detail page gains a **Last Relayed** row for repeaters/rooms with
the 🟢/🟡 state badge. Tooltip explains the distinction from "Last Heard".

## TDD

- **Red commit** `4445f91`: `repeater_liveness_test.go` + stub
`GetRepeaterRelayInfo` returning zero. Active and Stale tests fail on
assertion (LastRelayed empty / mismatched). Idle and IgnoresAdverts
already match the desired behavior under the stub. Compiles, runs, fails
on assertions — not on imports.
- **Green commit** `5fcfb57`: Implementation. All four tests pass. Full
`cmd/server` suite green (~22s).

## Performance

`O(N)` over `byPathHop[pubkey]` per call. The index is bounded by store
eviction; a single repeater has at most a few hundred entries on real
data. The `/api/nodes` loop adds one map read + scan per repeater row —
negligible against the existing enrichment work.

## Limitations (per issue body)

1. Observer coverage gaps — if no observer hears a repeater's relay,
it'll show as idle even when actively relaying. This is inherent to
passive observation.
2. Low-traffic networks — a repeater in a quiet area legitimately shows
idle. The 🟡 indicator copy makes that explicit ("alive (idle)").
3. Hash collisions are mitigated by the existing `resolveWithContext`
path before pubkeys land in `byPathHop`.

Fixes #662

---------

Co-authored-by: clawbot <bot@corescope.local>
2026-05-05 01:17:52 -07:00
Kpa-clawbot 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>
2026-05-05 01:17:22 -07:00
Kpa-clawbot 417b460fa0 feat(css): fluid scaffolding — clamp() spacing/type/container tokens (#1054) (#1066)
## Summary

Lands the **fluid CSS foundation** for the responsive scaffolding effort
(parent #1050). Pure additive change to the top of `public/style.css` —
no component CSS touched.

## What changed

### New tokens in `:root`
- **Spacing scale** — `--space-xs … --space-2xl` via `clamp()`. 1440px
targets match the prior hardcoded `4 / 8 / 16 / 24 / 32 / 48` px values
to within ~1px.
- **Type scale** — `--fs-sm … --fs-2xl` via `clamp(min, vw-based, max)`.
Floors keep text readable at 768px; caps prevent runaway growth at
2560px+.
- **Radii** — `--radius-sm/md/lg` via `clamp()`.
- **Container layout** — `--gutter` (`clamp()`) and `--content-max`
(`min(100% - 2*gutter, 1600px)`) for fluid horizontal layout without
media queries.

### Base consumption
- `html, body` now sets `font-size: var(--fs-md)`.

### Parallel-work safety
- Added `FLUID SCAFFOLDING` section header at the top.
- Added `COMPONENT STYLES` section header marking where the rest of the
file (nav, tables, charts, map, packets, analytics …) begins. Sibling
tasks 1050-3..6 / 1052-* edit inside that region and won't conflict with
this PR.

## TDD

- **Red:** `2d6f90a` — `test-fluid-scaffolding.js` asserts the new
tokens exist with `clamp()`/`min()`, that `html, body` consumes
`--fs-md`, and that the section marker is present. Fails on assertions
(15 failed, 0 passed).
- **Green:** `7b4d59b` — implementation in `public/style.css`. All 15
assertions pass.

## Acceptance criteria

- [x] Fluid spacing scale `--space-xs..--space-2xl` via `clamp()`
- [x] Fluid type scale `--fs-sm..--fs-2xl` via `clamp()`
- [x] Replace base body font-size with the new token
- [x] Container layout vars `--content-max`, `--gutter` via
`min()`/`clamp()`
- [x] No component CSS edits (only `:root`, `html`, `body`)
- [x] No visual regression at 1440px (token targets numerically match
prior px values)

## Notes for reviewers

- Pre-existing `test-frontend-helpers.js` failure on master is unrelated
(`nodesContainer.setAttribute is not a function`) and not introduced
here.
- `--content-max` uses `min(100% - 2*gutter, 1600px)` — the `100% - …`
arm wins on small viewports and guarantees a gutter always remains.

Fixes #1054

---------

Co-authored-by: clawbot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 01:14:39 -07:00
Kpa-clawbot 78dabd5bda feat(filter): timestamp predicates (after/before/between/age) — #289 (#1070)
Fixes #289.

Adds Wireshark-style timestamp predicates to the client-side packet
filter
engine (`public/packet-filter.js`).

## New syntax

| Form | Meaning |
| --- | --- |
| `time after "2024-01-01"` | packets with timestamp strictly after the
given datetime |
| `time before "2024-12-31T23:59:59Z"` | packets strictly before |
| `time between "2024-01-01" "2024-02-01"` | inclusive range
(order-insensitive) |
| `age < 1h` | packets newer than 1 hour |
| `age > 24h` | packets older than 24 hours |
| `age < 7d && type == ADVERT` | composes with existing predicates |

Duration units: `s` / `m` / `h` / `d` / `w`. Datetime values use
`Date.parse`
(ISO 8601 + bare `YYYY-MM-DD`). `time` is also accepted as `timestamp`.

## Implementation

- `OP_WORDS` extended with `after`, `before`, `between`.
- New `TK.DURATION` token: lexer recognises `<number><unit>` and
pre-converts
  to seconds at lex time (no per-evaluation parsing cost).
- `between` is a two-value op handled in `parseComparison`.
- Field resolver:
- `time` / `timestamp` → epoch-ms; falls back to `first_seen` then
`latest`
    so grouped rows from `/api/packets?groupByHash=true` work.
  - `age` → seconds since `Date.now()`.
- Parse-time validation rejects invalid datetimes and unknown duration
units
(silent-fail would have been a footgun — every packet would just
disappear).
- Null/missing timestamps → predicate returns `false`, consistent with
the
  existing null-field behaviour for `snr` / `rssi`.

## Open questions from the issue

- **UTC vs local**: defaults to whatever `Date.parse` returns. Bare
dates like
`"2024-01-01"` are interpreted as UTC midnight by the spec. Tying this
to
  the #286 timestamp display setting can be a follow-up.
- **URL query string**: out of scope for this PR.

## Tests

- New `test-packet-filter-time.js`: 20 tests covering
`after`/`before`/`between`,
ISO datetimes, all duration units, composition with `&&`, null-timestamp
safety,
  invalid-datetime / invalid-unit errors, and `first_seen` fallback.
- Wired into `.github/workflows/deploy.yml` JS unit-test step.
- Existing `test-packet-filter.js` (69 tests) and inline self-tests
still pass.

## Commits

- Red: `5ccfad3` — failing tests + lexer-only stub (compiles, asserts
fail)
- Green: `976d50f` — implementation

---------

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-05 01:13:48 -07:00
Kpa-clawbot cbfd159f8e feat(ws): pull-to-reconnect on touch devices (Fixes #1063) (#1068)
## Summary

Reframes the browser's native pull-to-refresh on touch devices as a
**WebSocket reconnect** instead of a full page reload. On data pages
(Packets, Nodes, Channels — and globally, since the WS is shared) a
downward pull at `scrollTop=0` cycles the WS, which is what users
actually want when they reach for that gesture.

Fixes #1063.

## Behavior

- **Touch-only**: gated by `('ontouchstart' in window) ||
navigator.maxTouchPoints > 0`. Desktop is untouched.
- **Scroll-safe**: every handler re-checks `scrollTop > 0` and bails out
— never hijacks normal scroll.
- **Visual affordance**: a fixed chip slides down from the top with a
rotating ⟳ icon; opacity and rotation scale with pull progress (0 →
`PULL_THRESHOLD_PX = 80px`).
- **`preventDefault` is conservative**: only after `dy > 16px` and only
on `touchmove`, so taps and short swipes are not affected.
- **Result feedback**: a brief toast — green `Connected ✓` if WS was
already OPEN, `Reconnecting…` otherwise. Both auto-dismiss after ~1.8s.
- **Reconnect path**: closes the existing WS so the existing `onclose`
auto-reconnect fires immediately; an explicit `connectWS()` is also
called as a safety net when `ws` is null.
- **No regression** to existing WS auto-reconnect — same `connectWS` /
`setTimeout(connectWS, 3000)` chain, just kicked manually.

## TDD

- **Red commit** `f90f5e9` — adds `test-pull-to-reconnect.js` with 6
assertions; stub functions added to `app.js` so tests reach assertion
failures (not ReferenceError). 3/6 fail on behavior.
- **Green commit** `53adbd9` — full implementation; 6/6 pass.

## Files

- `public/app.js` — `pullReconnect()`, `setupPullToReconnect()`,
`_ensurePullIndicator()`, `_showPullToast()`, `_isTouchDevice()`. Wired
into `DOMContentLoaded` next to `connectWS()`. Touched the WS section
only.
- `test-pull-to-reconnect.js` — vm sandbox suite covering exposure,
WS-close, listener wiring, threshold trigger, scroll-position gate.

## Acceptance criteria check

-  Pull-down at scroll-top triggers WS reconnect + data refetch
(debounced cache invalidate fires on next WS message)
-  Visible affordance during pull (rotating chip)
-  Resolves on success (toast), shows status toast on disconnect path
-  Disabled when not at `scrollTop=0`
-  No regression to existing WS auto-reconnect

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
2026-05-05 01:11:59 -07:00
Kpa-clawbot eaf14a61f5 fix(css): 48px touch targets, :active states, hover→tap (#1060) (#1067)
## Summary

Fixes #1060 — free-win CSS pass for touch usability.

- All major interactive controls (`.btn`, `.btn-icon`, `.nav-btn`,
`.nav-link`, `.ch-icon-btn`, `.ch-remove-btn`, `.ch-share-btn`,
`.ch-gear-btn`, `.panel-close-btn`, `.mc-jump-btn`, `button.ch-item`)
now declare `min-height: 48px` / `min-width: 48px`. Hit-area grows;
visual padding/icon size unchanged on desktop because the rules use
`inline-flex` centering.
- Added visible `:active` feedback (background shift + `transform:
scale(0.92–0.97)` + opacity) on every button class — touch devices have
no hover, so `:active` is the only press signal.
- Hover-only `.sort-help` tooltip rule is now wrapped in `@media (hover:
hover)`; added a CSS-only `:focus` / `:focus-within` tap-to-reveal path
with a visible focus ring so the same content is reachable on touch (and
via keyboard).
- All changes scoped to the `=== Touch Targets ===` section. No other
CSS section modified, no JS touched, no markup edits.

## Acceptance criteria

- [x] All interactive controls reach 48×48 CSS-px touch target (verified
by `test-touch-targets.js`).
- [x] Every button has a visible `:active` state (no hover-only
feedback).
- [x] Hover tooltip rule is gated behind `@media (hover: hover)`, with
`:focus-within` tap-to-reveal fallback.
- [x] Desktop visuals preserved (padding-based, not visual-size-based).

## TDD

- Red commit `327473b` — `test-touch-targets.js` asserts every required
selector/property; it compiles and fails on assertion against pre-change
CSS.
- Green commit `e319a8f` — Touch Targets section rewrite; test passes.

```
$ node test-touch-targets.js
test-touch-targets.js: OK
```

Fixes #1060

---------

Co-authored-by: bot <bot@corescope>
2026-05-05 01:11:08 -07:00
Kpa-clawbot f9cd43f06f fix(analytics): integrate channels list with PSK decrypt UX + add link from Channels page (#1042)
## What

Integrates the Analytics → Channels section with the PSK decrypt UX (PRs
#1021–#1040). Replaces nonsense `chNNN` placeholders with useful display
names and groups the table the same way the Channels sidebar does.

## Before
- Encrypted channels showed raw `ch185`, `ch64`, `ch?` placeholders.
- Locally-decrypted PSK channels (with stored keys + labels) were not
surfaced — every encrypted row looked identical and useless.
- Single flat list, sorted by last activity by default.

## After
- **My Channels** 🔑 — any analytics row whose hash byte matches a stored
PSK key (via `ChannelDecrypt.getStoredKeys()` + `computeChannelHash`).
Display name uses the user's label if set, otherwise the key name.
- **Network** 📻 — known cleartext channels (server-provided names) and
rainbow-table-decoded encrypted channels.
- **Encrypted** 🔒 — unknown encrypted, rendered as `🔒 Encrypted (0xNN)`
instead of `chNNN`.
- Within each group: messages descending (most active first).
- New `📊 Channel Analytics →` link in the Channels page sidebar header →
`#/analytics`.

## How
- Pure `decorateAnalyticsChannels(channels, hashByteToKeyName, labels)`
— testable in isolation, sets `displayName` + `group` per row.
- `buildHashKeyMap()` — async helper that resolves stored PSK keys to
their channel hash bytes via `computeChannelHash`. Used at render time;
first paint uses an empty map (best-effort) and re-renders once keys
resolve. Graceful fallback when `ChannelDecrypt` is missing or there are
no stored keys.
- `channelTbodyHtml` gains an `opts.grouped` flag — opt-in so the
existing flat sort still works for any other caller.
- The analytics API endpoint is **unchanged** — this is purely frontend
rendering.

## Tests
`test-analytics-channels-integration.js` — 19 assertions covering
decoration, grouping, sort order, and the channels-page link. Added to
`test-all.sh`.

Red commit: `5081b12` (12 assertion failures + stub).  
Green commit: `6be16d9` (all 19 pass).

---------

Co-authored-by: bot <bot@corescope.local>
Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 00:05:09 -07:00
Kpa-clawbot 67da696a42 fix(channels): hide raw psk:* in header, label share button, red delete button (#1041)
## Channel UX round 2 (follow-up to #1040)

Three UX issues reported after #1040 landed:

### 1. Header shows raw `psk:372a9c93` for PSK channels
The selected-channel title rendered `ch.name` directly, which for
user-added PSK channels is the synthetic `psk:<hex8>` string. Users see
opaque key fragments where they expected the friendly name they typed.

**Fix:** new `channelDisplayName(ch)` helper. Returns `ch.userLabel`
when set, falls back to `"Private Channel"` for any `psk:*` name, then
to the original name, then to `Channel <hash>`. Used in both
`selectChannel` (header) and `renderChannelRow` (sidebar).

### 2. Share button `⤴` is unrecognizable
Up-arrow glyph carried no meaning — users didn't know it opened the
QR/key reshare modal.

**Fix:** swap `⤴` for `📤 Share` text label. Same hook, same handler.

### 3. ✕ delete button is a subtle span, not a destructive button
Looked like decorative text, not a real action.

**Fix:** `.ch-remove-btn` gets `background: var(--statusRed, #b54a4a)`,
`color: white`, `border-radius: 4px`, `padding: 4px 8px`, `font-weight:
bold`. Now reads as a destructive action.

### TDD
- Red commit `2d05bbf`: 9 failing assertions (helper missing, ⤴ still
present, CSS rules absent), test compiles + runs to assertion failure.
- Green commit `938f3fc`: all 12 assertions pass. Existing
`test-channel-ux-followup.js` still 28/28.

### Files
- `public/channels.js` — `channelDisplayName` helper, header + row
rendering, share button label
- `public/style.css` — `.ch-remove-btn` destructive styling
- `test-channel-ux-round2.js` — new test (helper behavior + source/CSS
assertions)

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-04 20:56:01 -07:00
Kpa-clawbot c00b585ee5 fix(channels): UX follow-ups to #1037 (touch target, '0 messages', share, locality, #meshcore) (#1040)
## Summary

Seven UX follow-ups to the channel modal/sidebar redesign in #1037.

## Fixes

1. **✕ touch target** — was 13px font + 0×4 padding, far below WCAG
2.5.5 / Apple HIG 44×44px. Bumped `.ch-remove-btn` to a 44×44 hit area
without disturbing desktop layout.
2. **"0 messages" preview** — user-added (PSK) channel rows showed `0
messages` even when dozens were decrypted. `messageCount` only tracks
server-known activity, not PSK decrypts. Drop the misleading fallback:
when no last message is known and the count is zero/absent, render
nothing.
3. **Privacy footer wording** — old copy "Clear browser data to remove
stored keys" was misleading after #1037 added per-channel ✕. Reworded to
point users at the ✕ button.
4. **Reshare affordance** — each user-added row now exposes a `⤴` Share
button that re-opens the QR + key for that channel via
`ChannelQR.generate` (with a plain-hex + `meshcore://channel/add?...`
URL fallback when the QR vendor lib isn't loaded). Reuses the Add
Channel modal; cleared on close.
5. **Drop "(your key)" suffix** from the row preview. The 🔑 badge
already conveys ownership; the suffix was noise. The key hex itself is
now only revealed on explicit Share, not in the sidebar.
6. **Make browser-local nature obvious** — the prior framing made
local-only sound like a feature when it's actually a constraint users
need to plan around. Adds:
- Prominent `.ch-modal-callout` in the Add Channel modal: *"Channels are
saved to **THIS browser only**. They won't appear on other devices or
browsers, and clearing browser data will remove them."*
   - `🖥️ (this browser)` marker in the **My Channels** section header
- Remove-confirm prompt now explicitly says *"permanently remove the key
from this browser"*
7. **#meshcore, not #LongFast** — `#LongFast` is Meshtastic's default
channel name. The meshcore network's analogous default is `#meshcore`.
Updated placeholder + case-sensitivity example in the modal.

## TDD

- Red commit `878d872` — failing assertions for fixes 1–6.
- Green commit `444cf81` — implementation.
- Red commit `6cab596` — failing assertions for fix 7.
- Green commit `9adc1a3` — `#meshcore` swap.

`test-channel-ux-followup.js` (18 assertions) passes. Existing
`test-channel-modal-ux.js` (33) and `test-channel-sidebar-layout.js` (8)
remain green.

## Files
- `public/channels.js` — row template, share handler, modal
callout/footer, sidebar header, confirm copy, placeholder swap
- `public/style.css` — `.ch-remove-btn` / `.ch-share-btn` 44×44,
`.ch-modal-callout`, `.ch-section-locality`
- `test-channel-ux-followup.js` — new test file

---------

Co-authored-by: clawbot <clawbot@local>
2026-05-04 19:57:53 -07:00
Kpa-clawbot cea2c70d12 feat(#1034): channel UX redesign PR1 — Add Channel modal + sectioned sidebar (#1037)
## Summary

PR 1 of 3 for #1034 — channel UX redesign. Replaces the cramped inline
"type a name or 32-hex blob" form with a clear modal dialog, and
reorganizes the sidebar into three labeled sections.

**Scope of this PR:** Modal UI + sectioned sidebar. QR generation/scan
is deferred to PR #2 (placeholders are wired and ready).
`channel-decrypt.js` crypto is untouched.

## What changed

### New modal: `[+ Add Channel]`

Triggered by the new sidebar button. Three sections:

1. **Generate PSK Channel** — name + `[Generate & Show QR]` →
`crypto.getRandomValues(16)` → hex → `ChannelDecrypt.storeKey`. QR
rendering ships in PR #2; for now `#qr-output` surfaces the hex key as
text.
2. **Add Private Channel (PSK)** — 32-hex input (regex-validated),
optional display name, `[Add]`. `[📷 Scan QR]` placeholder is present but
`disabled` (PR #2 wires it).
3. **Monitor Hashtag Channel** — non-editable `#` prefix + free text +
case-sensitivity warning + `[Monitor]`. Reuses
`ChannelDecrypt.deriveKey`.

Privacy footer: _"🔒 Keys stay in your browser. CoreScope is a passive
observer..."_

Close ✕, backdrop click, and Escape all dismiss.

### Sectioned sidebar

`renderChannelList()` rewritten to render three sections:

- **My Channels** — `userAdded` channels. ✕ always visible. Last sender
+ relative time.
- **Network** — server-known cleartext channels.
- **Encrypted (N)** — collapsed by default (toggle persists in
`localStorage`). Shows hash byte + packet count.

The legacy "🔒 No key" checkbox and `#chShowEncrypted` toggle are removed
entirely. Encrypted channels are always fetched; the renderer groups
them.

## Tests

- **Unit** — `test-channel-modal-ux.js` (33 assertions): added to
`test-all.sh`. Covers sidebar button, modal markup, three sections, QR
placeholders, privacy footer, sectioned sidebar, modal handlers (incl.
`crypto.getRandomValues(16)`).
- **E2E** — `test-channel-modal-e2e.js` (Playwright, 14 steps). Covers
modal open/close, section rendering, invalid-hex error, valid-hex
storage, encrypted-section toggle. Run with:
  ```
CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:38201
node test-channel-modal-e2e.js
  ```
- `test-channel-psk-ux.js` — updated to reference `#chPskName` (was
`#chKeyLabelInput`).

### Red→green proof

- Red commit (`7ee421b`): test added with 31 expected assertion
failures, no source change.
- Green commit (`897be8f`): implementation lands, test passes 33/33.

## Browser-validated

Built `cmd/server/`, ran against `test-fixtures/e2e-fixture.db`,
exercised modal open → invalid hex → valid hex → key persisted → modal
closes → sectioned sidebar renders + Encrypted toggle expands. All 14
E2E steps pass.

## What's NOT in this PR

- QR code rendering (PR #2)
- Camera/QR scanning (PR #2)
- Migration of legacy localStorage format (PR #3, if needed — current
key format is unchanged)
- `channel-decrypt.js` changes (none — UI-only PR)

## Acceptance criteria from #1034

- [x] Modal opens on `[+ Add Channel]` click
- [x] Three sections clearly separated with labels
- [x] Add PSK: accepts 32-hex (QR scan = PR #2)
- [x] Monitor Hashtag: derives key, case-sensitivity warning shown
- [x] Privacy footer present
- [x] Sidebar: three sections (My Channels / Network / Encrypted)
- [x] ✕ button visible and functional on My Channels entries
- [x] "No key" checkbox removed
- [ ] Generate PSK QR display — text fallback only; QR is PR #2
- [ ] Old stored keys migrate seamlessly — no migration needed (storage
format unchanged)

Refs #1034

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-04 18:40:46 -07:00
Kpa-clawbot 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>
2026-05-04 18:29:48 -07:00
Kpa-clawbot d967170dd3 fix(channels): sidebar layout for user-added (PSK) rows — nested <button> bug (#1033)
## Problem

Channel sidebar layout broke for user-added (PSK) channels. Visible
symptoms in the screenshot:

- No ✕ (delete) button on user-added rows
- 🔑 emoji floating in the wrong position
- Message preview text (e.g. `KpaPocket: Тест`) orphaned **between**
channel entries instead of inside the row
- Spinner/loading dots misaligned

## Root cause

**HTML5 forbids nested `<button>` elements.** The `.ch-item` row is a
`<button>`, and #1024 added a `<button class="ch-remove-btn">` inside
it. The HTML parser implicitly closes the outer `.ch-item` the moment it
sees the inner `<button>`, then re-parents everything after it (✕ and
the `.ch-item-preview` line) outside the row.

Resulting DOM tree (parser-corrected, simplified):

```
<button class="ch-item">[icon] Levski 🔑</button>   <-- closes early
<button class="ch-remove-btn">✕</button>            <-- orphaned, "floating"
<div class="ch-item-preview">KpaPocket: Тест</div>  <-- orphaned
<button class="ch-item">[icon] #bookclub …</button>
```

Compounded by `.ch-remove-btn { opacity: 0 }` (only visible on row
hover), which made the ✕ undiscoverable on touch devices even before the
parser bug.

## Fix

`public/channels.js`
- Replace the inner `<button class="ch-remove-btn">` with `<span
class="ch-remove-btn" role="button" tabindex="0">`. Click delegation
already keys off `[data-remove-channel]` so behavior is unchanged.
- Add `keydown` (Enter / Space) handler on `#chList` so the role=button
span stays keyboard-accessible.
- Relabel the ambiguous `🔒 No key` toggle to `🔒 Show encrypted (no
key)`, with an explanatory `title` ("Show encrypted channels you don't
have a key for (locked, can't decrypt)") so users understand it controls
visibility of channels they haven't added a PSK for.

`public/style.css`
- `.ch-remove-btn`: drop `opacity: 0` default. Now `0.55` idle, `0.9` on
row hover, `1` on direct hover/focus. Added `:focus` outline removal +
`display: inline-flex` so the ✕ centers cleanly.
- Add `.ch-user-badge` rule (was unstyled — contributed to the
misalignment of the 🔑).

## TDD

- Red commit `eeb94ad` — `test-channel-sidebar-layout.js` (7 assertions,
3 failing on master).
- Green commit `2959c3d` — fix; all 7 pass.
- Wire commit `4d6100d` — added to `test-all.sh`.

Existing channel test files still pass (`test-channel-psk-ux.js`,
`test-channel-live-decrypt.js`,
`test-channel-live-decrypt-userprefix.js`,
`test-channel-decrypt-m345.js`,
`test-channel-decrypt-insecure-context.js`).

## Files changed
- `public/channels.js`
- `public/style.css`
- `test-channel-sidebar-layout.js` (new)
- `test-all.sh`
2026-05-04 18:29:45 -07:00
Kpa-clawbot 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>
2026-05-04 18:29:42 -07:00
Kpa-clawbot 26daa760cd fix(channels): live PSK decrypt for user-added channels (#1029 follow-up) (#1031)
## Problem

PR #1030 added live PSK decrypt for GRP_TXT WS packets, but in
production it still didn't work for **user-added** PSK channels. New
messages never appeared in real time on a channel added via the sidebar
key form — users had to refresh the page to see them via the REST fetch
path (regression #1029).

## Root cause

`decryptLivePSKBatch` rewrites the payload with the raw channel name:

```js
payload.channel = dec.channelName;   // e.g. "medusa"
```

But user-added channels live in `channels[]` under the key produced by
`addUserChannel`:

```js
hash: 'user:' + name,                // e.g. "user:medusa"
```

`selectedHash` also uses the `user:`-prefixed key while a user-added
channel is open. Downstream in `processWSBatch`:

| Line | Check | Result |
|---|---|---|
| 962 | `c.hash === channelName` | `"medusa" !== "user:medusa"` → user
channel never matched |
| 982 | `channelName === selectedHash` | `"medusa" !== "user:medusa"` →
message never appended to open chat |
| 974 | `channels.push({ hash: channelName, ... })` | duplicate plain
`"medusa"` entry pushed into sidebar |

The unread bumper (`channels.js:1086`) compared `chName === prior` with
the same mismatch, so it bumped an unread badge on the channel currently
being viewed.

Verified end to end against staging WS traffic (live `decryption_status:
"decrypted"` packets observed; user-added channel never updated,
duplicate entry created).

## Fix

`decryptLivePSKBatch` now also stamps a canonical sidebar key on the
payload:

```js
payload.channelKey = hasUserCh ? ('user:' + dec.channelName) : dec.channelName;
```

`processWSBatch` and the unread bumper route on `payload.channelKey`
(falling back to `payload.channel` for server-known CHAN packets — no
behavior change there).

After the fix:
-  live message appends to the open user-added chat
-  sidebar row's `lastMessage` / `messageCount` / `lastActivityMs`
update
-  no duplicate non-prefixed sidebar entry
-  unread bumped only on channels NOT being viewed

## TDD

Red commit `f1719a8` — `test-channel-live-decrypt-userprefix.js`, fails
6/9 on assertions (NOT build error) on pristine `channels.js`.
Green commit `da87018` — minimal fix in `channels.js`, all 9/9 pass.

Verified red gates the change: stashed `public/channels.js`, re-ran test
on red commit alone → 6 assertion failures (open channel got 0 messages,
duplicate sidebar entry, unread bumped on viewed channel).

## Files changed

- `public/channels.js` — stamp/route on `channelKey`
- `test-channel-live-decrypt-userprefix.js` (new) — red-then-green
regression test

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-05-04 16:44:35 -07:00
Kpa-clawbot 3290ff1ed5 fix(channels): auto-decrypt PSK channels on WebSocket live feed (#1029) (#1030)
Closes #1029.

## Problem

PSK-decrypted channels show new messages only after a full page refresh.
The WebSocket live feed delivers `GRP_TXT` packets as encrypted blobs
and the channel UI has no hook to auto-decrypt them with stored keys.
The REST fetch path (used on initial load + on `selectChannel`) already
decrypts; the WS path silently dropped on the floor.

## Fix

Two new helpers in `public/channel-decrypt.js`:

- `buildKeyMap()` → `Map<channelHashByte, { channelName, keyBytes,
keyHex }>`
  built from `getStoredKeys()`. Cached and invalidated on `saveKey` /
  `removeKey`, so the WS hot path is O(1) per packet after the first
  build.
- `tryDecryptLive(payload, keyMap)` → returns
`{ sender, text, channelName, channelHashByte }` when the payload is an
  encrypted `GRP_TXT` whose channel hash matches a stored key and whose
  MAC verifies; `null` otherwise.

`public/channels.js` wraps `debouncedOnWS` with an async pre-pass
(`decryptLivePSKBatch`) that:

1. Skips the work entirely when no encrypted `GRP_TXT` is in the batch
   or no PSK keys are stored.
2. For each match, rewrites `payload.channel`, `payload.sender`, and
   `payload.text` so the existing `processWSBatch` consumes the packet
   exactly the same way it consumes a server-decrypted `CHAN`.
3. Bumps a per-channel `unread` counter for any decrypted message
   whose channel is not currently selected. The badge renders in the
   sidebar (`.ch-unread-badge`) and resets on `selectChannel`.

`processWSBatch` itself is untouched, so the existing channel-view
behavior, dedup-by-packet-hash, region filtering, and timestamp ticker
all continue to work as before.

## TDD

- **Red** (`2e1ff05`): `test-channel-live-decrypt.js` asserts the new
  helpers + the channels.js integration contract. With stub
  `buildKeyMap`/`tryDecryptLive` returning empty/null, the test compiles
  and runs to completion with **8/14 assertion failures** (no crashes,
  no missing-symbol errors).
- **Green** (`1783658`): real implementation lands; **14/14 pass**.

## Verification (Rule 18)

- `node test-channel-live-decrypt.js` → 14/14 pass
- All other channel tests still pass:
  - `test-channel-decrypt-ecb.js` 7/7
  - `test-channel-decrypt-insecure-context.js` 8/8
  - `test-channel-decrypt-m345.js` 24/24
  - `test-channel-psk-ux.js` 19/19
- `cd cmd/server && go build ./...` clean
- Booted the server against the fixture DB and curled
  `/channel-decrypt.js`, `/channels.js`, `/style.css` — all three serve
  the new code with the auto-injected `__BUST__` cache buster.

## Performance

The WS pre-pass is gated by a quick scan: zero-cost when no encrypted
`GRP_TXT` is present in the batch. When PSK keys exist, the key map is
cached (sig-keyed on the stored-keys snapshot) so `crypto.subtle.digest`
runs once per stored key per change, not per packet. Each match costs
one MAC verify + one ECB decrypt — the same work
`fetchAndDecryptChannel`
already does, just amortized over time instead of in a single batch.

## Out of scope

- Decoupling the badge from the live feed (server should ideally tag
  packets with `decryptionStatus` before broadcast). Tracked separately.
- Persisting the `unread` counter across reloads (currently in-memory).

---------

Co-authored-by: clawbot <bot@corescope.local>
2026-05-04 04:56:43 +00:00
Kpa-clawbot 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>
2026-05-03 21:06:59 -07:00
Kpa-clawbot a1f4cb9b5d fix(channels): PSK channel UX — delete, label, badge, toast (#1020) (#1024)
## Problem

The PSK channel decrypt UX was unusable (#1020):

1. ✕ button only appeared when a `userAdded` flag happened to be set,
which wasn't reliable for keys matching server-known hashes.
2. PSK channels visually indistinguishable from server-known encrypted
channels — both rendered with 🔒.
3. No way to give a PSK channel a friendly name; sidebar always showed
`psk:<hex8>`.
4. "Decrypt count" toast was scraped from `#chMessages .ch-msg` after a
race, so it often reported zero or stale numbers.

## Changes

### `public/channel-decrypt.js`
- **New API**: `saveLabel(name, label)`, `getLabel(name)`,
`getLabels()`.
- `storeKey(name, hex, label?)` — third optional `label` argument
persists alongside the key under a separate `corescope_channel_labels`
localStorage namespace.
- `removeKey` now also clears the stored label.

### `public/channels.js`
- Add-channel form gets a second row with `#chKeyLabelInput` ("optional
name (e.g. My Crew)").
- `addUserChannel(val, label)` — passes the label through to `storeKey`.
- `mergeUserChannels()` reads `getLabels()` and propagates `userLabel`
onto channel objects (both new ones and ones that match an existing
server-known hash).
- `renderChannelList()` distinguishes user-added rows:
  - `.ch-user-added` class + `data-user-added="true"` attribute.
- 🔓 badge icon (vs 🔒 for server-known no-key) and a 🔑 marker next to the
name.
  - Display name uses the user-supplied label when present.
- ✕ remove button is now keyed off `userAdded` (which
`mergeUserChannels` always sets for stored keys).
- `selectChannel` now returns `{ messageCount, wrongKey?, error?, stale?
}`. `addUserChannel` uses that for the toast instead of scraping the
DOM, and surfaces `wrongKey` explicitly: "Key does not match any packets
for …".

## Acceptance criteria

- [x] ✕ (delete) button on all user-added PSK channels in sidebar
- [x] Clicking ✕ removes key + label + cache from localStorage and
removes from sidebar
- [x] Visual badge/icon distinguishing "my keys" (🔓 + 🔑 +
`.ch-user-added`) from "unknown encrypted" (🔒 + `.ch-encrypted`)
- [x] Optional name field in the add-channel form (`#chKeyLabelInput`),
stored alongside key in localStorage
- [x] Name displayed in sidebar instead of `psk:<hex>`
- [x] Toast shows decrypt result count after adding (and reports
`wrongKey` explicitly)

## Tests

`test-channel-psk-ux.js` (added to `test-all.sh`) — 19 assertions:

- ChannelDecrypt label storage + retrieval + `removeKey` cascade.
- E2E DOM contract for `channels.js`: `#chKeyLabelInput`,
`.ch-user-added`, 🔓 icon, `addUserChannel` accepts label, no DOM
scraping for decrypt count.
- End-to-end `mergeUserChannels` label propagation through a
sandbox-loaded `ChannelDecrypt`.

Red commit (`da6d477`) failed 8/15 assertions; green commit (`542bb1d`)
— all 19 pass. Existing channel tests still green:

```
node test-channel-decrypt-ecb.js   → 7/7
node test-channel-decrypt-m345.js  → 24/24
node test-channel-psk-ux.js        → 19/19
```

(The pre-existing `test-frontend-helpers.js` failure on `nodes.js`
`loadNodes` reproduces on `origin/master` — unrelated.)

## Notes

- Decrypt logic untouched (PR #1021 already fixed it).
- No config fields added.
- Keys + labels stay in the user's browser; nothing transmitted.

Fixes #1020

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-03 18:38:18 -07:00
Kpa-clawbot 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>
2026-05-03 17:56:12 -07:00
Kpa-clawbot 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>
2026-05-04 00:46:24 +00:00
Kpa-clawbot a56ee5c4fe feat(analytics): selectable timeframes via ?window/?from/?to (#842) (#1018)
## Summary
Selectable analytics timeframes (#842). Adds backend support for
`?window=1h|24h|7d|30d` and `?from=&to=` on the three main analytics
endpoints (`/api/analytics/rf`, `/api/analytics/topology`,
`/api/analytics/channels`), and a time-window picker in the Analytics
page UI that drives them. Default behavior with no query params is
unchanged.

## TDD trail
- Red: `bbab04d` — adds `TimeWindow` + `ParseTimeWindow` stub and tests;
tests fail on assertions because the stub returns the zero window.
- Green: `75d27f9` — implements `ParseTimeWindow`, threads `TimeWindow`
through `compute*` loops + caches, wires HTTP handlers, adds frontend
picker + E2E.

## Backend changes
- `cmd/server/time_window.go` — full `ParseTimeWindow` (`?window=`
aliases + `?from=/&to=` RFC3339 absolute range; invalid input → zero
window for backwards compatibility).
- `cmd/server/store.go` — new
`GetAnalytics{RF,Topology,Channels}WithWindow` wrappers; `compute*`
loops skip transmissions whose `FirstSeen` (or per-obs `Timestamp` for
the region+observer slice) falls outside the window. Cache key composes
`region|window` so different windows do not poison each other.
- `cmd/server/routes.go` — handlers call `ParseTimeWindow(r)` and
dispatch to the `*WithWindow` methods.

## Frontend changes
- `public/analytics.js` — new `<select id="analyticsTimeWindow">`
rendered under the region filter (All / 1h / 24h / 7d / 30d). Selecting
an option triggers `loadAnalytics()` which appends `&window=…` to every
analytics fetch.

## Tests
- `cmd/server/time_window_test.go` — covers all aliases, absolute range,
no-params backwards compatibility, `Includes()` bounds, and `CacheKey()`
distinctness.
- `cmd/server/topology_dedup_test.go`,
`cmd/server/channel_analytics_test.go` — updated callers to pass
`TimeWindow{}`.

## E2E (rule 18)
`test-e2e-playwright.js:592-611` — opens `/#/analytics`, asserts the
picker is rendered with a `24h` option, then asserts that selecting
`24h` triggers a network request to `/api/analytics/rf?…window=24h`.

## Backwards compatibility
No params → zero `TimeWindow` → original code paths (no filter,
region-only cache key). Verified by
`TestParseTimeWindow_NoParams_BackwardsCompatible` and by the existing
analytics tests still passing unchanged on `_wt-fix-842`.

Fixes #842

---------

Co-authored-by: you <you@example.com>
Co-authored-by: corescope-bot <bot@corescope>
2026-05-03 17:41:22 -07:00
Kpa-clawbot df69a17718 feat(#772): short pubkey-prefix URLs for mesh sharing (#1016)
## Summary

Fixes #772 — adds a short-URL form for node detail pages so operators
can paste node links into a mesh chat without bringing along a
64-hex-char public key.

## Approach

**Pubkey-prefix resolution** (no allocator, no lookup table).

- The SPA hash route `#/nodes/<key>` already accepts whatever
pubkey-shaped string the user pastes; the front end forwards it to `GET
/api/nodes/<key>`.
- When that lookup misses **and** the path is 8..63 hex chars, the
backend now calls `DB.GetNodeByPrefix` and:
  - returns the matching node when exactly one node has that prefix,
- returns **409 Conflict** when multiple nodes share the prefix (with a
"use a longer prefix" hint),
  - falls through to the existing 404 otherwise.
- 8 hex chars = 32 bits of entropy, which is enough for fleets in the
low thousands. Operators can extend to 10–12 chars if collisions become
common.
- The full-screen node detail card gets a new **📡 Copy short URL**
button that copies `…/#/nodes/<first 8 hex chars>`.

### Why not an opaque ID table (`/s/<id>`)?

Considered and rejected:

- Needs persistence + an allocator + cleanup story.
- IDs aren't self-describing — operators can't sanity-check them.
- IDs don't survive a DB rebuild.
- 32 bits of pubkey already buys us collision resistance with zero
moving parts.

If the directory grows past the point where 8-char prefixes routinely
collide, we can extend the minimum length without changing the URL
shape.

## Changes

- `cmd/server/db.go` — new `GetNodeByPrefix(prefix)` returning `(node,
ambiguous, error)`. Validates hex; rejects <8 chars; `LIMIT 2` to detect
collisions cheaply.
- `cmd/server/routes.go` — `handleNodeDetail` falls back to prefix
resolution; canonicalizes pubkey downstream; emits 409 on ambiguity;
honors blacklist on the resolved pubkey.
- `public/nodes.js` — adds **📡 Copy short URL** button + handler on the
full-screen node detail card.
- `cmd/server/short_url_test.go` — Go tests (red-then-green).
- `test-e2e-playwright.js` — E2E: navigates via prefix-only URL and
asserts the new button surfaces.

## TDD evidence

- Red commit: `2dea97a` — tests added with a stub `GetNodeByPrefix`
returning `(nil, false, nil)`. All four assertions failed (assertion
failures, not build errors): expected node got nil; expected
ambiguous=true got false; route 404 vs expected 200/409.
- Green commit: `9b8f146` — implementation lands; `go test ./...` passes
locally in `cmd/server`.

## Compatibility

- Existing 64-char pubkey URLs are untouched (exact lookup runs first).
- Blacklist is enforced both on the raw input and on the resolved
pubkey.
- No new config knobs.

## What I did **not** touch

- `cmd/server/db_test.go`, other route tests — unchanged.
- Packet-detail short URLs (issue scopes nodes; revisit in a follow-up
if asked).

Fixes #772

---------

Co-authored-by: clawbot <bot@corescope.local>
2026-05-03 17:40:54 -07:00
Kpa-clawbot f229e15869 feat(packet-filter): transport boolean + T_FLOOD/T_DIRECT route aliases (#339) (#1014)
## Summary

Adds Wireshark-style filter support for transport route type to the
packets-page filter engine, per #339.

## New filter syntax

| Filter | Matches |
|---|---|
| `transport == true` | route_type 0 (TRANSPORT_FLOOD) or 3
(TRANSPORT_DIRECT) |
| `transport == false` | route_type 1 (FLOOD) or 2 (DIRECT) |
| `transport` | bare truthy — same as `transport == true` |
| `route == T_FLOOD` | alias for `route == TRANSPORT_FLOOD` |
| `route == T_DIRECT` | alias for `route == TRANSPORT_DIRECT` |
| `route == TRANSPORT_FLOOD` / `TRANSPORT_DIRECT` | already worked —
canonical names |

Aliases are case-insensitive (`route == t_flood` works).

## Implementation

- `public/packet-filter.js`: new `transport` virtual boolean field
driven by `isTransportRouteType(rt)` which returns `rt === 0 || rt ===
3`, mirroring `isTransportRoute()` in `cmd/server/decoder.go`.
- `ROUTE_ALIASES = { t_flood: 'TRANSPORT_FLOOD', t_direct:
'TRANSPORT_DIRECT' }` resolved in the equality comparator, same pattern
as the existing `TYPE_ALIASES`.
- All client-side; no backend changes (issue noted this).

## Tests / TDD

Red commit: `9d8fdf0` — five new assertion-failing test cases + wires
`test-packet-filter.js` into CI (it existed but wasn't being executed).
Green commit: `c67612b` — implementation makes all 69 tests pass.

The CI wiring is part of the red commit on purpose: previously
`test-packet-filter.js` was never run by CI, so a frontend filter
regression couldn't fail the build. Now it can.

## CI gating proof

Run `git revert c67612b` locally → `node test-packet-filter.js` reports
5 assertion failures (not build/import errors). Re-applying the green
commit returns all tests to passing.

Fixes #339

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-03 17:40:12 -07:00
Kpa-clawbot 4d043579f8 feat: geofilter draft save (localStorage) + downloadable config snippet (#1006)
## Issue

Closes #819

## Summary

Adds Save Draft / Load Draft / Download buttons to
`/geofilter-builder.html` so operators can:
- Persist their work-in-progress polygon across sessions (localStorage)
- Reload it later to continue editing
- Download a ready-to-paste `geo_filter` JSON snippet for `config.json`

## Implementation

- New module `public/geofilter-draft.js` exposes `GeofilterDraft` global
with `saveDraft / loadDraft / clearDraft / buildConfigSnippet /
downloadConfig`.
- Builder HTML wires three new buttons; updates the help text to
document the new flow.

## TDD

- Red commit: `b0a1a4c` (tests fail — module doesn't exist)
- Green commit: `a717f33` (implementation added, all tests pass)

## How to test

1. Open `/geofilter-builder.html`
2. Click 3+ points on the map
3. Click "Save Draft" — reload page — click "Load Draft" → polygon
restored
4. Click "Download" → `geofilter-config-snippet.json` downloaded with
correct format

---

E2E assertion added: test-e2e-playwright.js:2264

---------

Co-authored-by: you <you@example.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-03 18:24:08 +00:00
Kpa-clawbot c186129d47 feat: parse and display per-hop SNR values for TRACE packets (#1007)
## Summary

Parse and display per-hop SNR values from TRACE packets in the Packet
Byte Breakdown panel.

## Changes

### Backend (`cmd/server/decoder.go`)
- Added `SNRValues []float64` field to Payload struct
(`json:"snrValues,omitempty"`)
- In the TRACE-specific block, extract SNR from header path bytes before
they're overwritten with route hops
- Each header path byte is `int8(SNR_dB * 4.0)` per firmware — decode by
dividing by 4.0

### Frontend (`public/packets.js`)
- Added "SNR Path" section in `buildFieldTable()` showing per-hop SNR
values in dB when packet type is TRACE
- Added TRACE-specific payload rendering (trace tag, auth code, flags
with hash_size, route hops)

## TDD

- Red commit: `4dba4e8` — test asserts `Payload.SNRValues` field
(compile fails, field doesn't exist)
- Green commit: `5a496bd` — implementation passes all tests

## Testing

- `go test ./...` passes (all existing + 2 new TRACE SNR tests)
- No frontend test changes needed (no existing TRACE UI tests; rendering
is additive)

Fixes #979

---------

Co-authored-by: you <you@example.com>
2026-05-03 11:17:25 -07:00
Kpa-clawbot 23d1e8d328 feat: add flood/direct packet filter to observer comparison page (#1000)
## Summary

Adds a **Flood / Direct packet filter** dropdown to the observer
comparison page. This addresses the issue that direct packets (heard by
only one observer) skew the comparison percentages.

## Changes

- **`public/compare.js`**: Added `filterPacketsByRoute(packets, mode)`
function and a "Packet Type" dropdown (All / Flood only / Direct only)
to the comparison controls. Changing the filter re-runs the comparison
with filtered packets.
- **`test-compare-flood-filter.js`**: Unit tests for the filter
function.

## Route type mapping (from firmware)

| Route Type | Value | Filter |
|---|---|---|
| TransportFlood | 0 | Flood |
| Flood | 1 | Flood |
| Direct | 2 | Direct |
| TransportDirect | 3 | Direct |

## TDD

- Red commit: `484fa72` (test only, fails)
- Green commit: `5661f71` (implementation, tests pass)

Fixes #928

---------

Co-authored-by: you <you@example.com>
2026-05-03 08:58:25 -07:00
Kpa-clawbot e86b5a3a0c feat: show multi-byte hash support indicator on map markers (#1002)
## Summary

Show 2-byte hash support indicator on map markers. Fixes #903.

## What changed

### Backend (`cmd/server/store.go`, `cmd/server/routes.go`)

- **`EnrichNodeWithMultiByte()`** — new enrichment function that adds
`multi_byte_status` (confirmed/suspected/unknown), `multi_byte_evidence`
(advert/path), and `multi_byte_max_hash_size` fields to node API
responses
- **`GetMultiByteCapMap()`** — cached (15s TTL) map of pubkey →
`MultiByteCapEntry`, reusing the existing `computeMultiByteCapability()`
logic that combines advert-based and path-hop-based evidence
- Wired into both `/api/nodes` (list) and `/api/nodes/{pubkey}` (detail)
endpoints

### Frontend (`public/map.js`)

- Added **"Multi-byte support"** checkbox in the map Display controls
section
- When toggled on, repeater markers change color:
  - 🟢 Green (`#27ae60`) — **confirmed** (advertised with hash_size ≥ 2)
- 🟡 Yellow (`#f39c12`) — **suspected** (seen as hop in multi-byte path)
  - 🔴 Red (`#e74c3c`) — **unknown** (no multi-byte evidence)
- Popup tooltip shows multi-byte status and evidence for repeaters
- State persisted in localStorage (`meshcore-map-multibyte-overlay`)

## TDD

- Red commit: `2f49cbc` — failing test for `EnrichNodeWithMultiByte`
- Green commit: `4957782` — implementation + passing tests

## Performance

- `GetMultiByteCapMap()` uses a 15s TTL cache (same pattern as
`GetNodeHashSizeInfo`)
- Enrichment is O(n) over nodes, no per-item API calls
- Frontend color override is computed inline during existing marker
render loop — no additional DOM rebuilds

---------

Co-authored-by: you <you@example.com>
2026-05-03 08:56:09 -07:00
Kpa-clawbot 8dfcec2ff3 feat: include favorites and claimed nodes in export/import JSON (#1003)
## Summary

Extends the customizer v2 export/import to include favorite nodes and
claimed ("My Mesh") nodes, so users can transfer their full setup
between browsers/devices.

## Changes

### `public/customize-v2.js`
- `readOverrides()` now merges `favorites` (from `meshcore-favorites`)
and `myNodes` (from `meshcore-my-nodes`) into the exported JSON
- `writeOverrides()` extracts `favorites`/`myNodes` arrays and writes
them to their respective localStorage keys, keeping theme overrides
separate
- `validateShape()` validates both new keys as arrays, rejecting
non-array values
- `VALID_SECTIONS` updated to include `favorites` and `myNodes`

### `test-customizer-v2.js`
- 8 new tests covering read/write/validate for both favorites and
myNodes

## TDD
- Red commit: `0405fb7` (failing tests)
- Green commit: `bb9dc34` (implementation)

Fixes #895

---------

Co-authored-by: you <you@example.com>
2026-05-02 23:04:20 -07:00
Kpa-clawbot 440bda6244 fix(channels): channel color picker UX (closes #681) (#995)
## Summary

Fixes the channel color picker UX issues on both Live page and Channels
page.

Closes #681

## Repro Evidence (on master at HEAD)

- **Live feed dots**: 12px inline — too small to reliably click in a
fast-moving feed
- **Right-click hijack**: `contextmenu` listener on live feed conflicts
with browser context menu
- **Channels page**: No way to clear an assigned color without opening
the picker popover
- **Popover positioning**: 8px edge margin causes overlap with panel
borders

## Root Cause

| Issue | File:Line |
|-------|-----------|
| Tiny dots | `public/live.js:2847` — inline `width:12px;height:12px` |
| Context menu hijack | `public/channel-color-picker.js:231` —
`feed.addEventListener('contextmenu', ...)` |
| No clear affordance | `public/channels.js:1101` — dot rendered without
adjacent clear button |
| Popover overlap | `public/channel-color-picker.js:108-109` — `vw - pw
- 8` margin |

## Fix

1. Increased feed color dots to 18px (visible, clickable)
2. Removed contextmenu listener from live feed — dots are the
interaction point
3. Added inline `✕` clear button next to colored dots on channels page
4. Increased popover edge margin to 14px

## TDD Evidence

- **Red commit:** `2034071` — 6/8 tests fail (dot size, contextmenu,
clear affordance, margins)
- **Green commit:** `49636e5` — all 8 tests pass

## Verification

- `node test-color-picker-ux.js` — 8/8 pass
- `node test-channel-color-picker.js` — 17/17 pass (existing tests
unbroken)

---------

Co-authored-by: you <you@example.com>
2026-05-02 21:05:15 -07:00
Kpa-clawbot aea0a9caee fix(packets): preserve scroll position on filter change + group expand/collapse (closes #431) (#996)
## Summary

Closes #431. Preserves scroll position on the packets page when filters
change or groups are expanded/collapsed.

## Problem

When an operator scrolls down through packet history then changes a
filter (type, observer, packet-filter expression) or expands/collapses a
group, `renderTableRows()` rebuilds the DOM which resets `scrollTop` to
0. This forces the user back to the top — frustrating when digging
through hundreds of packets.

## Fix

Save `scrollContainer.scrollTop` at the start of `renderTableRows()`,
restore it after DOM rebuild completes. Two restore points:
1. **Empty-results path** (line ~1821): after `tbody.innerHTML = ...` 
2. **Normal virtual-scroll path** (line ~1840): after
`renderVisibleRows()`

### Key lines changed
- `public/packets.js` lines 1748–1749: save scrollTop
- `public/packets.js` line 1821: restore after empty-state DOM write  
- `public/packets.js` line 1840: restore after renderVisibleRows

## TDD evidence

- **Red commit:** a99ba21 — test asserts scrollTop preserved; fails
without fix
- **Green commit:** 35cc4bf — adds save/restore; test passes

## Anti-tautology

Removing the `scrollContainer.scrollTop = savedScrollTop` lines causes
the test to fail (scrollTop becomes 0 instead of 500). Verified locally.

## Verification

- `node test-packets.js` — 83 passed, 0 failed
- `node test-packet-filter.js` — 62 passed, 0 failed

---------

Co-authored-by: you <you@example.com>
2026-05-02 21:03:01 -07:00
Kpa-clawbot 736b09697d fix(analytics): apply customizer timestamp format to chart axes (closes #756) (#981)
## Summary

Fixes #756 — the customizer timestamp format setting (ISO/ISO+ms/locale)
and timezone (UTC/local) were not applied to chart X-axis labels,
tooltips, or certain inline timestamps in the analytics pages.

## Changes

### `public/app.js`
- Added `formatChartAxisLabel(date, shortForm)` — a shared helper that
reads the customizer's `timestampFormat` and `timestampTimezone`
preferences and formats dates for chart axes accordingly.
`shortForm=true` returns time-only (for intra-day charts),
`shortForm=false` returns date+time (for multi-day ranges).

### `public/analytics.js`
- `rfXAxisLabels()`: now calls `formatChartAxisLabel()` instead of
hardcoded `toLocaleTimeString()`
- `rfTooltipCircles()`: tooltip timestamps now use
`formatAbsoluteTimestamp()` instead of raw ISO
- Subpath detail first/last seen: now uses `formatAbsoluteTimestamp()`
- Neighbor graph last_seen: now uses `formatAbsoluteTimestamp()`

### `public/node-analytics.js`
- Packet timeline chart labels: now use `formatChartAxisLabel()`
(respects short vs long form based on time range)
- SNR over time chart labels: now use `formatChartAxisLabel()`

## Behavior by setting

| Setting | Chart axis (short) | Chart axis (long) |
|---------|-------------------|-------------------|
| ISO | `14:30` | `05-03 14:30` |
| ISO+ms | `14:30:05` | `05-03 14:30:05` |
| Locale | `2:30 PM` | `May 3, 2:30 PM` |

All respect the UTC/local timezone toggle.

## Testing

- Server builds cleanly (`go build`)
- Served `app.js` contains `formatChartAxisLabel` (verified via curl)
- Graceful fallback: all callsites check `typeof formatChartAxisLabel
=== 'function'` before calling, preserving backward compat if script
load order changes

---------

Co-authored-by: you <you@example.com>
2026-05-02 20:10:29 -07:00
Kpa-clawbot 53ab302dd6 fix(packets): clear-filters button (rebased + addresses greybeard) (closes #964) (#975)
Rebased version of #973 onto current master, with greybeard review
fixes.

## Changes from #973
- **Stowaway revert dropped**: The original PR branched from older
master and inadvertently reverted PR #926's MQTT connect-retry fix
(`cmd/ingestor/main.go` + `cmd/ingestor/main_test.go`). After rebasing
onto current master (which includes #926 + #970), these files no longer
appear in the diff.
- **Greybeard M1 fixed**: Time-window filter (`savedTimeWindowMin`,
`fTimeWindow` dropdown, `localStorage 'meshcore-time-window'`) is now
reset by the clear-filters button. The clear-button visibility predicate
also accounts for non-default time window.
- **Greybeard m1 fixed**: Replaced 7 tautological source-grep tests with
8 behavioral vm-sandbox tests that extract and execute the actual clear
handler + `updatePacketsUrl`, asserting real state transitions.

## Original feature (from #973)
Clear-filters button for the packets page — resets all filter state
(hash, node, observer, channel, type, expression, myNodes, time window,
region) and refreshes. Button visibility auto-toggles based on active
filter state.

Closes #964
Supersedes #973

## Tests
- `node test-clear-filters.js` — 8 behavioral tests 
- `node test-packets.js` — 82 tests 
- `cd cmd/ingestor && go test ./...` — 

---------

Co-authored-by: you <you@example.com>
2026-05-02 12:12:51 -07:00
Kpa-clawbot 3364eed303 feat: separate "Last Status Update" from "Last Packet Observation" for observers (v3 rebase) (#969)
Rebased version of #968 (which was itself a rebase of #905) — resolves
merge conflict with #906 (clock-skew UI) that landed on master.

## Conflict resolution

**`public/observers.js`** — master (#906) added "Clock Offset" column to
observer table; #968 split "Last Seen" into "Last Status" + "Last
Packet" columns. Combined both: the table now has Status | Name | Region
| Last Status | Last Packet | Packets | Packets/Hour | Clock Offset |
Uptime.

## What this PR adds (unchanged from #968/#905)

- `last_packet_at` column in observers DB table
- Separate "Last Status Update" and "Last Packet Observation" display in
observers list and detail page
- Server-side migration to add the column automatically
- Backfill heuristic for existing data
- Tests for ingestor and server

## Verification

- All Go tests pass (`cmd/server`, `cmd/ingestor`)
- Frontend tests pass (`test-packets.js`, `test-hash-color.js`)
- Built server, hit `/api/observers` — `last_packet_at` field present in
JSON
- Observer table header has all 9 columns including both Last Packet and
Clock Offset

## Prior PRs

- #905 — original (conflicts with master)
- #968 — first rebase (conflicts after #906 landed)
- This PR — second rebase, resolves #906 conflict

Supersedes #968. Closes #905.

---------

Co-authored-by: you <you@example.com>
2026-05-02 12:03:42 -07:00
Kpa-clawbot 4f0f7bc6dd fix(ui): fill remaining gaps in payload-type lookup tables (10/11/15) (#967)
## Summary

Fill the remaining gaps in payload-type lookup tables noted out-of-scope
on #965. Every firmware-defined payload type (0–11, 15) now has entries
in all four frontend tables.

## Changes

Three types were missing from one or more tables:

| Type | Name | `PAYLOAD_COLORS` (app.js) | `TYPE_NAMES` (packets.js) |
`TYPE_COLORS` (roles.js) | `TYPE_BADGE_MAP` (roles.js) |

|------|------|--------------------------|--------------------------|-------------------------|---------------------------|
| 10 | Multipart | added | added | added `#0d9488` | added |
| 11 | Control | added |  (already) | added `#b45309` | added |
| 15 | Raw Custom | added | added | added `#c026d3` | added |

## Color choices

- **MULTIPART** `#0d9488` (teal) — multi-fragment stitching, distinct
from PATH's `#14b8a6`
- **CONTROL** `#b45309` (amber) — warm brown, distinct hue from ACK's
grey `#6b7280`
- **RAW_CUSTOM** `#c026d3` (fuchsia) — magenta, distinct from TRACE's
pink `#ec4899`

All pass WCAG 3:1 contrast against both white and dark (#1e1e1e)
backgrounds.

## Tests

- `test-packets.js`: 82/82 
- `test-hash-color.js`: 32/32 

Badge CSS auto-generation: `syncBadgeColors()` in `roles.js` iterates
`TYPE_BADGE_MAP` keyed against `TYPE_COLORS`, so the three new entries
automatically get `.type-badge.multipart`, `.type-badge.control`, and
`.type-badge.raw-custom` CSS rules injected at page load.

Firmware source: `firmware/src/Packet.h:19-32` — types 0x00–0x0B and
0x0F. Types 0x0C–0x0E are not defined.

Follows up on #965.

---------

Co-authored-by: you <you@example.com>
2026-05-02 11:17:34 -07:00
Kpa-clawbot b47587f031 feat(#690): expose observer skew + per-hash evidence in clock UI (#906)
## Summary

UI completion of #690 — surfaces observer clock skew and per-hash
evidence that the backend already computes but wasn't exposed in the
frontend.

**Not related to #845/PR #894** (bimodal detection) — this is the UI
surface for the original #690 scope.

## Changes

### Backend: per-hash evidence in node clock-skew API (commit 1)
- Extended `GET /api/nodes/{pubkey}/clock-skew` to return
`recentHashEvidence` (most recent 10 hashes with per-observer
raw/corrected skew and observer offset) and `calibrationSummary`
(total/calibrated/uncalibrated counts).
- Evidence is cached during `ClockSkewEngine.Recompute()` — route
handler is cheap.
- Fleet endpoint omits evidence to keep payload small.

### Frontend: observer list page — clock offset column (commit 2)
- Added "Clock Offset" column to observers table.
- Fetches `/api/observers/clock-skew` once on page load, joins by
ObserverID.
- Color-coded severity badge + sample count tooltip.
- Singleton observers show "—" not "0".

### Frontend: observer-detail clock card (commit 3)
- Added clock offset card mirroring node clock card style.
- Shows: offset value, sample count, severity badge.
- Inline explainer describing how offset is computed from multi-observer
packets.

### Frontend: node clock card evidence panel (commit 4)
- Collapsible "Evidence" section in existing node clock skew card.
- Per-hash breakdown: observer count, median corrected skew,
per-observer raw/corrected/offset.
- Calibration summary line and plain-English severity reason at top.

## Test Results

```
go test ./... (cmd/server) — PASS (19.3s)
go test ./... (cmd/ingestor) — PASS (31.6s)
Frontend helpers: 610 passed, 0 failed
```

New test: `TestNodeClockSkew_EvidencePayload` — 3-observer scenario
verifying per-hash array shape, corrected = raw + offset math, and
median.

No frontend JS smoke test added — no existing test harness for
clock/observer rendering. Noted for future.

## Screenshots

Screenshots TBD

## Perf justification

Evidence is computed inside the existing `Recompute()` cycle (already
O(n) on samples). The `hashEvidence` map adds ~32 bytes per sample of
memory. Evidence is stripped from fleet responses. Per-node endpoint
returns at most 10 evidence entries — bounded payload.

---------

Co-authored-by: you <you@example.com>
2026-05-02 10:30:54 -07:00
Kpa-clawbot c67f3347ce fix(ui): add GRP_DATA (type 6) to filter dropdown + color tables (#965)
## Bug

Packet type 6 (`PAYLOAD_TYPE_GRP_DATA` per `firmware/src/Packet.h:25`)
was missing from three frontend lookup tables:
- `public/app.js:7` — `PAYLOAD_COLORS` had no entry for 6 → badge color
fell back to `unknown` (grey)
- `public/packets.js:29` — `TYPE_NAMES` (used by the Packets page
type-filter dropdown) had no entry for 6 → "Group Data" missing from the
menu
- `public/roles.js:17,24` — `TYPE_COLORS` and `TYPE_BADGE_MAP` had no
`GRP_DATA` entry → no dedicated CSS class

The packet detail page already handled it (via `PAYLOAD_TYPES` in
`app.js:6` which had `6: 'Group Data'`) so individual GRP_DATA packets
render correctly. The gap was only in the filter UI + badge styling.

## Fix

Add the missing entry in each table. 4 lines across 3 files.

- `app.js`: add `6: 'grp-data'` to `PAYLOAD_COLORS`
- `packets.js`: add `6:'Group Data'` to `TYPE_NAMES`
- `roles.js`: add `GRP_DATA: '#8b5cf6'` to `TYPE_COLORS` and `GRP_DATA:
'grp-data'` to `TYPE_BADGE_MAP`

Color choice `#8b5cf6` (violet) — distinct from GRP_TXT's blue but
visually adjacent so operators read them as related types.

## Verification (rule 18 + 19)

Built server locally, served the JS files, grepped the rendered output:

```
$ curl -s http://localhost:13900/packets.js | grep TYPE_NAMES
const TYPE_NAMES = { ... 5:'Channel Msg', 6:'Group Data', 7:'Anon Req' ... };

$ curl -s http://localhost:13900/app.js | grep PAYLOAD_TYPES
const PAYLOAD_TYPES = { ... 5: 'Channel Msg', 6: 'Group Data', 7: 'Anon Req' ... };

$ curl -s http://localhost:13900/roles.js | grep GRP_DATA
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', GRP_DATA: '#8b5cf6', ...
ADVERT: 'advert', GRP_TXT: 'grp-txt', GRP_DATA: 'grp-data', ...
```

Frontend tests pass: `test-packets.js` 82/82, `test-hash-color.js`
32/32.

## Out of scope

Consolidating the duplicated PAYLOAD_TYPES / TYPE_NAMES tables into a
single source of truth is a separate cleanup. Two parallel name maps
continues to be a footgun (this is the second time a new type's been
added to one but not the other).

Co-authored-by: Kpa-clawbot <bot@example.invalid>
2026-05-02 09:55:09 -07:00
Kpa-clawbot 04c8558768 fix(spa): data-loaded setAttribute in finally so it fires on errors (#959)
## Bug

PR #958 added `data-loaded="true"` attributes for E2E sync, but placed
the `setAttribute` call inside the `try` block of `loadNodes()` /
`loadPackets()` / `loadNodes()` (map). When the API call failed (e.g.
`/api/observers` returns 500, or any other exception), the `catch`
swallowed the error and `setAttribute` was never reached. E2E tests then
waited 15s for `[data-loaded="true"]` and timed out.

This blocked PR #954 CI repeatedly with `Map page loads with markers:
page.waitForSelector: Timeout 15000ms exceeded`.

## Fix

Move `setAttribute('data-loaded', 'true')` to a `finally` block in all
three handlers (`map.js`, `nodes.js`, `packets.js`). The attribute now
fires on both success and error paths, so E2E tests proceed (test still
asserts on the actual rendered state — markers, rows, etc — so an empty
page still fails the right assertion, just much faster).

Removed the duplicate setAttribute calls inside the try blocks (the
finally is the single source of truth now).

## Verification

- `node test-packets.js` 82/82 
- `node test-hash-color.js` 32/32 
- Code reading: each `finally` runs after either success or catch, sets
the same attribute on the same container element.

## Why CI didn't catch this on #958

The PR #958 tests passed because the staging fixture happened to load
successfully when those tests ran. The flake only manifests when an
upstream fetch fails (e.g. observer API returning unexpected shape,
network blip, server still warming).

Co-authored-by: Kpa-clawbot <bot@example.invalid>
2026-05-01 10:49:21 -07:00
Kpa-clawbot 053aef1994 fix(spa): decouple navigate() from theme fetch + add data-loaded sync attributes (#955) (#958)
## Summary

Fixes the chained async init race identified in RCA #3 of #955.

`navigate()` (which dispatches page handlers and fetches data) was gated
behind `/api/config/theme` resolving via `.finally()`. Tests use
`waitUntil: 'domcontentloaded'` which returns BEFORE theme fetch
resolves, creating a race condition where 3+ serial network requests
must complete before any DOM rows appear.

## Changes

### Decouple navigate() from theme fetch (public/app.js)
- Move `navigate()` call out of the theme fetch `.finally()` block
- Call it immediately on DOMContentLoaded — theme is purely cosmetic and
applies in parallel

### Add data-loaded sync attributes (public/nodes.js, map.js,
packets.js)
- Set `data-loaded="true"` on the container element after each page's
data fetch resolves and DOM renders
- Nodes: set on `#nodesLeft` after `loadNodes()` renders rows
- Map: set on `#leaflet-map` after `renderMarkers()` completes
- Packets: set on `#pktLeft` after `loadPackets()` renders rows

### Update E2E tests (test-e2e-playwright.js)
- Add `await page.waitForSelector('[data-loaded="true"]', { timeout:
15000 })` before row/marker assertions
- Increase map marker timeout from 3s to 8s as additional safety margin
- Tests now synchronize on data readiness rather than racing DOM
appearance

## Verification

- Spun up local server on port 13586 with e2e-fixture.db
- Confirmed navigate() is called immediately (not gated on theme)
- Confirmed data-loaded attributes are present in served JS
- API returns data correctly (2 nodes from fixture)

Closes #955 (RCA #3)

Co-authored-by: you <you@example.com>
2026-05-01 08:07:08 -07:00
Kpa-clawbot 7bb5ff9a7f fix(e2e): tag flying-packet polyline so test selector doesn't pick up geofilter polygons (#953)
## Bug

Master CI failing on `Map trace polyline uses hash-derived color when
toggle ON`. The test selector `path.leaflet-interactive` was too broad —
it matched **geofilter region polygons** (`L.polygon` calls in
`live.js:1052`/`map.js:327`), which are styled with theme variables, not
`hsl()`. None of those polygons have an `hsl(` stroke, so the assertion
failed even though the actual flying-packet polylines DO use hash colors
correctly.

## Fix

1. Tag flying-packet polylines with a dedicated class
`live-packet-trace` (`public/live.js:2728`).
2. Update the test selector to target that class specifically.
3. Treat "no flying-packet polylines drawn in the test window" as SKIP
(not fail) — animation may not trigger in 3s.

## Verification (rule 18)

- Read implementation at `live.js:2724-2729`: polyline color IS set from
`hashFill` when toggle is ON. The implementation is correct.
- Read polygon callers at `live.js:1052` (geofilter regions) — confirmed
they share the same `path.leaflet-interactive` class.
- The test was selecting wrong DOM nodes; fix narrows to dedicated
class.

No code logic changed — only DOM tagging + test selector.

Co-authored-by: Kpa-clawbot <bot@example.invalid>
2026-05-01 09:00:49 +00:00
Kpa-clawbot b9758111b0 feat(hash-color): bright vivid fill + dark outline + live feed/polyline surfaces (#951)
## Hash-Color: Bright Vivid Fill + Dark Outline + Extended Surfaces

Follow-up to #948 (merged). Revises the hash-color algorithm for better
perceptual discrimination and extends hash coloring to additional Live
page surfaces.

### Algorithm Changes (`public/hash-color.js`)
- **Hue**: bytes 0-1 (16-bit → 0-360°) — unchanged
- **Saturation**: byte 2 (55-95%) — NEW, was fixed 70%
- **Lightness**: byte 3 (light 50-65%, dark 55-72%) — NEW, was fixed
L=30/38/65
- **Outline** (`hashToOutline`): same-hue dark color (L=25% light, L=15%
dark) — NEW
- Sentinel threshold raised to 8 hex chars (need 4 bytes of entropy)
- Drops WCAG fill-darkening approach — outline carries contrast instead

### Live Page Updates (`public/live.js`)
- **Dot marker**: uses `hashToOutline()` for stroke (was TYPE_COLOR)
- **Polyline trace**: uses hash fill color (unified dot + trace by hash)
- **Feed items**: 4px `border-left` stripe matching packets table

### Test Updates
- `test-hash-color.js`: 32 tests (S variability, L variability, outline
< fill, same hue, pairwise distance)
- `test-e2e-playwright.js`: 2 new assertions (feed stripe, polyline hsl
stroke)

### Verification
- 20 real advert hashes from fixture DB: all produce unique hues (20/20)
- Pairwise HSL distance: avg=0.51, min=0.04
- Go server built and run against fixture DB — HTML serves updated
module
- VM sandbox render-check confirms distinct vivid fills with darker
outlines

Closes #946 §2.10/§2.11 scope extension.

---------

Co-authored-by: you <you@example.com>
Co-authored-by: Kpa-clawbot <bot@example.invalid>
2026-05-01 08:53:04 +00:00
Kpa-clawbot 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>
2026-05-01 01:10:11 -07:00