Commit Graph
134 Commits
Author SHA1 Message Date
Kpa-clawbotandcorescope-bot c7f0c6931f fix(1843): restore QR quiet zone on node-details codes (#1844)
## Summary

QR codes on node-details pages didn't scan. Two `public/nodes.js` render
sites called `qr.createSvgTag(3, 0)`; the vendor lib treats the second
arg as an **absolute pixel margin**, so `0` removed the QR quiet zone.
QR spec requires ≥4 modules of light border, so every scanner rejected
the code.

With `cellSize=3`, four modules = 12 pixels. Changed both call sites to
`createSvgTag(3, 12)`:
- `nodes.js:813` — node-details list detail
- `nodes.js:1705` — node-map overlay (also swaps background to
transparent; with margin restored, dark modules stay 12px from the SVG
edge so the transparent overlay still parses over the map tile)

Out of scope, filed as follow-up in triage: contrast tuning of the
transparent-overlay branch.

## TDD

Red commit: `3be8552b` — [test-only, CI
red](https://github.com/Kpa-clawbot/CoreScope/commit/3be8552baff64795504e8dcb888ed25cbd6a50be)
Green commit: `6f7e83a6` — two-line fix + test passes

## Test

`test-issue-1843-node-qr-quiet-zone.js` — Node/vm harness that loads
`public/vendor/qrcode.js`, grep-asserts both `nodes.js` call sites use
margin ≥ 12px, renders the SVG, and checks the `<path>` `M` coordinates
keep all dark modules ≥ 12px from every viewBox edge (and viewBox =
`modules*cellSize + 2*margin`).

Verified red on parent commit (test fails with margin=0 leaving dark
modules at 0,0), green after fix (dark modules at 12,12 with 12px free
border).

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— all gates clean.

Browser verified: pending — will validate on staging after CI. (Frontend
UX bug; DOM/grep test above exercises the same SVG code path that
scanners consume.)

E2E assertion added: `test-issue-1843-node-qr-quiet-zone.js:63` (viewBox
+ `<path>` coord grep on real-rendered SVG).

Fixes #1843.

---------

Co-authored-by: corescope-bot <bot@corescope.dev>
2026-07-10 00:44:48 -07:00
Kpa-clawbotandopenclaw-bot 9d47a4adea fix(1836): normalize pubkey case on observer↔node cross-nav links (#1837)
Fixes #1836.

Observer↔node cross-nav links from #1826 land on 404 because pubkeys are
stored lowercase in `nodes` and uppercase in `observers`, and the
backend `WHERE` lookups are case-sensitive. The two link builders now
normalize case at the boundary.

## Fix
- `public/observer-detail.js`: observer → node href passes
`currentId.toLowerCase()`.
- `public/nodes.js`: node → observer href passes
`n.public_key.toUpperCase()`.

## TDD
- Red: `test-issue-1836-crossnav-case-normalization.js` asserts the two
hrefs contain `.toLowerCase()` / `.toUpperCase()`. Fails on master.
- Green: 2-line production change makes the test pass.

Scope: 2 production line edits + 1 new test file.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-07-08 23:08:42 -07:00
Kpa-clawbotandmeshcore-bot ba68069c23 fix(#1825): add cross-nav links between observer and node detail pages (#1826)
Adds cross-navigation between the observer detail page and the node
detail page for the same pubkey (community feature request from
cwichura).

**Changes**
- `public/observer-detail.js`: new `<a
href="#/nodes/${encodeURIComponent(currentId)}">View node detail →</a>`
inside `.page-header`, next to the `<h2 id="obsTitle">`.
- `public/nodes.js`: new sibling `<a
href="#/observers/${encodeURIComponent(n.public_key)}"
class="btn-primary">Observer →</a>` in the same button row as the
`Analytics` / `Reach` anchors on the full node detail page. Uses the
existing `ph-eye` phosphor icon.

**Test — TDD red→green**
- Red commit: `8c2315e1` (`test(#1825): red — observer<->node cross-link
anchors missing`) — 4/4 assertions fail on master; CI RED.
- Green commit: `ff8f6ed7` — minimum production change; 4/4 assertions
pass locally.

Test file: `test-issue-1825-observer-node-cross-links.js` —
static-source DOM-grep style consistent with the neighbouring
`test-issue-1789-observer-firmware-cols.js` /
`test-observers-headings.js` pattern. It asserts:
1. observer-detail.js contains
`href="#/nodes/${encodeURIComponent(currentId)}"`.
2. That anchor sits inside the `.page-header` block.
3. nodes.js contains
`href="#/observers/${encodeURIComponent(n.public_key)}"`.
4. That anchor is a sibling of the analytics/reach anchors in the same
flex row.

**Notes**
- Pubkeys are `encodeURIComponent`-escaped on both sides
(defense-in-depth; MeshCore pubkeys are hex only).
- No API changes. No CSS changes. No new dependencies.

Fixes #1825

---------

Co-authored-by: meshcore-bot <meshcore-bot@users.noreply.github.com>
2026-07-06 20:13:04 -07:00
Michael J. Arcan fc26fb6b3a feat(#1751): show transported region scopes in repeater sidebar (#1752)
Closes #1751.
2026-06-27 14:59:50 -07:00
Kpa-clawbotandopenclaw-bot db5520f70f fix(nodes): copy URL buttons produce malformed origin#frag URLs (#1753) (#1755)
## Problem

The **Copy URL** and **Copy short URL** buttons on the node detail page
produced URLs like:

```
https://analyzer.00id.net#/nodes/abcdef…
```

The `/` between the authority and the fragment is missing. RFC 3986
allows that form, but several mobile browsers (and some link-detection
heuristics) reject or mis-parse it.

## Fix

Three sites in `public/nodes.js` concatenated `location.origin` with a
literal that started with `'#/'`. Prepend `/`:

- `public/nodes.js:772` — full Copy URL (full pubkey)
- `public/nodes.js:783` — Copy short URL (8-char prefix)
- `public/nodes.js:1580` — side-pane Copy URL

All three now build `https://analyzer.00id.net/#/nodes/…`, which every
browser accepts.

## Tests

`test-issue-1753-copy-url-slash.js` — extracts every `location.origin +
'<literal>'` site from `public/nodes.js` and asserts the literal starts
with `/`. Wired into `.github/workflows/deploy.yml`.

- **Red commit** `b4df2786` — test added; CI fails on assertion (3 of 4
cases) because the literals still start with `'#/'`.
- **Green commit** `2c59f7c0` — three literals fixed to `'/#/nodes/'`;
test passes (4/4).

## Verification

```
$ node test-issue-1753-copy-url-slash.js
issue-1753 copy-URL slash regression
  ✅ found at least 3 location.origin + literal sites in public/nodes.js
  ✅ public/nodes.js:772 literal starts with "/#/" (got "/#/nodes/")
  ✅ public/nodes.js:783 literal starts with "/#/" (got "/#/nodes/")
  ✅ public/nodes.js:1580 literal starts with "/#/" (got "/#/nodes/")
  4 passed, 0 failed
```

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ clean (all gates + warnings green).

Fixes #1753

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
2026-06-20 11:44:00 -07:00
a344ae0a12 fix(#1719): contrast root causes — active-btn / skew-badge / role-swatch / status-green (#1720)
## Summary

Fixes the four recurring color-contrast root causes #1719 identifies
behind ~320 axe violations on PR #1707's expanded gate. All fixes are
token-based; no hardcoded hex introduced.

## TDD

- **Red:** `151db732` — `test-a11y-1719-contrast-root-causes-e2e.js`
asserts WCAG AA on all 4 patterns; failed with 12 sub-threshold probes.
- **Green:** `dd26554e` — fixes below; test now reports 11/11 PASS.

## Patterns + measured contrast (before → after)

| # | Surface | Before | After | Note |
|---|---|---|---|---|
| P1 | `.rf-range-btn.active` / `.clock-filter-btn.active` /
`.subpath-jump-nav a` / `#ptCheckBtn` / `#ptGenBtn` | `#fff` on
`--accent` (#4a9eff) = **2.75:1** | `--text-on-accent` on
`--accent-strong` = **4.95:1** | Consolidated into ONE grouped
`.btn-active-accent, ...` rule; inline buttons now use the shared class
|
| P2 | `.skew-badge--no_clock` (dark theme) | `#fff` on `--text-muted`
(#d1d5db) = **1.47:1** | `#fff` on `--skew-badge-no-clock-bg` (#4b5563)
= **7.56:1** | New dedicated token, both themes |
| P3 | Neighbor-graph role swatches, light theme on white | room 3.30:1
/ sensor 3.19:1 / observer 4.23:1 | room **5.02** / sensor **5.02** /
observer **5.70** | `customize.js` defaults bumped to
palette-{green/amber/purple}-700 |
| P4 | `.analytics-stat-card` text in `--status-green` on white |
**2.28:1** | new `--status-green-text` = #15803d → **5.02:1** |
`--status-green` background token unchanged (still #22c55e); inline text
usages routed to the new token |

## Why this unblocks #1707

#1707's 320 axe color-contrast hits decompose into:
- 137× single-rule `.skew-badge--no_clock` → P2.
- ~N×4 active-button surfaces (rf-health / clock-health / subpaths /
prefix-tool) → P1.
- Role-swatch text on `/#/analytics?tab=neighbor-graph` (light) → P3.
- `.analytics-stat-card` text on `/#/analytics?tab=nodes` (light) → P4.

After this merges, the next CI run on #1707 should see the expanded gate
go green (or down to a small ≤5 residual the operator can triage
separately per the issue's acceptance criteria).

## Local axe gate

`BASE_URL=… node test-a11y-axe-1668.js` was **NOT** run locally — the
sandbox's bundled chromium fails to boot Playwright (known issue). CI on
this PR runs the same gate against the staging fixture; relying on that.

The dedicated test `test-a11y-1719-contrast-root-causes-e2e.js` is
CSS+JS-parse-driven (no browser) and runs in <100ms — it's the
regression net for these 4 patterns specifically.

```
$ node test-a11y-1719-contrast-root-causes-e2e.js
  PASS [P1] theme=light .rf-range-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=light .clock-filter-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=light .subpath-jump-nav a  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=dark .rf-range-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=dark .clock-filter-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=dark .subpath-jump-nav a  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P2] theme=light .skew-badge--no_clock  fg=#fff bg=#4b5563  ratio=7.56:1
  PASS [P2] theme=dark .skew-badge--no_clock  fg=#fff bg=#4b5563  ratio=7.56:1
  PASS [P3] theme=light nodeColors.room on white  fg=#15803d bg=#ffffff  ratio=5.02:1
  PASS [P3] theme=light nodeColors.sensor on white  fg=#b45309 bg=#ffffff  ratio=5.02:1
  PASS [P3] theme=light nodeColors.observer on white  fg=#7c3aed bg=#ffffff  ratio=5.70:1
  PASS [P4] theme=light .analytics-stat-card text color (--status-green-text)  fg=#15803d bg=#ffffff  ratio=5.02:1
  PASS [P4] theme=dark .analytics-stat-card text color (--status-green-text)  fg=#22c55e bg=#232340  ratio=6.65:1

PASS: all 4 root-cause patterns ≥ 4.5:1 in both themes (issue #1719)
```

## Out-of-scope (intentional)

- Other text-on-light `var(--status-green)` usages in `nodes.js`
(Critical/Valuable labels): different surface, not the
analytics-stat-card pattern #1719 calls out. Tracked under analytics
audit umbrella.
- Hardcoded `var(--status-green, #2ecc71)` fallback in `nodes.js` lines
648/666: same scope deferral.
- Allowlist entries: none added per the issue's acceptance criteria.

Fixes #1719.

---------

Co-authored-by: clawbot <clawbot@kpa.local>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
2026-06-13 14:47:57 -07:00
d954ea7444 feat(#1668): axe-core CI gate for WCAG AA color-contrast (M5) (#1696)
Partial fix for #1668 (M5 of 6).

After M1 (audit), M2 (color tokens, #1676), M3 (typography floor,
#1679), and M4 (per-route polish, #1681) cleared ~95% of
contrast/typography violations, M5 **locks in the wins** by adding an
axe-core CI gate that fails the build on any new WCAG AA color-contrast
regression.

## What's in the box

- `test-a11y-axe-1668.js` — Playwright + `@axe-core/playwright`. Runs
every major CoreScope route × `{dark, light}` at 1200×900 desktop,
injects axe, runs only the `color-contrast` rule, asserts net violations
=== 0.
- `test-a11y-axe-1668-selftest.js` — fast, deterministic, browser-free
unit test that exercises the YAML allowlist parser, the
`violationAllowed` matcher, and the route/theme metadata. Runs in the JS
unit block (no browser needed).
- `tests/a11y-allowlist.yaml` — operator-flagged false-positive
allowlist. **0 entries at M5 baseline.**

## Allowlist format

Each entry MUST cite a GH issue # and an `expires_at` date. Missing
fields = refused. Expired `expires_at` = refused (warning logged). This
**forces a periodic revisit** — no permanent suppressions.

```yaml
- route: /analytics?tab=channels
  selector: ".some-known-stale-element"
  rule: color-contrast
  issue: 1234
  expires_at: 2026-09-01
```

## Routes covered (19 × 2 themes = 38 cells)

`/`, `/packets`, `/nodes`, `/channels`, `/live`, `/map`, `/observers`,
`/compare`,
`/analytics?tab={overview,rf,topology,channels,hashsizes,collisions,roles,airtime}`,
`/audio-lab`, `/customize`, `/replay`.

## TDD red→green

- **RED** (`08adafdb`) — adds the gate + deliberately regresses
`--text-muted` from `palette-gray-700` (~10:1) to `#9ca3af` (~2.4:1).
axe-core fails on every light-theme cell.
- **GREEN** (`f62fb1e0`) — restores the M2 token. Net violations = 0
across all 38 cells.

## Scope discipline

- Only `color-contrast` (matches M2/M3/M4 scope). M6 owns `image-alt`,
`aria-required-attr`, `label`, mobile viewports, and letsmesh A/B.
- No new design tokens.
- M2-M4 tokens untouched.

## CI wiring

- `.github/workflows/deploy.yml:155` — selftest in JS unit block.
- `.github/workflows/deploy.yml:367` — real axe browser run in the
Playwright E2E block after the fixture server is up.

## Deps

`@axe-core/playwright@4.11.3` + `axe-core@4.12.1` added to
`devDependencies`. Pinned versions.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-06-12 20:00:35 -07:00
d910ea0208 feat(#1638): confidence rating weighted by hash mode (#1687)
Fixes #1638.

## Problem
`getConfidenceIndicator` in `public/nodes.js` treats every observation
as equal evidence, so a node seen 5 times via 1-byte hash prefixes
(which collide ~8-way across a typical mesh) scores the same as a node
seen 5 times via 6-byte prefixes (effectively unambiguous). The user
asked for confidence to respect ambiguity.

## Change
- `cmd/server/neighbor_graph.go` — new `CountsByMode map[int]int` on
`NeighborEdge`, bumped in `upsertEdge` / `upsertEdgeWithCandidates`
based on the observation's hash-prefix byte length (1/2/4/6). Merged in
`resolveEdge` when ambiguous→resolved edges collapse.
- `cmd/server/neighbor_api.go` — `NeighborEntry.counts_by_mode` exposed
(omitempty), and `dedupPrefixEntries` merges per-mode counts when an
unresolved prefix entry collapses into a resolved one. Flat `Count`
field preserved for back-compat.
- `public/nodes.js::getConfidenceIndicator` — weights observations by
mode: 1-byte=0.125, 2-byte=0.5, 4/6-byte=1.0. A single 6-byte sighting
counts ~8× a raw 1-byte one. HIGH triggers when EITHER the legacy
heuristic clears OR weighted count ≥3. Legacy entries without
`counts_by_mode` keep working (default weight 0.5).
- Tooltip now shows the per-mode breakdown (e.g. "Observations: 5
(1-byte: 3, 6-byte: 2)").

## TDD
- RED:
`cmd/server/neighbor_graph_test.go::TestBuildNeighborGraph_CountsByMode`
— fixture with 1/2/4-byte sightings asserts per-mode tally (commit
`838965f3`).
- RED: `test-confidence-indicator.js` — 6-byte mostly-sighted neighbor
must outrank 1-byte mostly-sighted neighbor at equal flat count (commit
`4bd5e18e`).
- GREEN: implementation in commit `7511606d`. All 4 JS tests pass; new
Go test passes; full Go suite passes (two pre-existing flakes unrelated,
both pass when isolated).

## Browser verification
Synthetic side-by-side of OLD vs NEW classifier against representative
inputs — see screenshot. 1-byte-only and 6-byte-only at the same flat
count diverge from MEDIUM/MEDIUM to MEDIUM/HIGH, and 3 6-byte sightings
now upgrade where 20 1-byte sightings stay MEDIUM.

## Preflight overrides
- check-branch-scope: cross-stack: justified — backend exposes the new
`counts_by_mode` field and the frontend consumes it; the whole point of
the change.

## Compat
- `Count` field unchanged in shape and value.
- `counts_by_mode` is `omitempty`; legacy persisted edges (loaded from
`neighbor_edges` via `neighbor_persist.go`) get no per-mode breakdown
and fall back to the default weight (0.5) — no UI regression.

---------

Co-authored-by: bot <bot@local>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-06-12 11:38:43 -07:00
Kpa-clawbotandBot 89eade6e7b M6: emoji → Phosphor — final sweep, lint gate, carry-forwards (#1648) (#1654)
Red commit: fe7468d473 (CI run: will
appear in this PR's Checks tab — emoji lint test fails on the red
commit, passes on green)

**Fixes #1648.**

Closes the 6-milestone emoji→Phosphor migration started in #1649.

## Sweep results (real UI icons swapped this PR)

| File:line | Before | After |
|---|---|---|
| `public/index.html:140` | ⭐ | `ph-star-fill` |
| `public/mobile-page-actions.js:154` | ⭐ | `ph-star-fill` |
| `public/geofilter-builder.html:76` | ⬇ | `ph-download-simple` |
| `public/analytics.js:2103` | ⏱️ | `ph-clock` |
| `public/analytics.js:2288` | ⏰ | `ph-clock` |
| `public/analytics.js:4080` | ⏰ | `ph-clock` |
| `public/nodes.js:1066` | ⏰ | `ph-clock` |
| `public/observer-detail.js:284` | ⏰ | `ph-clock` |
| `public/channel-qr.js:133` | 📋 | `ph-clipboard-text` |
| `public/channel-qr.js:139` | ✓ | `ph-check` |
| `public/packets.js:1472` | ⏸ | `ph-pause` |

## Carry-forwards addressed

- **M5 CDP — live `.cust-emoji-preview` re-render** —
`public/customize-v2.js:2434` wires `renderConfigGlyph` to the `input`
event so previews update without Save+reload. (commit `9e698a04`)
- **M5 CDP — `.modal-close` 44×44 mobile** — `public/style.css` adds a
≤640px breakpoint bumping both `.modal-close` and `.ch-modal-close` to
WCAG-minimum hit targets. (commit `9e698a04`)
- **M4 CDP — route-hop fallback color** — `public/route-render.js` now
reads `var(--status-info)` (new token added to `:root` and dark-mode
blocks in `style.css`) instead of baked `#3b82f6`. (commit `9e698a04`)
- M2 carry-forward set (⭐ favStar / ▾ More / ⚠️ clock / 🌱⚡ welcome
cards) verified already addressed by re-running M3 emoji scan — all
green.

## Lint gate (M6 headline)

- Test: `test-issue-1648-m6-final-sweep.js` — full repo scan across
`public/**.{js,html,css}` and `cmd/(server|ingestor|decrypt)/*.go`.
- Self-test: `test-issue-1648-m6-lint-self.js` — exercises the lint
engine + anti-tautology probe.
- Allowlist: `tests/emoji-allowlist.txt`. Format: `path` (glob),
`path:line`, `path:line:U+XXXX`, or `/regex/`. Add intentional emojis
here with a `# why` comment.
- Wired into `test-all.sh` alongside M1/M2/M3 scans.

## routes.go smoke check

Server-side defaults in `cmd/server/routes.go:567-574` confirmed
`ph:bluetooth`/`ph:radio`/`ph:broadcast`/`ph:repeat` (M5 landed).
Operator-customized configs on staging/prod still carry their legacy
emoji overrides — per M5 design call those are preserved and NOT touched
by this PR.

## PR closing list

Fixes #1648. M1 #1649 ✅, M2 #1650 ✅, M3 #1651 ✅, M4 #1652 ✅, M5 #1653 ✅,
this PR ✅.

---------

Co-authored-by: Bot <bot@corescope>
2026-06-11 05:44:37 -07:00
Kpa-clawbotandopenclaw-bot 3062745437 M2: emoji → Phosphor Icons — page headers & table chrome (#1648) (#1650)
Red commit: df6a406a89 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2F1648-m2-headers-tables)

Partial fix for #1648 (M2 of 6). Do NOT close the tracking issue.

M2 covers page headers + table chrome: section glyphs, refresh/action
buttons, status pills, payload-type icon maps. Heavy on analytics.js.

## Per-file swap counts

| file | swaps |
| --- | --- |
| public/analytics.js | 89 |
| public/nodes.js | 29 |
| public/packets.js | 30 |
| public/live.js | 30 |
| public/map.js | 11 |
| public/perf.js | 9 |
| public/audio-lab.js | 5 |
| public/node-analytics.js | 4 |
| public/table-sort.js | 1 |
| public/traces.js | 1 |
| **total** | **209** |

Plus 48 new Phosphor SVG symbols vendored into
`public/icons/phosphor-sprite.svg`
(regular weight, alphabetical): arrows-out, battery-high, battery-low,
bomb,
book-open, buildings, caret-down, caret-up, cell-signal-high,
chart-line, chats,
check-circle, clipboard-text, clock, crosshair, dice-five, envelope,
flame,
gear, globe, graph, handshake, house-line, info, key, link,
list-numbers,
lock-open, map-pin, microphone, path, piano-keys, prohibit, pulse,
push-pin,
question, radio, repeat, ruler, share-network, shuffle, signpost,
speaker-high,
target, thermometer, trend-up, trophy, x-circle. Total sprite now 82
symbols, ~35 KB.

## Tests

- Static scan: `test-issue-1648-m2-emoji-scan.js` asserts ZERO emoji
  codepoints (U+1F300–1FAFF, U+2600–27BF) and zero misc-icon chars
  (◆●■▲★☆○✓✗⚠✉) in each M2 file, plus a minimum `<use href="…#ph-…">`
  ref count per file.
- E2E: `test-issue-1648-m2-icons-e2e.js` — 15 Chromium assertions
  (test-issue-1648-m2-icons-e2e.js:31–245) covering /analytics, /packets
  filter row, /nodes table chrome, /live audio + feed buttons, /map
  controls h3 + toggle, /traces, /perf, /audio-lab loop button, plus a
  sprite-resolution check (every rendered `<use>` resolves to a defined
  `<symbol>` — i.e. no `.notdef` glyph fallback). E2E assertion added:
  `test-issue-1648-m2-icons-e2e.js:96`.
- Both wired into `.github/workflows/deploy.yml` E2E block.

Anti-tautology proof: reverting the audio-lab.js Packet Data h3 swap
(restoring `📦`) flips the static scan from PASS to assertion failure
`actual: 1, expected: 0` (audio-lab.js emoji-hit count check). Verified
locally before push.

## Browser verification

Local Chromium against `corescope-server -port 13581` + e2e fixture DB.
Screenshots of /analytics, /nodes, /packets, /live, /map at 1200×900 and
375×812. No `.notdef` glyphs; theme toggle preserved; sprite resolves on
every page.

## Out of scope (carried forward)

- customize.js / customize-v2.js NODE_EMOJI + PACKET_TYPE_EMOJI configs
**[M5]**
- `cmd/server/routes.go` L567-574 onboarding-tile emoji **[M6]**
- home.js welcome cards 🌱 ⚡ etc. **[M3]**
- route-view overlays (route-view-utils.js, route-view.js,
hop-display.js, path-inspector.js) **[M4]**
- channels.js modals + footer 💬 📋 🔒 **[M5]**
- roles.js NODE_SHAPE_EMOJI (used by route-view, not M2) **[M4]**
- packets.js L2169 expand caret swapped (was `▶/▼`); other ▶ in
audio-lab
  alabPlay button left as-is — out of M2 range (U+25B6 ≠ emoji).

Adheres to rule 34: no `Fixes #1648`, no auto-close.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-11 00:42:33 -07:00
e2212f5015 feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (v2, review-complete) (#1627)
Re-submission of #1625 (which was merged early, then reverted in #1626)
— now with **all three round-1 reviews addressed** so it lands in one
hardened state instead of as post-merge follow-ups.

## What

Per-node **Reach** view: a standalone page (`#/nodes/{pubkey}/reach`) +
a node-detail section + `GET /api/nodes/{pubkey}/reach`. It shows which
nodes a node has a **stable two-way RF link** with, derived from raw
`path_json` adjacency (a path travels origin→observer, so `[A,B]` ⇒ B
heard A). A link is bidirectional when both directions have
observations; the **bottleneck** (weaker direction) rates two-way
reliability. Nodes are identified only by **unique 2–3 byte** path
prefixes (1-byte collides → excluded).

## Review fixes folded in vs #1625

**Performance (Carmack):** hard scan LIMIT (200k) + modest prealloc;
`json.Unmarshal` replaced by a single-pass `parsePathTokens` (100k-row
scan 2.2M→1.3M allocs, 344→203ms); memoized resolver; size-hinted maps
(attribution over 100k rows: 102 allocs); `context.Context` plumbed;
cache `RWMutex` + evict-oldest (no full wipe); singleflight dedup;
degree/rank from a 60s shared snapshot; bench rewritten (ReportAllocs,
1k/10k/100k, mixed-payload, isolated attribution).

**Correctness/safety + tests (Independent + Kent Beck):** pubkey
validation → 400; error logging instead of silent swallow (first_seen /
degree / marshal→500 / discarded rows); `public_key=?` index use;
canonical `PayloadADVERT`; `min()` builtin; documented cache-slice
immutability; mux ordering comment. New tests: scanReachRows decode,
3-byte token branch, non-advert first-hop guard, observer SNR
aggregation across rows, HTTP-level attribution (asserts non-zero
we_hear/they_hear), 400/404/blacklist/cache-hit.

**UI / a11y / Tufte:** in-map legend (tiers + thresholds); dropped the
colour+width double-encoding (constant width, colour-only); colour-blind
glyphs (●●●/●●/●) + tier title beside the bottleneck number; dark-theme
`--link-*`; lighter table (horizontal rules, sentence-case headers); map
built once + link layer updated in place on toggle (no flicker);
time-range no longer flashes a loader; `destroy()` generation guard;
statCard escaping; scoped `@media print` to `#nq-report`;
`fieldset/legend` + `for/id` toggles; `aria-pressed` / `aria-live` /
back-link `aria-label`; "distance (km)" + bottleneck tooltip + no-GPS
note; inline styles → CSS; decorative emoji removed.

**Docs:** api-spec documents the 5-min cache, 200k scan cap, and 400.

## Testing
- `cmd/server` full suite green; reach unit + endpoint + bench all pass.
- `eslint public/*.js` (no-undef) and the XSS-sink gate clean.
- E2E updated: request status checks + exact (non-tautological) toggle
assertions + hard map-render assert.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


---

## TDD-history note (Kent Beck gate)

This branch carries production + tests together, not a fabricated
red→green sequence. That's deliberate: the branch was rebased onto
upstream and the intermediate SHAs were squashed, so reconstructing a
"failing-test-first" commit after the fact would be theatre, not
evidence — and rewriting history to stage it would be dishonest. The
behaviour is instead covered by a comprehensive, anti-tautological suite
(directional attribution edges, 3-byte token branch, non-advert
first-hop guard, observer SNR aggregation, HTTP-level attribution
asserting non-zero counts, scan-cap truncation, zero-reach 200-not-404,
companion mis-attribution, cache eviction). Requesting maintainer
acceptance of the work on test *substance* rather than commit
*choreography*; the net-new-UI exemption is not claimed for the server
endpoint.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: meshcore-bot <bot@meshcore>
2026-06-08 22:13:02 -07:00
efiten 9c5faab1e4 Revert "feat(nodes): per-node Reach page (#1625)" (#1626)
Reverts #1625.

#1625 was merged before the round-1 reviews (Independent / Kent Beck /
Tufte) were addressed. Reverting to land it cleanly: a fresh PR will
re-add the feature with the perf pass, the backend correctness/safety +
test-coverage fixes, and the UI/a11y (Tufte) batch folded in, so it goes
through review in a single hardened state rather than as a string of
post-merge follow-ups.

No functional loss — the feature returns in the replacement PR.
2026-06-08 12:35:12 +00:00
efitenandClaude Opus 4.8 47f85f6c4c feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (directional link quality) (#1625)
## What

Adds a per-node **Reach** view that answers "how well does this specific
node hear, and get heard by, its neighbours?" — both as a standalone
page (`#/nodes/{pubkey}/reach`) and as a section on the node detail
page.

New endpoint: **`GET /api/nodes/{pubkey}/reach`**.

## What it measures

For the target node it derives, from raw `path_json` adjacency (a path
travels origin→observer, so in `[A,B]` B received A directly):

- **Directional link counts** per neighbour: `we_hear` (how often we
received them) vs `they_hear` (how often they received us).
- **Bidirectional / bottleneck**: a link is two-way stable when both
directions > 0; the weaker direction is the bottleneck and rates real
two-way reliability.
- **Importance**: neighbour degree + rank, relay-observation volume,
bidirectional-link count, direct-observer count.
- **Direct observers**: who received the node at 0 hops, with SNR.

Reliability rule: a neighbour is only attributed when its pubkey
**prefix is unique** at the path's byte length (collisions are skipped,
never misattributed).

## UI

- Standalone Reach page + node-detail section.
- Reusable bidirectional link map (OSM) with links coloured by
bottleneck.
- Incoming/outgoing toggles to isolate each direction.

## Naming note (deliberate, no collision)

This is distinct from the existing **per-observer reachability** in
topology analytics (`ReachNode` / `ObserverReach` / `perObserverReach`).
This PR adds its own `NodeReach*` response structs in a new
`node_reach.go` and a new `/api/nodes/{pubkey}/reach` route — there are
no symbol or route collisions (verified: `go build ./...` clean). Happy
to rename to disambiguate further (e.g. "Link Quality") if you'd prefer
to reserve "Reach" for the per-observer feature.

## Testing

- `cmd/server`: endpoint shape/404/limit-clamp + unit tests for token
derivation and directional attribution, plus a scan benchmark — all
pass.
- Frontend: helper tests + Reach-page E2E (`test-node-reach-e2e.js`),
standalone route + incoming/outgoing toggles.
- `go build ./...` and `eslint public/*.js` (no-undef) clean.

## Docs

Design spec, implementation plan, and the `GET
/api/nodes/{pubkey}/reach` API contract are included under `docs/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:11:06 +02:00
a4776557ae feat(#1290): use firmware repeat:on|off hint to exclude listener-only observers from disambiguator (#1624)
Closes #1290.

cross-stack: justified — backend persists firmware-side `repeat` hint to
a new observers column, frontend surfaces the listener/repeater status
as a badge on the observers list and node-detail Heard By table per the
issue's UI acceptance criterion.

## What

Firmware 1.16 publishes a `repeat: on|off` flag in the MQTT `/status`
JSON (confirmed by @cwichura on the issue thread — see
[`MQTTMessageBuilder.cpp:58`](https://github.com/agessaman/MeshCore/blob/b45373a31f111fb0de98bb3b168226d09ceadc47/src/helpers/MQTTMessageBuilder.cpp#L58)
in `agessaman/MeshCore mqtt-bridge-implementation-flex`). Listener-only
observers (`repeat:off`) by firmware contract never relay packets, so
they cannot legitimately be a hop in someone else's resolved path. This
PR plumbs the hint end-to-end so the disambiguator stops considering
them.

## How

* **`internal/dbschema`**: idempotent `can_relay INTEGER DEFAULT 1`
migration on `observers`, plus `AssertReady` probe (server fatal-logs if
absent). Mirrored in `cmd/ingestor/db.go` `CREATE TABLE` for fresh DBs.
Annotated `PREFLIGHT: async=true` — `DEFAULT 1` is constant so SQLite
does this as a metadata-only schema rewrite.
* **`cmd/ingestor`**: `extractObserverMeta` accepts `repeat` as bool,
case-insensitive string (`on|off|true|false|yes|no`), or numeric `0|1`.
Missing field → `nil` → `COALESCE` preserves the existing column value
(back-compat with legacy observers). Plumbed through `UpsertObserverAt`
and the prepared upsert statement.
* **`cmd/server`**: `GetNonRelayObserverPubkeys` + new
`prefixMap.markNonRelay` drop matching candidates inside
`pm.resolveWithContext` at the top of the resolver, so all 4 tiers see
the pruned candidate set. `ObserverResp.CanRelay` is surfaced on
`/api/observers` and `/api/observers/{id}`. `GetNodeHealth` enriches
per-observer rows with `can_relay` so the node-detail badge renders.
Probe-and-fall-back when the `can_relay` column is absent (legacy test
fixtures).
* **`public/`**: listener vs repeater pill on observers list, observer
detail `Relay` stat card, and node-detail `Heard By` table. CSS uses
existing theme vars.

## Test

Added `TestResolveWithContext_ExcludesNonRelayObservers_Issue1290` in
`cmd/server/resolve_non_relay_1290_test.go` covering all three required
cases:
* `repeat:off` pubkey → not a candidate (assertion failed in red commit
`5f7fdb96`, passes after green `f12911dc`)
* `repeat:on` pubkey → still a candidate (regression guard)
* legacy obs (no field) → still a candidate (back-compat)

Red→green proof:
```
$ git log --oneline origin/master..HEAD
f12911dc feat(#1290): exclude listener-only observers from path-hop disambiguator
5f7fdb96 test(#1290): red — assert listener-only observers excluded from path-hop candidates
```

Full server + ingestor + dbschema + migrate test suites pass locally.

## Acceptance checklist (from #1290)

* [x] Ingestor parses `repeat` field (boolean OR string `on|off`)
* [x] Field persisted on `observers` table (new `can_relay BOOLEAN`
column, idempotent migration via `internal/dbschema`)
* [x] Server's disambiguator (`pm.resolveWithContext`) excludes
`can_relay=false` observer-nodes from path-hop candidate set
* [x] UI badge on observers list + node detail page indicating
"listener" vs "repeater"
* [x] Backward compat: legacy observers default to `can_relay=true`
* [x] Test: `repeat:off` → NOT a candidate
* [x] Test: `repeat:on` → IS a candidate
* [x] Test: legacy → IS a candidate

## Out of scope (preserved per issue)

Backfilling already-resolved paths is left as a follow-up. No
firmware/broker changes.

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-06-08 01:27:13 -07:00
37a7a92730 fix(#1616): detach slide-over panel on close (architectural focus-restore fix) + --repeat-each=20 CI gate (#1617)
Fixes #1616. Supersedes the soften-and-track approach from #1172 (now
closed).

## What

Architectural fix for the slide-over close path so it no longer
transitions through a `focused-but-hidden` state. Chromium-headless
cannot deterministically order focus/blur events when `panel.hidden =
true` happens in the same microtask as a delegated table re-render —
root cause of the flake family that was blocking ~8 unrelated PRs at a
time and flipping master CI ~50%.

## How (three changes per #1616 acceptance criteria)

1. **Panel detach on close.** `open()` attaches panel + backdrop to
`<body>`; `close()` removes them. `isOpen()` is now a boolean flag
(`panelOpen`) instead of `(!panel.hidden)` — the closed panel literally
does not exist in the document tree, so there is no focused-but-hidden
window.
2. **Focus restore by `data-value` lookup at restore time.** Sync
`tr.focus()` BEFORE detach. If `document.activeElement !== tr` after the
sync call, attach a one-shot `MutationObserver` on the table's `tbody`;
on a matching row re-attach, call `.focus()` once and `disconnect()`.
Observer has a 2s timeout fallback so it doesn't leak when the row is
genuinely gone.
3. **Permanent CI flake-gate.** New step in
`.github/workflows/deploy.yml`: runs `test-slideover-1056-e2e.js` 20
consecutive times. Any single non-zero exit aborts. If this step ever
turns red post-merge, the focused-but-hidden state has crept back in.

## Hard-asserted (no more soft-warn)

All three deferred assertions are now `assert(...)`:

- `focus-restore@800: Escape returns focus to originating row`
- `focus-restore@800: X-button click returns focus to originating row`
- `resize@800→1440 nodes: cleanup releases panel, backdrop, scroll-lock,
focus` (focusRestored portion)

## Commits

- `fce39304` — RED: un-skip the two soft-skipped assertions
- `cead78df` — GREEN: architectural fix (detach + MutationObserver)
- `4f6d5c47` — CI: permanent `--repeat-each=20` flake-gate

## Verification

The 20-run gate is the verification. Watch the new `Slide-over E2E
flake-gate (#1616, --repeat-each=20)` step on this PR's CI; merge only
if it passes.

## Why this is the right fix

Five prior patches (`7891b70`, `366af4f`, `36ebecc`, `df5397f`,
`d681505`) all targeted the focus call ordering and all flaked in CI
Chromium-headless. The unfixable bit is "hidden-but-was-focused" —
Chromium reorders blur/focus across that transition
non-deterministically. Removing the transition (detach instead of hide)
removes the race entirely.

Closes #1616. Closes #1172 (already closed).

---------

Co-authored-by: openclaw-bot <bot@openclaw>
Co-authored-by: CoreScope bot <bot@corescope.local>
Co-authored-by: clawbot <bot@clawbot.local>
2026-06-06 17:43:08 -07:00
Kpa-clawbotandopenclaw-bot 26105748ff fix(nodes): paginate /api/nodes — surface all nodes past 500-row server cap (#1606) (#1607)
## Summary

Fixes #1606 — frontend `public/nodes.js` issued a single `?limit=5000`
fetch to `/api/nodes` and trusted the response as the complete node set.
After PR #1540 (v3.8.3) clamped `/api/nodes` `?limit` to 500 as a DoS
guard, that single fetch silently truncated to the top 500 rows by
`last_seen DESC`. On the reporter's 2313-node deployment, **78% of nodes
(1813) were invisible** in the Nodes page, with no UI indication
anything was missing.

Replaces the single fetch in `loadNodes()` with a pagination loop driven
by `data.total` from the first response. Stops when `_allNodes.length >=
total`, when the server returns a short page, or at a 10 000-row safety
cap. `counts` is taken from the first response and refreshed on each
subsequent page (last writer wins; the server returns the same `counts`
payload each call).

Scope is deliberately narrow per the (munger) finding in the triage
comment: the three sibling call sites (`analytics.js:2080,2817`,
`packets.js:791`) are **NOT** touched here. They get their own
follow-up.

## Repro

```bash
curl -s "https://analyzer.marwoj.net/api/nodes?limit=5000" | jq '{nodes_len: (.nodes | length), total}'
# Before fix on >500-node deployment:
#   { "nodes_len": 500, "total": 2313 }   ← frontend silently displays only 500
```

## Before / after evidence

Unit test `test-issue-1606-pagination.js` drives `loadNodes()` against a
mocked `api()` exposing 1200 fixture nodes with a 500-per-page server
cap (mirrors the real `/api/nodes` clamp).

| | `_allNodes.length` | `data.total` |
|---|---:|---:|
| Before (single fetch) | **500** | 1200 |
| After (pagination loop) | **1200** | 1200 |

Red commit: `700a5cc4` (test asserts `_allNodes.length === data.total`,
fails 500 ≠ 1200).
Green commit: `6d51da45` (pagination loop, test passes).

All 611 tests in `test-frontend-helpers.js` continue to pass — the
existing nodes.js WS-handler runtime tests are unaffected.

## Browser verified

Mocked-API unit test only — staging currently has <500 nodes so the bug
isn't reproducible there. The reporter's deployment
(`analyzer.marwoj.net`, 2313 nodes) is where the visible regression
occurs. The unit test reproduces the exact failure mode against a
controllable fixture.

## E2E assertion added

`test-issue-1606-pagination.js:170` — `assert.strictEqual(all.length,
env.fixtureTotal, ...)`

## Files changed

- `public/nodes.js` — `loadNodes()` single fetch → pagination loop
- `test-issue-1606-pagination.js` — new regression test (sandboxed
nodes.js + mock api)

## Out of scope (deferred to follow-up)

Per triage's (munger) note, these three siblings have the same
single-fetch bug and need their own focused PR:

- `public/analytics.js:2080` (`limit=10000`)
- `public/analytics.js:2817` (`limit=10000`)
- `public/packets.js:791` (`limit=2000`)

Closes #1606

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-06 08:50:29 -07:00
Kpa-clawbotandopenclaw-bot 1a2b8c48be feat(node-detail): link RTC-reset warning to offending packet hashes (#1094) (#1590)
## Problem
Node detail's bimodal-clock warning showed only `⚠️ N of last M adverts
had nonsense timestamps (likely RTC reset)` — no way to tell which
packets, no way to verify the heuristic, no way to drill in.

## Fix
Additive, two-sides:

**Backend** (`cmd/server/clock_skew.go`)
- New type `BadSample { Hash, AdvertTS, SkewSec }`.
- New field `NodeClockSkew.RecentBadSamples []BadSample` (`omitempty`).
- Populated from the **same** bimodal-bad classification pass that
produces `RecentBadSampleCount` — no heuristic change. `tsSkewPair`
carries `hash` + `advertTS` so the classifier can record per-sample
evidence without a second walk; drift code is unaffected (reads only
`ts`/`skew`).

**Frontend** (`public/nodes.js`)
- `bimodalWarning` preserves the existing count summary line, then
renders a `<ul>` of bad samples: each `<li>` is `<a
href="#/packets/HASH">hash[:8]</a> → formatTimestamp(advertTS)` with ISO
tooltip. Defensive `Array.isArray` so older API responses still render
the summary alone.

## TDD
- **Red:**
`cmd/server/clock_skew_issue1094_test.go::TestIssue1094_RecentBadSamples_ExposesHashAndTimestamp`
— seeds 3 healthy + 2 bimodal-bad adverts, asserts `RecentBadSamples`
has length 2 with the expected hashes and advert timestamps. Fails on
the assertion (`len = 0, want 2`) with the stub-only commit.
- **Green:** classifier populates the slice; existing #1285 and bimodal
tests stay green.
- Red commit: `ed501f4b`
- Green commit: `54305b06`

## Cross-stack
Backend + frontend ship together (`cross-stack: justified` commit). API
stays backward compatible (`omitempty` server, `Array.isArray` client)
but the feature only lights up with both halves present.

## Preflight
Clean — PII, branch scope, red-commit, CSS vars, XSS sinks, migrations,
fixture coverage all pass.

## Acceptance
- [x] Warning lists specific packet hashes
- [x] Each hash links to `#/packets/<hash>`
- [x] Bad advert timestamp shown next to the hash
- [x] Pattern is reusable — `BadSample` is a clean shape any future
heuristic that flags specific packets can adopt

Fixes #1094

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-04 18:48:27 -07:00
Kpa-clawbotandfix-1166-bot 7533b3b67b feat(nodes): sortable First Seen column on Nodes table (#1166) (#1587)
## Summary

Adds a sortable **First Seen** column to the Nodes table so users can
spot newly observed repeaters in their region (per the reporter's use
case).

Closes #1166

## Backend

`/api/nodes` already exposes `first_seen` per node via `db.scanNodeRow`
(sourced from the existing `nodes.first_seen` column — no schema
migration, no recomputation, no extra query cost). The red test pins
that contract.

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

- New `<th data-sort-key="first_seen" data-sort-default="desc">First
Seen</th>` between Last Seen and Adverts.
- Cell renders via `renderNodeTimestampHtml(n.first_seen)` — same
relative-time + absolute-ISO `title=` tooltip as the Last Seen column.
Empty values render as `—`.
- `sortNodes` gains a `first_seen` branch with **empty-last** semantics:
nodes without a `first_seen` always sort to the bottom regardless of
asc/desc direction, so unknowns never clutter the top of the table.
- Empty-state `colspan` bumped 7 → 8.

## TDD

- **Red commit** `112442f4` — `test-issue-1166-first-seen-column.js` +
`cmd/server/first_seen_1166_test.go`. The backend half passes on red
(field already returned); 5 frontend assertions fail on assertions
(column header missing, sort branch missing, empty-last violated).
- **Green commit** `9274b36c` — only `public/nodes.js`. All 6 tests
pass.

Verified red is real-fail (assertion-shaped) by checking out the red
commit's `nodes.js` and re-running the test: 5 failures, all on
`assert.strictEqual`, none on parse/import.

## Test results

```
node test-issue-1166-first-seen-column.js  → 6 passed, 0 failed
node test-frontend-helpers.js              → 611 passed, 0 failed
go test ./cmd/server/...                   → ok (45.16s, all pass)
```

## Files changed

- `public/nodes.js` (+14 / −1)
- `test-issue-1166-first-seen-column.js` (new)
- `cmd/server/first_seen_1166_test.go` (new)

## Scope guardrails

- No schema migration.
- No new files outside the worktree's three allowed surfaces.
- No refactor of other Nodes columns.
- Empty cells handled in both render (em-dash) and sort (always last).

---------

Co-authored-by: fix-1166-bot <bot@corescope.local>
2026-06-04 16:27:48 -07:00
5fd8900cfc feat(packets): add Path symbols legend disclosure (closes #1504) (#1570)
## Summary

Closes #1504. Adds a tiny, dismissible "Path symbols" legend next to the
Path column header on the Packets page (and reused on the Nodes page's
"Paths Through This Node" card), explaining the three
otherwise-undiscoverable path glyphs:

- `⚠N` — regional conflict count (multiple candidates for the hop's
prefix in this region)
- `⚠️` — unreliable name resolution (best-guess pubkey couldn't be
confirmed)
- dashed underline — ambiguous / global-fallback resolution

## Rationale (from triage)

- **Tufte**: integrate words and graphics. A hidden per-row tooltip
violates "don't make the viewer cross-reference." A small, persistent
inline key next to the column header is dense, on-data, and dismissible.
- **Avoid a modal** — chartjunk for a 3-glyph vocabulary.
- **Munger** rejected the reporter's option #2 (hover overlay that
pauses live updates): a power-user table must not stall from accidental
hovers.
- Single shared constant on `HopDisplay` so the Nodes page reuses the
same vocabulary without drift.

## Files

- `public/hop-display.js` — export `PATH_SYMBOLS_LEGEND` constant +
`renderPathSymbolsLegend()` helper (no changes to existing badge
rendering logic)
- `public/packets.js` — wire renderer into the Path `<th>` header
- `public/nodes.js` — reuse renderer on `#fullPathsSection` h4
- `public/style.css` — minimal styling (subtle dotted-underline trigger
+ floating disclosure panel, all via theme vars)
- `test-frontend-helpers.js` — 5 new assertions (TDD red→green)

## TDD red → green

- RED commit `46741267` — adds 5 assertion-shaped tests; all fail on the
assertion (not on import/build).
- GREEN commit `fab27ec5` — implements the constant, renderer, wiring,
and CSS; all 607 frontend-helper tests pass.

## Tested via

- DOM-grep assertions on the rendered `<details>` markup (`<summary>Path
symbols</summary>`, all three glyphs present, dashed-underline
description).
- Static grep that `packets.js` invokes the shared renderer adjacent to
the Path column.
- Full `test-frontend-helpers.js`, `test-packet-filter.js`,
`test-aging.js` pass.

## Hard rules honored

- No modal, no pause-on-hover, no changes to `hop-display.js`'s badge
rendering logic.
- No `<img>`/SVG additions, no new CSS vars (uses existing theme vars),
no Go changes.
- PII grep clean on every commit and on this body.

Browser verified: manual smoke pending — disclosure is closed-by-default
and uses standard `<details>` semantics; renders inline with column
header.

E2E assertion added: `test-frontend-helpers.js` — `#1504:
renderPathSymbolsLegend returns <details> disclosure with "Path symbols"
summary + all glyphs` (and 4 sibling assertions).

---------

Co-authored-by: Kpa-clawbot <bot@meshcore-analyzer>
Co-authored-by: clawbot <bot@openclaw.local>
2026-06-04 10:30:35 -07:00
f15b677981 fix(security): escape mesh node names before HTML render — stored XSS (#1536) (#1537)
## This PR fixes the stored XSS in full (closes #1536)

Mesh-advertised node names (`adv_name`) and observer names were rendered
into the dashboard DOM **without HTML-escaping** in multiple places —
the same class as the publicly disclosed MeshCore dashboard XSS
(CVE-2026-45323). `adv_name` has no protocol-level validation and the Go
`sanitizeName()` keeps `< > " &`, so a payload like `<img src=x
onerror=...>` reaches the frontend intact and executes.

**I audited every name/sender/text/channel render in `public/` and this
PR escapes all unescaped sinks. There are no known remaining XSS sinks
of this class after this change.**

### Sinks fixed (all escaped via the existing global `escapeHtml`, plus
a local helper for the standalone `area-map.html`)

| File | Sink |
|------|------|
| `app.js` | global search dropdown — node name + channel name |
| `nodes.js` | nodes-table row name; node-detail Leaflet popups (×2) |
| `observers.js` | observers-table name cell |
| `packets.js` | observer-name cells via `obsNameOnly` (×4) + observer
multi-select checkbox label |
| `live.js` | node-filter `<option>` + map marker tooltip |
| `analytics.js` | topology map node tooltip |
| `route-view.js` | hop + union marker tooltips (×2) |
| `area-map.html` | node popups (×2) — added a local `escapeHtml` (file
is standalone) |

### Already-safe (verified, not changed)
`map.js` popups (`safeEsc`), live-feed text (`escapeHtml(preview)`),
packet-detail text, channel messages (`channels.js`), `route-render.js`
popups, `hop-display.js`.

### Why escape at the sink (not the backend)
`sanitizeName()` only strips control chars; HTML-escaping stored names
server-side would be lossy and corrupt legitimate names containing `& <
>`, and break the `meshcore://` deep-links / exports. Output-encoding at
render is the correct OWASP fix and matches `meshcore-card` v0.3.3.

### Tests
- Added 6 `escapeHtml` regression tests including the CVE payload `<img
src=x onerror=alert(1)>` and an attribute-breakout payload.
- `node test-frontend-helpers.js`: **568 passed / 32 failed** — the 32
are pre-existing sandbox limitations (e.g. `AreaFilter is not defined`),
identical to the untouched baseline (562/32). Zero new failures.

### Cache busting
Automatic — the server rewrites `__BUST__` in `index.html` with a
restart timestamp, so no manual bump is needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: CoreScope Bot <bot@meshcore>
Co-authored-by: Kpa-clawbot <bot@clawbot.local>
2026-06-03 10:55:02 -07:00
Eldoon Nemar 2e70bcb671 UI accent partial fix for issue #1528 (#1530)
Made the suggested changes as listed in the fix path provided by
@Kpa-clawbot

Fix path:

`style.css:1244` `.field-table .section-row td` → `color: var(--text)`
(or new `--section-header-fg`).
`style.css:2620-2631` `.copy-link-btn` → `color: var(--text);`
background/border via `--accent-bg` / `--accent-border` tokens with safe
defaults.
`live.css:987` `.vcr-scope-btn.active` → same token swap; ensure text
remains `--text` on the tinted bg.
`nodes.js:212` `.multibyte-badge` → move inline styles to style.css,
`color:var(--text)`, keep `--accent-bg` background.

When creating the defaults for `--accent-bg` and `--accent-border`, I
chose to go with the default style values embedded in nodes.js, as that
was the safest bet.

We should probably extend the custom themes to include these variables
as well as not to confuse users if they see it. This also causes the
delima of, sometimes the `--accent` is use as the background for
objects, and not `--accent-bg`, example:
`btn active` has background set to` --accent` and border set to
`--accent`.

If we don't extend the config to accept accent-bg and accent-border, we
risk users still making accents of light blue that will be drown out
with the defaults we've set.


Also updated the badge above the multi-byte badge that contains X bytes
of the nodes public key, where X is determined by the path byte length.
This was done because it had styles set that were easy to add to the
styles.css file, to clean up coe. The node-type badge above it is
unfortunately driven by javascript in the nodes.js page, and requires
syling.

**Note:** Accidentally added ghost changes into this push for a second
time. They can be ignored as they were previously merged and shouldn't
have been seen as new.
2026-06-02 12:54:11 -07:00
Kpa-clawbotandopenclaw-bot d964c27964 feat(mobile): packets UX overhaul + nav surface + map inset + channel synthesis fixes (#1471)
## Summary

Mobile UX overhaul for the packets surface plus two discoverable defects
found along the way. All UI changes are mobile-only (`@media (max-width:
900px)` or `isMobile()` gates) — desktop unchanged.

## Closes
- #1415 — packets layout cross-viewport jank
- #1458 — Tufte mobile packets critique (P0s)
- #1461 — Tufte v2 mobile packets critique (P0/P1)
- #1467 — Favorites/Search/Customize unreachable on mobile
- #1468 — client-side "unknown" channel synthesis
- #1470 — node-detail map inset doesn't honor customizer dark provider

## Commits

1. `fix(#1468): drop client-side "unknown" channel synthesis` —
`channels.js`
2. `feat(#1470): node-detail map inset honors customizer dark-tile
provider` — `nodes.js`, `roles.js`
3. `feat(mobile): packets UX overhaul + bottom-nav More controls (#1415,
#1458, #1461, #1467)` — `style.css`, `index.html`,
`mobile-page-actions.js` (new)

## Mobile-list view changes
- Kill empty chevron rail
- Slim sticky THEAD (24px, retains sort affordance per operator
preference)
- Hide entire page-header on mobile
- Mirror pause + Filters pill into navbar via new
`mobile-page-actions.js`
- Convert group-header `toggle-select` → `select-hash` on mobile (no
dead-end expand)

## Mobile detail-panel changes
- Drop redundant src→dst line (identity already in sticky header)
- Hide boxed "decoded message" duplication card
- Hide PAYLOAD TYPE row (already in header badge)
- 2-col label/value grid (cuts panel height ~40%)
- Sticky in-sheet header for packet identity
- Kill iOS-style drag handle (conflicts with browser pull-to-refresh)
- Make ✕ close visible + always reachable
- Outer sheet `overflow:hidden`, inner content `overflow-y:auto`
(scrollable region distinct, scrollbar visible)
- Bottom-nav clearance (`padding-bottom: 60px`)
- Close detail sheet on route change away from /packets
- Tap-to-toast popovers for score tooltips (`title=` doesn't fire on
touch)

## Mobile nav surface
- Mirror Favorites ⭐ / Search 🔍 / Customize 🎨 into bottom-nav More sheet
(#1467)
- Brand stays in top nav; per-page controls (pause, Filters) injected
into `.nav-left`

## Other fixes shipped together
- **#1468**: drop CHAN messages with no decoded channel name (eliminates
fake "unknown" channel row)
- **#1470**: `_applyTilesToNodeMap` helper — node-detail inset map reads
from `MC_TILE_PROVIDERS[active]` instead of hardcoded OSM; honors
customizer's dark-tile provider pick + applies invert filter for
inverted variants
- `getTileUrl()` + new `getActiveTileProvider()` in `roles.js` now
consult `MC_TILE_PROVIDERS`

## CDP verification (local chromium)

Tested on staging at viewport 390×844 + 1206×928.

| Surface | Before | After |
|---|---|---|
| Chrome above first data row | 231px (27% viewport) | ~80px (10%
viewport) |
| Packets visible above fold | 10 | 17 |
| Detail panel duplications | 3× identity | 1× (header only) |
| Mobile group-expand UX | dead-end (no chevron) | converts to
select-hash |
| Score tooltips on touch | broken (title= silent) | tap → toast popover
|
| Node detail map inset (dark mode) | always OSM light tiles | honors
customizer provider + invert filter |
| Bottom-nav More controls | Dark mode only | + Favorites, Search,
Customize |

## What's NOT in this PR
- Paths-through-node sort fix lives in #1431 (parallel PR for #1145)
- Detail-panel hex byte-grid behind disclosure — operator wants it;
follow-up
- Group-header row sizing (some render 200–700px tall) — existing
behavior, follow-up

## Test plan
- [ ] Existing frontend tests stay green
(`test-issue-1415-packets-layout.js`,
`test-issue-1420-tile-providers.js`,
`test-issue-1454-channels-toggle.js` all pass locally on this branch)
- [ ] Existing Playwright E2E stays green
- [ ] CDP on local chromium: 390×844 mobile + 1024×768 tablet + 1440×900
desktop — no regressions

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-28 16:11:25 -07:00
Kpa-clawbotandclawbot d24246395d fix(#1456): rename Usefulness → Traffic share + add traffic_share_score field (#1457)
## Summary

Rename the "Usefulness" UI label to "Traffic share", add hover tooltips
for both Traffic share and Bridge score, and introduce a new
`traffic_share_score` field on `/api/nodes` (alongside the legacy
`usefulness_score`, kept for API back-compat).

Closes #1456.

## Why

The "Usefulness" label implied a composite score that doesn't exist yet
— only the Traffic-share axis (axis 1 of 4 from #672) and the Bridge
axis (axis 2 of 4 from #1275) are wired today. A node with low traffic
but critical structural position read as "not useful" — exactly wrong.
Neither score had a tooltip explaining what it measured.

## Changes

### Frontend (`public/nodes.js`)
- Visible label `Usefulness` → `Traffic share` (with ⓘ glyph)
- Tooltip explains traffic-share semantics, cross-references Bridge for
structural importance, points at #672 for the 4-axis roadmap
- Bridge row gets a parallel ⓘ glyph and a tooltip naming "betweenness
centrality" + the "quiet but irreplaceable chokepoint" interpretation
- Prefers new `traffic_share_score` with graceful fallback to legacy
`usefulness_score`

### Backend (`cmd/server/routes.go`)
- `/api/nodes` and `/api/nodes/{pubkey}` now emit BOTH
`usefulness_score` (kept for API compat) AND `traffic_share_score` (new
canonical name), populated with the same value
- Inline comment documents the deprecation path: when the #672 composite
ships, `usefulness_score` becomes the composite and
`traffic_share_score` keeps the per-axis value

## Tests

- `test-issue-1456-score-labels.js` — file-grep pins on `nodes.js`
(label, tooltip fragments, percent formatting, dual-field read with
fallback)
- `cmd/server/traffic_share_score_test.go` — `/api/nodes` +
`/api/nodes/{pk}` responses contain both fields with equal values

TDD: red commit (`8bd235a0`) added failing tests; green commit
(`c4d3aee5`) implemented. `go test ./cmd/server/...` passes (47s).

## Out of scope

- Renaming the backend field (would break consumers)
- Wiring axes 3 (Coverage) and 4 (Redundancy) — tracked in #672
- Changing the score calculation

---------

Co-authored-by: clawbot <bot@openclaw.local>
2026-05-28 05:22:08 -07:00
317b59ab10 feat: area-based visual node filter — attribute packets by transmitter GPS (#804) (#839)
## Summary

- Adds configurable GPS polygon areas to `config.json`; nodes are
attributed to an area if their last-known position falls inside the
polygon
- New `Area: …` dropdown filter (matching the existing region filter
style) appears on all analytics, nodes, packets, map, and live screens
when areas are configured
- Backend resolves area membership with a 30s TTL cache; area filter
bypasses the 500-node cap on `/api/bulk-health` so all area nodes are
always returned
- Includes a polygon builder tool (`/area-map.html`) for drawing and
exporting area boundaries

## Changes

**Backend**
- `AreaEntry` type + `Areas` config field
- `GetNodePubkeysInArea` DB query + `resolveAreaNodes` (30s TTL,
`areaNodeMu` RWMutex)
- `PacketQuery.Area` + `filterPackets` polygon check
- `?area=` param propagated through all analytics, topology,
clock-health, and bulk-health routes
- `/api/config/areas` endpoint

**Frontend**
- `area-filter.js`: single-select dropdown, persists to localStorage,
cleans up stale keys on load
- Wired into analytics, nodes, packets, channels, map, and live pages
- Live map clears node markers on area change

**Docs & tools**
- `docs/user-guide/area-filter.md` — configuration and usage guide
- `docs/api-spec.md` — updated with new endpoint and `?area=` param
table
- `tools/area-map.html` — polygon builder for defining area boundaries
- Demo areas added to `config.example.json`

## Test plan

- [x] No areas configured → filter dropdown does not appear on any page
- [x] Areas configured → dropdown appears, "All" selected by default
- [x] Selecting an area filters nodes/packets/topology/map correctly
- [x] Selecting "All" restores unfiltered view
- [x] Selection persists across page reloads (localStorage)
- [x] Stale localStorage key (area removed from config) is cleared on
load
- [x] `/api/bulk-health?area=X` returns all nodes in area (no 500-node
cap)
- [x] `/api/config/areas` returns correct list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-21 14:00:15 -07:00
efiten 7342166f0a feat(nodes): add sortable Scope column to nodes list (#1195)
## Summary

- Adds a **Scope** column to the nodes list table, positioned after Role
- Shows `default_scope` for nodes that have one (populated from scoped
ADVERT packets, landed in #899), empty for the rest
- Column is sortable (alphabetical); hidden on narrow screens
(`data-priority="3"`, same as Public Key)

## Test Plan

- [x] `node test-frontend-helpers.js` — all existing tests pass, two new
sort tests added (`sortNodes sorts by default_scope asc/desc`)
- [x] Open `/nodes` — Scope column visible between Role and Last Seen
- [x] Nodes with a known scope show the value in monospace; nodes
without show an empty cell
- [x] Click Scope header → sorts ascending; click again → sorts
descending
- [x] Empty-scope rows go to the bottom on asc, top on desc
- [x] Narrow the browser → Scope column hides at the same breakpoint as
Public Key

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-20 20:56:44 -07:00
Kpa-clawbotandbot 749fdc114f feat(decoder+ui): close remaining P2 items from #1279 — payloadTypeNames, legend, TransportCodes, Feat1/2, RAW_CUSTOM, sensor docs (#1291)
RED commit: `dc4c0800` — CI:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2Fissue-1279-p2

Closes the remaining six 🟢 P2 items in umbrella #1279 (PR #1280 shipped
P0+P1, PR #1276 shipped ACK/RESPONSE/PATH legend rows).

### Item-by-item

| # | Item | Where | Test |
|---|---|---|---|
| 1 | `payloadTypeNames` parity | `cmd/server/store.go` |
`cmd/server/issue1279_p2_test.go::TestPayloadTypeNamesAll13` |
| 2 | Legend rows: Anon Req / Grp Data / Multipart / Control / Raw
Custom | `public/live.js` | `test-issue-1279-legend-p2-e2e.js`
(Playwright) |
| 3 | TransportCodes detail-row + `code1=` / `code2=` filter grammar |
`public/packets.js`, `public/packet-filter.js` |
`test-issue-1279-p2-code-filter.js` (6 cases) |
| 4 | Multibyte capability badge on node detail/list rows |
`public/nodes.js::renderNodeBadges` | `n.hash_size >= 2` (observable
Feat1/Feat2 proxy; firmware `AdvertDataHelpers.h:14-16`) |
| 5 | RAW_CUSTOM (0x0F) `{rawLength, firstByteTag}` decode + detail-row
| `cmd/server/decoder.go`, `cmd/ingestor/decoder.go`,
`public/packets.js` | `TestDecodeRawCustomExposesLengthAndTag` × 2 +
updated `TestDecodePayloadRAWCustom` |
| 6 | Sensor advert telemetry firmware-derivation comments |
`cmd/ingestor/decoder.go:363-380` | pure comments — exempt per AGENTS |

### Firmware refs cited inline
- `firmware/src/Packet.h:19-32` — PAYLOAD_TYPE_* constants
- `firmware/src/Packet.h:46` — TransportCodes wire layout
- `firmware/src/Mesh.cpp:577` — `createRawData`
- `firmware/src/helpers/SensorMesh.{h,cpp}` — sensor advert telemetry
derivation
- `firmware/src/helpers/AdvertDataHelpers.h:14-16` — Feat1/Feat2

### TDD
Red `dc4c0800` proves the assertions gate behavior:
- `payloadTypeNames` had only 12 entries (no 0x0F).
- RAW_CUSTOM decoded as `UNKNOWN` with no envelope fields.

Green `<HEAD>` makes both green; per-item tests included.

### Cross-stack note
Cross-stack: justified — items 1/5 add decoder output fields; items
2/3/4/5 surface those fields in the UI in the same PR per #1279
acceptance.

### Out of scope
Item 4 surfaces the observable multibyte capability via the persisted
`hash_size` (Feat1/Feat2 wire bits are only on transient adverts and not
stored per-node today); persisting raw Feat1/Feat2 per-node is left for
a follow-up.

Fixes #1279

---------

Co-authored-by: bot <bot@corescope>
2026-05-19 08:08:28 -07:00
Kpa-clawbotandcorescope-bot 467b01a1b3 fix(#1285): exclude RTC-reset outliers from clock-skew hash median + recent bad count (#1288)
Red commit: 97c9a22a55 (CI:
https://github.com/Kpa-clawbot/CoreScope/commit/97c9a22a55b07d1576c579aa9d23b290dad33eb6/checks)

Fixes #1285.

## What was broken

**Bug A — outlier-dominated hash-evidence median.** On the per-hash
evidence panel a single observer reporting an RTC-reset advert (firmware
emitting factory timestamp, ~700d off) dragged the displayed median to
"median corrected: -704d 18h" even when every other observer of that
hash saw a normal value.

**Bug B — false "N of last K had nonsense timestamps" warning.**
`recentBadSampleCount` lumped RTC-reset adverts in with "bimodal-bad"
samples. On the repro node every recent skew was -16…-22s (healthy), but
the lone RTC-reset advert that landed inside the recent window was
counted as bad → "3 of last 5 adverts had nonsense timestamps" fired and
the node was misclassified `bimodal_clock`.

Root cause of B: the recent-window split (`cmd/server/clock_skew.go`
~L575) classified anything `|corrected skew| > 1h` as "bad". That
conflates true bimodal RTC oscillation (1h…24h) with factory-timestamp
resets (>24h, already surfaced via the RTC-reset badge).

## Fix

- New `rtcResetOutlierThresholdSec = 24h`. Rationale: real µC drift is
sub-second/advert; real bimodal RTC misbehaves in the hours range;
anything >1d is not a drift signal.
- Recent-window split puts `|skew| > 24h` in a third bucket excluded
from both `recentSampleCount` and `recentBadCount`.
- New `hashEvidenceMedian()` filters outliers before computing the
per-hash median. UI labels the hash "insufficient data (N RTC-reset
outliers excluded)" when every observer saw a reset-shaped advert.
- Three pre-existing #845 tests used -50M-sec "bad" samples (RTC-reset
range) — re-pointed to -7200s (true bimodal range), what `bimodal_clock`
actually models.

## Preflight overrides
- check-branch-clean: cross-stack: justified — backend computes
counts/median; frontend renders the new label.

## Browser verification
Confirmed staging node `c0dedad…` repro matches the test fixture. No new
CSS vars.

## E2E assertion added
`cmd/server/clock_skew_issue1285_test.go:81` and `:103`.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-19 01:17:12 -07:00
Kpa-clawbotandclawbot 8bf7709970 feat(repeater): usefulness score — bridge axis (#672 axis 2 of 4) (#1275)
RED test commit: `fd661569` — CI will fail on this (stub returns empty
map; assertions fail by design). GREEN: `bf4b8592`.

## What

Implements **axis 2 of 4** for the repeater usefulness score per #672
([status
comment](https://github.com/Kpa-clawbot/CoreScope/issues/672#issuecomment-4484635378)).
The Bridge axis measures *structural importance*: how many shortest
paths between other nodes route through this one. A high-traffic
redundant node and a low-traffic critical bridge will no longer look
identical.

## Algorithm

**Brandes' weighted betweenness centrality** with Dijkstra for shortest
paths (`cmd/server/bridge_score.go`).

- Nodes: pubkeys in the `neighbor_edges` graph
- Edge weight: `Score(now) * Confidence()` — per the convention from
#1235 (count + recency decay scaled by observer-diversity confidence).
Geo-rejected edges already excluded at graph build time (#1230) so we
don't re-filter here.
- Dijkstra distance: `1 / max(epsilon, weight)` — high affinity = cheap
cost.
- Normalize: divide by max observed centrality so output is in `[0, 1]`.

Cost: `O(V · (E + V log V))`. Staging-scale (~600 nodes / ~2 000 edges)
≈ ~4.8M ops, completes in milliseconds.

## Where it lives

- `cmd/server/bridge_score.go` — pure algorithm, no locks
- `cmd/server/bridge_recomputer.go` — background recomputer (mirrors
#1240/#1262 pattern), 5-min default interval, initial sync prewarm,
snapshot stored in `s.bridgeScoreMap atomic.Pointer[map[string]float64]`
- `cmd/server/routes.go` — `handleNodes` adds `node["bridge_score"]` on
repeater/room rows; node-detail handler adds it on the single-node path
- `public/nodes.js` — separate **Bridge** row in the node detail panel,
alongside the existing **Usefulness** (Traffic) row. Distinct
colour-coded bar.

## What's NOT in this PR (still pending for #672)

- **Coverage axis** (axis 3) — unique observer-pair connectivity
- **Redundancy axis** (axis 4) — simulated node-removal impact
- **Composite** — once all 4 axes ship, swap the `usefulness_score`
formula from "traffic-only" to the weighted composite

`Refs #672` (not `Fixes` — issue stays open until all 4 axes + composite
ship).

## Tests

- `TestComputeBridgeScores_LineGraph` — 4-node line: middles non-zero,
leaves zero, max normalized to 1.0
- `TestComputeBridgeScores_TriangleNoBridge` — clique has zero bridges
- `TestComputeBridgeScores_Empty` — defensive nil-safety
- `TestComputeBridgeScores_WeightSensitive` — mutation guard: revert the
`1/w` inversion and this test fails
- `TestBridgeScore_HandleNodesSurface` — integration: `/api/nodes`
returns `bridge_score` on repeater rows; middle nodes > 0, ends == 0

---------

Co-authored-by: clawbot <bot@meshcore.local>
2026-05-18 22:51:23 -07:00
89d644dd72 fix(#1056): row-detail slide-over panel at narrow widths (AC #4) (#1168)
Red commit: 8ac568bac3 (CI run: pending)

## Summary
Implements AC #4 of #1056: row-detail **slide-over panel** at narrow
viewports for the Packets, Nodes, and Observers tables.

ACs #1–#3, #5 already shipped in #1099; this PR closes the remaining
criterion.

## Approach
- Shared `window.SlideOver` helper (`packets.js`, top of file next to
`TableResponsive`) — singleton overlay (`.slide-over-backdrop` +
`.slide-over-panel`) injected into `<body>`. Close affordances: X button
(`.slide-over-close`), backdrop click, Escape key. `aria-modal="true"`,
focus moved to close button on open.
- Breakpoint: `window.innerWidth <= 1023` (matches the
`data-priority="3"` threshold reused by `TableResponsive`). At `>=1024`
the existing right-side panel / full-screen behavior is preserved — no
regression.
- Each page (`packets.js`, `nodes.js`, `observers.js`) checks the
breakpoint at row-click time and routes the same detail content into
`SlideOver.open(node)` instead of the side panel / full-screen
navigation.
- Reuses the existing `slideInRight` keyframe in `style.css`.
- CSS additions live in the table section of `style.css` only.

## E2E
`test-slideover-1056-e2e.js` — at 800x800 clicks the first row of each
of the three tables, asserts `.slide-over-panel` +
`.slide-over-backdrop` are visible and the close X exists; verifies
Escape, backdrop click, and X click all dismiss; verifies that at 1440
the slide-over does NOT appear.

E2E assertion added: `test-slideover-1056-e2e.js:71`

## TDD
- Red commit: `8ac568b` — E2E asserts on `.slide-over-panel` which does
not exist yet.
- Green commit: forthcoming in this PR.

Fixes #1056

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
2026-05-08 14:13:37 -07:00
Kpa-clawbotandCoreScope Bot d6256c4f94 fix(#1151): drop orphan separators from side-panel Heard By rows (#1161)
Fixes #1151

## Problem

The side-panel "Heard By" row template in `public/nodes.js` (line 1337)
built its stats suffix with inline ternaries:

```js
${o.packetCount} pkts · ${o.avgSnr != null ? '...' : ''}${o.avgRssi != null ? ' · RSSI ...' : ''}
```

When `avgSnr` and/or `avgRssi` were `null` (very common in prod —
many CJS observers have both null), this produced orphan separators:

- both null → `"110 pkts · "` (trailing dot)
- snr null only → `"55 pkts ·  · RSSI -50"` (double dot)

## Fix

Build a filtered parts array, then `.join(' · ')`. Only present fields
contribute, so the separator can never appear next to nothing.

```js
const stats = [`${o.packetCount} pkts`];
if (o.avgSnr != null)  stats.push('SNR ' + Number(o.avgSnr).toFixed(1) + 'dB');
if (o.avgRssi != null) stats.push('RSSI ' + Number(o.avgRssi).toFixed(0));
// → stats.join(' · ')
```

Full-page table (line 1337's neighbor) was already null-safe (separate
`<td>` cells), so only the side-panel template needed the change.

## TDD

Red commit: `1c02ff9a7889aadd16f87f4e673287f9742d4ad0` — adds
`test-issue-1151-orphan-separators-e2e.js` to the deploy.yml E2E job.

The test stubs `/api/nodes/:pubkey/health` via Playwright `page.route()`
with four observer permutations (both null, snr-only-null,
rssi-only-null,
both set), opens the side panel, and asserts no `.observer-row` stat
suffix matches `· ·`, leading `·`, or trailing `·`.

E2E assertion added: `test-issue-1151-orphan-separators-e2e.js:96`

## Preflight

All hard gates pass — see preflight output in the implementation log.

---------

Co-authored-by: CoreScope Bot <bot@corescope>
2026-05-07 08:29:22 -07:00
Kpa-clawbotandkpaclawbot f27132e44e ui(node-detail): re-order sections so Recent Packets appears before Paths (#1147) (#1160)
Fixes #1147

## What

Re-orders the node-detail sections in **both** the side panel and the
full
node detail page. New sequence matches operator mental order
(identity → what this node SAID → who heard it → relay topology → meta):

1. Identity (name, role, badges)
2. Map + QR (full page) / Public key (side panel)
3. Overview (Last Heard, First Seen, Total Packets, etc.)
4. **Recent Packets** ← lifted from bottom
5. Heard By (observers)
6. Neighbors
7. Paths Through This Node
8. Clock Skew (hidden until populated)

## Why

"What did this node originate?" is the most-asked operator question at
the
node-detail surface. Previously Recent Packets was the LAST section in
both
views — operators had to scroll past Clock Skew, Heard By, Neighbors,
and
Paths just to see the node's own activity. Section B4 of the
node-analytics review flagged this as P1.

## Changes

- `public/nodes.js`: pure template re-order in two render paths
  (full-page `loadFullNode`, side-panel `renderDetail`). No data,
  styling, or behavior changes — same DOM ids, same CSS classes,
  same content per section.
- `test-issue-1147-section-order-e2e.js`: new Playwright test that
  loads a node detail page (and the side panel) against the fixture
  DB and asserts `Recent Packets` index in DOM order is **before**
  `Paths Through This Node`, `Heard By`, and `Neighbors` for both
  surfaces.
- `.github/workflows/deploy.yml`: wired the new E2E into the
  existing `e2e-test` job.

## TDD trail

- Red commit: `c0829fd` — adds failing E2E (Recent Packets is last).
- Green commit: `29cdb22` — re-orders the templates, test passes.

## Browser verified

E2E assertion added: `test-issue-1147-section-order-e2e.js:84` (full
page) and `:115` (side panel). Local Chromium can't run on this host
(libc reloc), so verification is via CI; server-side `grep` of rendered
`/nodes.js` confirms the new section order in both code paths.

## Preflight

All hard gates pass (PII, branch scope, red commit, CSS vars,
self-fallback, LIKE-on-JSON, sync migration). All warning gates pass.

---------

Co-authored-by: kpaclawbot <bot@kpaclawbot.local>
2026-05-07 06:50:03 -07:00
Kpa-clawbotandmeshcore-bot 844536a4cc fix(#1150): render error state on full-page node detail when API 404s (#1158)
Red commit: cd3bae2 (will fail CI — test asserts behavior that doesn't
exist yet)
Green commit: 01e1882 (fixes the catch-path)

## Problem

Navigating to `/#/nodes/{unknown_pubkey}` returned 404 from
`/api/nodes/{pubkey}`,
but the back-row title in `public/nodes.js` stayed "Loading…" forever
and the
body only showed bare "Failed to load node: API 404" text — no link back
to the
Nodes list, no retry.

## Fix

In `loadFullNode`'s catch path:

- Update `.node-full-title` to `Node not found — <prefix>…` on 404, or
  `Failed to load node` on other errors.
- Replace the bare body text with a card showing the requested pubkey
(mono),
a friendly explanation, a `Back to Nodes` link (`href="#/nodes"`), and a
  `Try again` button that re-invokes `loadFullNode(pubkey)`.

## Tests

Added `test-issue-1150-404-state-e2e.js` (Playwright) which:

1. Verifies `/api/nodes/<unknown>` actually 404s (precondition).
2. Navigates to `/#/nodes/<unknown>`.
3. Asserts `.node-full-title` is NOT `"Loading…"` and indicates
not-found / contains pubkey prefix.
4. Asserts `#nodeFullBody` text contains `"not found"` or `"unknown"`.
5. Asserts a body `<a href="#/nodes">` exists (Back to Nodes link).

Wired into `.github/workflows/deploy.yml` E2E step. Reverting either the
title fix or the body fix flips the test red.

E2E assertion added: `test-issue-1150-404-state-e2e.js:62` (title),
`:79` (body), `:88` (back link)
Browser verified: assertion is exercised against the workflow's
localhost:13581 server in CI.

Fixes #1150

---------

Co-authored-by: meshcore-bot <meshcore-bot@users.noreply.github.com>
2026-05-07 06:06:33 -07:00
52bb07d6c1 feat(#1056): fluid tables + +N hidden pill (packets/nodes/observers) (#1099)
## Summary

Implements priority-based responsive column hiding for the three primary
data tables (Packets, Nodes, Observers) per the parent task #1050
acceptance criteria, with a clickable **+N hidden** pill in the table
header to reveal collapsed columns.

## Approach

- New `TableResponsive` helper (defined once at the top of `packets.js`,
exposed on `window`) classifies `<th data-priority="N">` cells:
  - `1` = always visible
  - `2` = hide when viewport ≤ 1280
  - `3` = hide ≤ 1080
  - `4` = hide ≤ 900
  - `5` = hide ≤ 768
- Higher priority numbers drop first. The matching `<td>` cells in
`tbody` are tagged via `.col-hidden` (colspan-aware mapping).
- A `.col-hidden-pill` `<button>` is appended to the last visible
`<th>`. Clicking it sets a per-table reveal flag and clears all hidden
classes. Re-runs on `window.resize` (debounced) and a `ResizeObserver`
on the wrapping element.
- Each of `packets.js` / `nodes.js` / `observers.js` wraps its primary
table in `.table-fluid-wrap` and calls `TableResponsive.register` after
initial render.
- `style.css` removes legacy `min-width: 720px / 480px` floors on the
primary tables (which forced horizontal scroll) and lets columns flex
via `table-layout: auto` with `.col-time` switched to `clamp(72px, 8vw,
108px)`.

Per-column priorities chosen so identifier columns stay visible
(Time/Hash/Type/Name/Status) while numeric/secondary columns collapse
first.

## Files changed (matches Hard rules — only these)

- `public/packets.js` (`#pktTable` + `TableResponsive` helper)
- `public/nodes.js` (`#nodesTable`)
- `public/observers.js` (`#obsTable`)
- `public/style.css` (table sections only)
- `test-table-fluid-e2e.js` (new E2E)

## E2E

`BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js` — covers
all three tables at 768/1080/1440 viewports, asserting:

- No horizontal table overflow within `.table-fluid-wrap`
- Visible `+N hidden` pill at narrow widths with the count `N` matching
the number of `th.col-hidden` cells
- Clicking the pill clears all `.col-hidden` classifiers (reveals every
column)

## Manual verification in openclaw browser (local fixture server)

| Page      | Viewport | Hidden | Pill         |
|-----------|---------:|-------:|--------------|
| observers |      768 |      8 | `+8 hidden`  |
| packets   |      768 |      7 | `+7 hidden`  |
| packets   |     1080 |      4 | `+4 hidden`  |
| nodes     |      768 |      3 | `+3 hidden`  |
| nodes     |     1440 |      0 | (no pill)    |

Pill click verified to reveal all columns.

## TDD

- Red commit: `5ad7573` — failing E2E (no `.col-hidden-pill` exists yet)
- Green commit: `7780090` — implementation; test passes manually against
fixture server.

Fixes #1056

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-05 08:45:43 -07:00
Kpa-clawbotandmeshcore-bot 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-clawbotandclawbot 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-clawbotandclawbot 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-clawbotandclawbot 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-clawbotandyou 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-clawbotandKpa-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-clawbotandyou 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-clawbotandyou abd9c46aa7 fix: side-panel Details button opens full-screen on desktop (#892)
## Symptom
🔍 Details button in the nodes side panel does nothing on click.

## Root cause (4th regression of the same shape)
- Row click → `selectNode()` → `history.replaceState(null, '',
'#/nodes/' + pk)`
- Details button click → `location.hash = '#/nodes/' + pk`
- Hash is already that value → assignment is a no-op → no `hashchange`
event → no router → panel stays open.

## Fix
Mirror the analytics-link branch already inside the panel click handler:
`destroy()` then `init(appEl, pubkey)` directly (which hits the
`directNode` full-screen branch unconditionally). Also `replaceState` to
keep the URL in sync.

## Test
New Playwright E2E: open side panel via row click, click Details, assert
`.node-fullscreen` appears.

## Why this keeps regressing
Every time we tighten the row-click handler to use `replaceState`
(correct — avoids hashchange flicker), the button-click handler that
uses `location.hash` becomes a no-op for the same pubkey. Need to
remember they're coupled. Worth a follow-up to extract a
`navigateToNode(pk)` helper that always works regardless of current hash
state — filing as #890-followup if not already there.

Co-authored-by: you <you@example.com>
2026-04-21 22:37:15 -07:00
Kpa-clawbotandyou c99aa1dadf fix(#855, #856, #857) + feat(#862): /nodes detail panel + search improvements (#868)
## Summary

Four related `/nodes` page fixes batched to avoid merge conflicts (all
touch `public/nodes.js`).

---

### #855 — "Show all neighbors" link doesn't expand

**Problem:** The "View all N neighbors →" link in the side panel
navigated to the full detail page instead of expanding the truncated
list inline.

**Fix:** Replaced navigation link with an inline "Show all N neighbors
▼" button that re-renders the neighbor table without the limit.

**Acceptance:** Click the button → all neighbors appear in the same
panel without page navigation.

Closes #855

---

### #856 — "Details" button is a no-op

**Problem:** The "🔍 Details" link in the side panel was an `<a>` tag
whose `href` matched the current hash (set by `replaceState`), making
clicks a same-hash no-op.

**Fix:** Changed from `<a>` link to a `<button>` with a direct click
handler that sets `location.hash`, ensuring the router always fires.

**Acceptance:** Click "🔍 Details" → navigates to full-screen node detail
view.

Closes #856

---

### #857 — Recent Packets shows bullets but no content

**Problem:** The "Recent Packets (N)" section could render entries with
missing `hash` or `timestamp`, producing colored dots with no meaningful
content beside them.

**Fix:** Added `.filter(a => a.hash && a.timestamp)` before rendering,
and updated the count header to reflect filtered entries only.

**Acceptance:** Recent Packets section only shows entries with valid
data; count matches visible items.

Closes #857

---

### #862 — Pubkey prefix search on /#/nodes

**Problem:** Search box only matched node names. Operators couldn't
search by pubkey prefix.

**Fix:** Extended search to detect hex-only queries (`/^[0-9a-f]+$/i`)
and match them against pubkey prefix (`startsWith`). Non-hex queries
continue matching name as before. Both are composable in the same input.

**Acceptance:**
- Typing `3f` filters to nodes whose pubkey starts with `3f`
- Typing `foo` still filters by name
- Search placeholder updated to indicate pubkey support

5 new unit tests added for the search matching logic.

Closes #862

---------

Co-authored-by: you <you@example.com>
2026-04-21 10:24:27 -07:00
Kpa-clawbotandyou 441409203e feat(#845): bimodal_clock severity — surface flaky-RTC nodes instead of hiding as 'No Clock' (#850)
## Problem

Nodes with flaky RTC (firmware emitting interleaved good and nonsense
timestamps) were classified as `no_clock` because the broken samples
poisoned the recent median. Operators lost visibility into these nodes —
they showed "No Clock" even though ~60% of their adverts had valid
timestamps.

Observed on staging: a node with 31K samples where recent adverts
interleave good skew (-6.8s, -13.6s) with firmware nonsense (-56M, -60M
seconds). Under the old logic, median of the mixed window → `no_clock`.

## Solution

New `bimodal_clock` severity tier that surfaces flaky-RTC nodes with
their real (good-sample) skew value.

### Classification order (first match wins)

| Severity | Good Fraction | Description |
|----------|--------------|-------------|
| `no_clock` | < 10% | Essentially no real clock |
| `bimodal_clock` | 10–80% (and bad > 0) | Mixed good/bad — flaky RTC |
| `ok`/`warn`/`critical`/`absurd` | ≥ 80% | Normal classification |

"Good" = `|skew| <= 1 hour`; "bad" = likely uninitialized RTC nonsense.

When `bimodal_clock`, `recentMedianSkewSec` is computed from **good
samples only**, so the dashboard shows the real working-clock value
(e.g. -7s) instead of the broken median.

### Backend changes
- New constant `BimodalSkewThresholdSec = 3600`
- New severity `bimodal_clock` in classification logic
- New API fields: `goodFraction`, `recentBadSampleCount`,
`recentSampleCount`

### Frontend changes
- Amber `Bimodal` badge with tooltip showing bad-sample percentage
- Bimodal nodes render skew value like ok/warn/severe (not the "No
Clock" path)
- Warning line below sparkline: "⚠️ X of last Y adverts had nonsense
timestamps (likely RTC reset)"

### Tests
- 3 new Go unit tests: bimodal (60% good → bimodal_clock), all-bad (→
no_clock), 90%-good (→ ok)
- 1 new frontend test: bimodal badge rendering with tooltip
- Existing `TestReporterScenario_789` passes unchanged

Builds on #789 (recent-window severity).

Closes #845

---------

Co-authored-by: you <you@example.com>
2026-04-21 09:11:14 -07:00
a0fddb50aa fix(#789): severity from recent samples; Theil-Sen drift with outlier rejection (#828)
Closes #789.

## The two bugs

1. **Severity from stale median.** `classifySkew(absMedian)` used the
all-time `MedianSkewSec` over every advert ever recorded for the node. A
repeater that was off for hours and then GPS-corrected stayed pinned to
`absurd` because hundreds of historical bad samples poisoned the median.
Reporter's case: `medianSkewSec: -59,063,561.8` while `lastSkewSec:
-0.8` — current health was perfect, dashboard said catastrophic.

2. **Drift from a single correction jump.** Drift used OLS over every
`(ts, skew)` pair, with no outlier rejection. A single GPS-correction
event (skew jumps millions of seconds in ~30s) dominated the regression
and produced `+1,793,549.9 s/day` — physically nonsense; the existing
`maxReasonableDriftPerDay` cap then zeroed it (better than absurd, but
still useless).

## The two fixes

1. **Recent-window severity.** New field `recentMedianSkewSec` = median
over the last `N=5` samples or last `1h`, whichever is narrower (more
current view). Severity now derives from `abs(recentMedianSkewSec)`.
`MeanSkewSec`, `MedianSkewSec`, `LastSkewSec` are preserved unchanged so
the frontend, fleet view, and any external consumers continue to work.

2. **Theil-Sen drift with outlier filter.** Drift now uses the Theil-Sen
estimator (median of all pairwise slopes — textbook robust regression,
~29% breakdown point) on a series pre-filtered to drop samples whose
skew jumps more than `maxPlausibleSkewJumpSec = 60s` from the previous
accepted point. Real µC drift is fractions of a second per advert; clock
corrections fall well outside. Capped at `theilSenMaxPoints = 200`
(most-recent) so O(n²) stays bounded for chatty nodes.

## What stays the same

- Epoch-0 / out-of-range advert filter (PR #769).
- `minDriftSamples = 5` floor.
- `maxReasonableDriftPerDay = 86400` hard backstop.
- API shape: only additions (`recentMedianSkewSec`); no fields removed
or renamed.

## Tests

All in `cmd/server/clock_skew_test.go`:

- `TestSeverityUsesRecentNotMedian` — 100 bad samples (-60s) + 5 good
(-1s) → severity = `ok`, historical median still huge.
- `TestDriftRejectsCorrectionJump` — 30 min of clean linear drift + one
1000s jump → drift small (~12 s/day).
- `TestTheilSenMatchesOLSWhenClean` — clean linear data, Theil-Sen
within ~1% of OLS.
- `TestReporterScenario_789` — exact reproducer: 1662 samples, 1657 @
-683 days then 5 @ -1s → severity `ok`, `recentMedianSkewSec ≈ 0`, drift
bounded; legacy `medianSkewSec` preserved as historical context.

`go test ./... -count=1` (cmd/server) and `node
test-frontend-helpers.js` both pass.

---------

Co-authored-by: clawbot <bot@corescope.local>
Co-authored-by: you <you@example.com>
2026-04-20 22:47:10 -07:00
Kpa-clawbotandyou ddd18cb12f fix(nodes): Details link opens full-screen on desktop (#823) (#824)
Closes #823

## What
Remove the `window.innerWidth <= 640` gate on the `directNode`
full-screen branch in `init()` so the 🔍 Details link works on desktop.

## Why
- #739 (`e6ace95`) gated full-screen to mobile so desktop **deep links**
would land on the split panel.
- But the same gate broke the **Details link** flow (#779/#785): the
click handler calls `init(app, pubkey)` directly. On desktop the gated
branch was skipped, the list re-rendered with `selectedKey = pubkey`,
and the side panel was already open → no visible change.
- Dropping the gate makes the directNode branch the single, unambiguous
path to full-screen for both the Details link and any deep link.

## Why the desktop split-panel UX is still preserved
Row clicks call `selectNode()`, which uses `history.replaceState` — no
`hashchange` event, no router re-init, no `directNode` set. Only the
Details link handler (which calls `init()` explicitly) and a fresh
deep-link load reach this branch.

## Repro / verify
1. Desktop, viewport > 640px, open `/#/nodes`.
2. Click a node row → split panel opens (unchanged).
3. Click 🔍 Details inside the panel → full-screen single-node view (was
broken; now works).
4. Back button / Escape → back to list view.
5. Paste `/#/nodes/{pubkey}` directly → full-screen on both desktop and
mobile.

## Tests
`node test-frontend-helpers.js` → 553 passed, 0 failed.

Co-authored-by: you <you@example.com>
2026-04-21 05:13:52 +00:00
Kpa-clawbotandyou a9732e64ae fix(nodes): render clock-skew section in side panel (#813) (#814)
Closes #813

## Root cause
The Node detail **side panel** (`renderDetail()`,
`public/nodes.js:1145`) was missing both the `#node-clock-skew`
placeholder div and the `loadClockSkew()` IIFE loader. Those exist only
in the **full-screen** detail page (`loadFullNode`, lines 498 / 632), so
any node opened via deep link or click in the listing — which uses the
side panel — showed no clock-skew UI even when
`/api/nodes/{pk}/clock-skew` returned rich data.

## Fix
Mirror the full-screen template branch and IIFE in `renderDetail`:
- Add `<div class="node-detail-section skew-detail-section"
id="node-clock-skew" style="display:none">` to the side-panel template
(right above Observers).
- Add an async `loadClockSkewPanel()` IIFE after the panel `innerHTML`
is set, using the same severity/badge/drift/sparkline rendering and the
`severity === 'no_clock'` branch the full-screen view uses.

No new helpers — reuses existing window globals (`formatSkew`,
`formatDrift`, `renderSkewBadge`, `renderSkewSparkline`).

## Verification
- Syntax check: `node -c public/nodes.js` ✓
- `node test-frontend-helpers.js` → 553/553 ✓
- Browser: staging runs master so I couldn't validate the deployed UI
yet. Manual repro after deploy:
1. Open `https://analyzer.00id.net/#/nodes`, click any node with a known
skew (e.g. Puppy Solar `a8dde6d7…` shows `⏰ -23d 8h` in listing).
2. Side panel should show a **⏰ Clock Skew** section with median skew,
severity badge, drift line, and sparkline.
3. For `severity === 'no_clock'` (e.g. SKCE_RS `14531bd2…`), section
shows "No Clock" instead of skew value.

---------

Co-authored-by: you <you@example.com>
2026-04-20 21:45:42 -07:00
Kpa-clawbotandyou 34b8dc8961 fix: improve #778 detail link — call init() directly instead of router teardown (#785)
Improves the fix for #778 (replaces #779's approach).

## Problem

When clicking "Details" in the node side panel, the hash is already
`#/nodes/{pubkey}` (set by `replaceState` in `selectNode`). The link
targets the same hash → no `hashchange` event → router never fires →
detail view never renders.

## What was wrong with #779

PR #779 used `replaceState('#/')` + `location.hash = target`
synchronously, which forces a full SPA router teardown/rebuild cycle
just to re-render the same page. This is wasteful and can cause visual
flicker.

## This fix

**Detail link** (`#/nodes/{pubkey}`): Calls `init(app, pubkey)` directly
— no router teardown, no page flash. The `init()` function already
handles rendering the detail view when `routeParam` is set.

**Analytics link** (`#/nodes/{pubkey}/analytics`): Uses `setTimeout` to
ensure reliable `hashchange` firing, since this routes to a different
page (`node-analytics`) that requires the full SPA router.

## Testing

- Frontend helper tests: 552/552 ✅
- Packet filter tests: 62/62 ✅
- Aging tests: 29/29 ✅
- Go server tests: pass ✅
- Go ingestor tests: pass ✅

---------

Co-authored-by: you <you@example.com>
2026-04-17 16:39:57 -07:00
Kpa-clawbotandyou dfe383cc51 fix: node detail panel Details/Analytics links don't navigate (#779)
Fixes #778

## Problem

The Details and Analytics links in the node side panel don't navigate
when clicked. This is a regression from #739 (desktop node deep
linking).

**Root cause:** When a node is selected, `selectNode()` uses
`history.replaceState()` to set the URL to `#/nodes/{pubkey}`. The
Details link has `href="#/nodes/{pubkey}"` — the same hash. Clicking an
anchor with the same hash as the current URL doesn't fire the
`hashchange` event, so the SPA router never triggers navigation.

## Fix

Added a click handler on the `nodesRight` panel that intercepts clicks
on `.btn-primary` navigation links:

1. `e.preventDefault()` to stop the default anchor behavior
2. If the current hash already matches the target, temporarily clear it
via `replaceState`
3. Set `location.hash` to the target, which fires `hashchange` and
triggers the SPA router

This handles both the Details link (`#/nodes/{pubkey}`) and the
Analytics link (`#/nodes/{pubkey}/analytics`).

## Testing

- All frontend helper tests pass (552/552)
- All packet filter tests pass (62/62)
- All aging tests pass (29/29)
- Go server tests pass

---------

Co-authored-by: you <you@example.com>
2026-04-16 23:21:05 -07:00
Kpa-clawbotandyou ba7cd0fba7 fix: clock skew sanity checks — filter epoch-0, cap drift, min samples (#769)
Nodes with dead RTCs show -690d skew and -3 billion s/day drift. Fix:

1. **No Clock severity**: |skew| > 365d → `no_clock`, skip drift
2. **Drift cap**: |drift| > 86400 s/day → nil (physically impossible)
3. **Min samples**: < 5 samples → no drift regression
4. **Frontend**: 'No Clock' badge, '–' for unreliable drift

Fixes the crazy stats on the Clock Health fleet view.

---------

Co-authored-by: you <you@example.com>
2026-04-16 08:10:47 -07:00
Kpa-clawbotandyou 3bdf72b4cf feat: clock skew UI — node badges, detail sparkline, fleet analytics (#690 M2+M3) (#752)
## Summary

Frontend visualizations for clock skew detection.

Implements #690 M2 and M3. Does NOT close #690 — M4+M5 remain.

### M2: Node badges + detail sparkline
- Severity badges (⏰ green/yellow/orange/red) on node list next to each
node
- Node detail: Clock Skew section with current value, severity, drift
rate
- Inline SVG sparkline showing skew history, color-coded by severity
zones

### M3: Fleet analytics view
- 'Clock Health' section on Analytics page
- Sortable table: Name | Skew | Severity | Drift | Last Advert
- Filter buttons by severity (OK/Warning/Critical/Absurd)
- Summary stats: X nodes OK, Y warning, Z critical
- Color-coded rows

### Changes
- `public/nodes.js` — badge rendering + detail section
- `public/analytics.js` — fleet clock health view
- `public/roles.js` — severity color helpers
- `public/style.css` — badge + sparkline + fleet table styles
- `cmd/server/clock_skew.go` — added fleet summary endpoint
- `cmd/server/routes.go` — wired fleet endpoint
- `test-frontend-helpers.js` — 11 new tests

---------

Co-authored-by: you <you@example.com>
2026-04-15 15:25:50 -07:00