mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 05:07:53 +00:00
07a2e1f2cbfaba40edee932ef37a0dfd764d9ac5
137
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
81bc9930af |
fix: address bot review on PR #1852 (2 MAJOR races + cleanup)
MAJOR (carmack): handleConfigClient and nodeListPostFilters both read
s.cfg.GeoFilter unlocked, but the field is cfgMu-guarded — PUT
/api/config/geo-filter swaps it under Lock(). Both sites now go through
s.getGeoFilter() like the other existing read sites.
MAJOR (dijkstra): window.MC_GEO_FILTER is set asynchronously by roles.js's
/api/config/client fetch, but nodes.js read it synchronously during
loadNodes(). On a cold page load where the Nodes page renders before that
fetch resolves, every node was silently classified as domestic until the
user clicked a filter chip. loadNodes() now awaits window.MeshConfigReady
(which never rejects) before the geo-scope filter runs. New regression
test proves the race with a manually-controlled promise, and fails
against the pre-fix code (verified via git stash).
MINOR: stale test-file header comment (still described the filter as
using the `foreign` flag, predating 6012ed0's lat/lon rewrite); loose
interface{} != float64 comparison in the geoFilter config test replaced
with explicit type assertions so a shape change fails loudly instead of
comparing unequal to the wrong type.
NIT: trimmed an 8-line struct-field doc comment; added
role="group"/aria-label to both the Status and Domestic/Foreign
filter-button groups on the Nodes page (bot noted Status already lacked
one too).
|
||
|
|
2c80b44787 |
fix: Domestic/Foreign node filter now classifies from lat/lon, not the stale foreign flag
The `foreign` flag only reflects nodes whose ADVERT was decoded and classified AFTER geo_filter was configured. On stg.meshview.dk this made the just-shipped filter nearly useless: only 27 of 1503 nodes were flagged foreign, while 570 actually have GPS placing them outside the configured box (543 of those simply never got re-flagged). Exposes the geo_filter box/polygon via /api/config/client (new GeoFilter field on ClientConfigResponse), and adds nodePassesGeoFilter + helpers to app.js — a faithful JS port of internal/geofilter's PassesFilter/PointInPolygon/DistToSegmentKm, cross-checked line-for-line against the real Go implementation for bbox, polygon, and buffer cases. Nodes with no GPS fix (or (0,0)) count as domestic, matching both the Go semantics and the user's explicit choice for this feature. nodes.js's Domestic/Foreign filter-group (added in f99df27) now calls nodePassesGeoFilter(n.lat, n.lon, window.MC_GEO_FILTER) instead of reading n.foreign. |
||
|
|
06554ba172 |
feat: Domestic/Foreign filter on the Nodes page
The unfiltered node list is dominated by foreign-flagged nodes (#730 geo_filter classification) on deployments with heavy cross-border traffic — hard to see which of 1500+ listed nodes are actually local. Adds an All/Domestic/Foreign filter-group next to the existing Status filter, narrowing on each node's `foreign` boolean, persisted to localStorage like the other Nodes-page filters. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
fc26fb6b3a |
feat(#1751): show transported region scopes in repeater sidebar (#1752)
Closes #1751. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
89eade6e7b |
M6: emoji → Phosphor — final sweep, lint gate, carry-forwards (#1648) (#1654)
Red commit:
|
||
|
|
3062745437 |
M2: emoji → Phosphor Icons — page headers & table chrome (#1648) (#1650)
Red commit:
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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>
|
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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) |
||
|
|
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> |
||
|
|
467b01a1b3 |
fix(#1285): exclude RTC-reset outliers from clock-skew hash median + recent bad count (#1288)
Red commit:
|
||
|
|
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> |
||
|
|
89d644dd72 |
fix(#1056): row-detail slide-over panel at narrow widths (AC #4) (#1168)
Red commit:
|
||
|
|
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> |
||
|
|
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> |
||
|
|
844536a4cc |
fix(#1150): render error state on full-page node detail when API 404s (#1158)
Red commit: |
||
|
|
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> |
||
|
|
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> |
||
|
|
45f30fcadc |
feat(repeater): liveness detection — distinguish actively relaying from advert-only (#662) (#1073)
## Summary Implements repeater liveness detection per #662 — distinguishes a repeater that is **actively relaying traffic** from one that is **alive but idle** (only sending its own adverts). ## Approach The backend already maintains a `byPathHop` index keyed by lowercase hop/pubkey for every transmission. Decode-window writes also key it by **resolved pubkey** for relay hops. We just weren't surfacing it. `GetRepeaterRelayInfo(pubkey, windowHours)`: - Reads `byPathHop[pubkey]`. - Skips packets whose `payload_type == 4` (advert) — a self-advert proves liveness, not relaying. - Returns the most recent `FirstSeen` as `lastRelayed`, plus `relayActive` (within window) and the `windowHours` actually used. ## Three states (per issue) | State | Indicator | Condition | |---|---|---| | 🟢 Relaying | green | `last_relayed` within `relayActiveHours` | | 🟡 Alive (idle) | yellow | repeater is in the DB but `relay_active=false` (no recent path-hop appearance, or none ever) | | ⚪ Stale | existing | falls out of the existing `getNodeStatus` logic | ## API - `GET /api/nodes` — repeater/room rows now include `last_relayed` (omitted if never observed) and `relay_active`. - `GET /api/nodes/{pubkey}` — same fields plus `relay_window_hours`. ## Config New optional field under `healthThresholds`: ```json "healthThresholds": { ..., "relayActiveHours": 24 } ``` Default 24h. Documented in `config.example.json`. ## Frontend Node detail page gains a **Last Relayed** row for repeaters/rooms with the 🟢/🟡 state badge. Tooltip explains the distinction from "Last Heard". ## TDD - **Red commit** `4445f91`: `repeater_liveness_test.go` + stub `GetRepeaterRelayInfo` returning zero. Active and Stale tests fail on assertion (LastRelayed empty / mismatched). Idle and IgnoresAdverts already match the desired behavior under the stub. Compiles, runs, fails on assertions — not on imports. - **Green commit** `5fcfb57`: Implementation. All four tests pass. Full `cmd/server` suite green (~22s). ## Performance `O(N)` over `byPathHop[pubkey]` per call. The index is bounded by store eviction; a single repeater has at most a few hundred entries on real data. The `/api/nodes` loop adds one map read + scan per repeater row — negligible against the existing enrichment work. ## Limitations (per issue body) 1. Observer coverage gaps — if no observer hears a repeater's relay, it'll show as idle even when actively relaying. This is inherent to passive observation. 2. Low-traffic networks — a repeater in a quiet area legitimately shows idle. The 🟡 indicator copy makes that explicit ("alive (idle)"). 3. Hash collisions are mitigated by the existing `resolveWithContext` path before pubkeys land in `byPathHop`. Fixes #662 --------- Co-authored-by: clawbot <bot@corescope.local> |
||
|
|
8b924cd217 |
feat(ui): encode view & filter state in URL hash (#749) (#1072)
## Summary Encodes view + filter state in the URL hash so deep links restore the exact page state (issue #749). ## Changes New shared helper `public/url-state.js` exposing `URLState`: - `parseSort('col:asc')` → `{column, direction}` (defaults to `desc`) - `serializeSort('col', 'desc')` → `'col'` (omits default direction) - `parseHash('#/nodes/abc?tab=x')` → `{route: 'nodes/abc', params: {tab:'x'}}` - `buildHash(route, params)` and `updateHashParams(updates, currentHash)` for round-tripping while preserving subpaths. Wired into: - **packets.js** — sort column/direction now in `#/packets?sort=col[:asc]`, restored on init (overrides localStorage). Subpath `#/packets/<hash>` preserved. - **nodes.js** — sort encoded as `#/nodes?sort=col[:asc]`, restored on init. Subpath `#/nodes/<pubkey>` preserved. - **analytics.js** — both selected tab (`tab=topology`) AND time-window picker value (`window=7d`) now round-trip via URL. Subview keys used by rf-health (`range/observer/from/to`) cleared when switching tabs to keep URLs clean. Existing deep links (`#/nodes/<pubkey>`, `#/packets/<hash>`, `?filter=…`, `?node=…`, `?observer=…`, `?channel=…`, `?timeWindow=…`, `?region=…`) all keep working — additive change only. ## Tests TDD red→green: - Red: `5e1482e` (stub throws "not implemented"; 18/18 tests fail on assertions) - Green: `512940e` (helper implemented; 18/18 pass) Wired `test-url-state.js` into `test-all.sh`. Fixes #749 --------- Co-authored-by: clawbot <clawbot@users.noreply.github.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
04c8558768 |
fix(spa): data-loaded setAttribute in finally so it fires on errors (#959)
## Bug PR #958 added `data-loaded="true"` attributes for E2E sync, but placed the `setAttribute` call inside the `try` block of `loadNodes()` / `loadPackets()` / `loadNodes()` (map). When the API call failed (e.g. `/api/observers` returns 500, or any other exception), the `catch` swallowed the error and `setAttribute` was never reached. E2E tests then waited 15s for `[data-loaded="true"]` and timed out. This blocked PR #954 CI repeatedly with `Map page loads with markers: page.waitForSelector: Timeout 15000ms exceeded`. ## Fix Move `setAttribute('data-loaded', 'true')` to a `finally` block in all three handlers (`map.js`, `nodes.js`, `packets.js`). The attribute now fires on both success and error paths, so E2E tests proceed (test still asserts on the actual rendered state — markers, rows, etc — so an empty page still fails the right assertion, just much faster). Removed the duplicate setAttribute calls inside the try blocks (the finally is the single source of truth now). ## Verification - `node test-packets.js` 82/82 ✅ - `node test-hash-color.js` 32/32 ✅ - Code reading: each `finally` runs after either success or catch, sets the same attribute on the same container element. ## Why CI didn't catch this on #958 The PR #958 tests passed because the staging fixture happened to load successfully when those tests ran. The flake only manifests when an upstream fetch fails (e.g. observer API returning unexpected shape, network blip, server still warming). Co-authored-by: Kpa-clawbot <bot@example.invalid> |
||
|
|
053aef1994 |
fix(spa): decouple navigate() from theme fetch + add data-loaded sync attributes (#955) (#958)
## Summary Fixes the chained async init race identified in RCA #3 of #955. `navigate()` (which dispatches page handlers and fetches data) was gated behind `/api/config/theme` resolving via `.finally()`. Tests use `waitUntil: 'domcontentloaded'` which returns BEFORE theme fetch resolves, creating a race condition where 3+ serial network requests must complete before any DOM rows appear. ## Changes ### Decouple navigate() from theme fetch (public/app.js) - Move `navigate()` call out of the theme fetch `.finally()` block - Call it immediately on DOMContentLoaded — theme is purely cosmetic and applies in parallel ### Add data-loaded sync attributes (public/nodes.js, map.js, packets.js) - Set `data-loaded="true"` on the container element after each page's data fetch resolves and DOM renders - Nodes: set on `#nodesLeft` after `loadNodes()` renders rows - Map: set on `#leaflet-map` after `renderMarkers()` completes - Packets: set on `#pktLeft` after `loadPackets()` renders rows ### Update E2E tests (test-e2e-playwright.js) - Add `await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 })` before row/marker assertions - Increase map marker timeout from 3s to 8s as additional safety margin - Tests now synchronize on data readiness rather than racing DOM appearance ## Verification - Spun up local server on port 13586 with e2e-fixture.db - Confirmed navigate() is called immediately (not gated on theme) - Confirmed data-loaded attributes are present in served JS - API returns data correctly (2 nodes from fixture) Closes #955 (RCA #3) Co-authored-by: you <you@example.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |