Commit Graph
1087 Commits
Author SHA1 Message Date
efitenandClaude Opus 5.5 8933e2c703 fix(channels): keep client-only state across a channel-list refresh (#2095) (#2096)
Fixes #2095.

`loadChannels()` replaced the `channels` array with the server snapshot
and carried nothing across, and `mergeUserChannels()` only ever ran from
`init()`. So any refresh destroyed every field that exists only in the
tab.

**No race needed for the worst of it.** Change the region filter or
toggle show-encrypted with a PSK channel open, and:

- the **My Channels** section disappears — `renderChannelList()` derives
it as `channels.filter(c => c.userAdded === true)`, and the server
returns neither `userAdded` nor a `user:`-prefixed hash
- every unread badge resets to 0
- the user's own labels vanish
- `reconcileSelectionAfterChannelRefresh()` cannot find the `user:*`
hash in the snapshot, so it nulls `selectedHash`, sets `messages = []`,
rewrites the URL to `#/channels` and replaces the open conversation with
"Choose a channel from the sidebar"

## What this does

`mergeClientChannelState(fresh, prev)` mirrors
`mergeWsAppendedIntoRest()`, which already does exactly this job for
`messages` (#1498). Same shape: pure, takes both arrays as parameters,
returns a fresh array, never aliases or mutates an input.

It carries `unread`, `userAdded` and `userLabel` by hash, and keeps
`lastActivityMs` / `lastSender` / `lastMessage` / `messageCount` when a
WebSocket batch landed while the request was in flight and is therefore
newer than the snapshot. Those four move together: a sender without its
message reads as a different message.

`mergeUserChannels()` now runs inside `loadChannels()`, before the
render and before the reconcile, so all three call sites get it instead
of `init()` alone.

## What it deliberately does not do

**It does not resurrect a channel the snapshot left out.** A
region-filter change legitimately narrows the list, so carrying
survivors over would defeat the filter, which is a worse bug than the
one being fixed. The helper only enriches rows already present in the
fresh snapshot.

That leaves half of finding 2 in the issue unfixed: a channel pushed by
the WebSocket handler during the initial in-flight window is still
dropped. That one self-heals on the channel's next packet, and the
reverted preview line is overwritten by the next WS batch. Fixing it
properly needs a way to tell "dropped because the snapshot is stale"
from "dropped because the filter excludes it", which is a larger change
than this.

## Tests

`tests/unit/test-issue-2095-channels-client-state.js`, 11 cases,
registered in `test-all.sh`.

Part 1 exercises the helper directly. Part 2 drives the real
`loadChannels()` through the existing `_channelsLoadChannelsForTest`
hook with a stubbed `api()`, which is what proves the helper is wired in
rather than merely defined.

**Verified by mutation.** With the wiring removed from `loadChannels()`
but the helper left in place, the three reproduction cases fail with
exactly the reported symptoms:

```
FAIL  a refresh keeps the My Channels rows
      My Channels lost on refresh (got ["public1"])
FAIL  a refresh keeps unread badges
      the unread badge reset to 0 on refresh   undefined !== 7
FAIL  a refresh does not close an open PSK conversation
      the open PSK channel was deselected      + null  - 'user:MyPSK'
```

The fourth case, "a refresh still drops a channel the server filtered
out", stays green throughout, so the fix cannot be defeating the region
filter.

Full frontend suite green: `sh test-all.sh` exits 0.

## Not done

**No browser validation.** This is frontend JS covered by unit tests
that call the production function through its own hook, but I did not
run Playwright against a server, so I am not claiming a browser check I
did not do.

`init()` still calls `mergeUserChannels()` and `renderChannelList()`
after `loadChannels()` resolves, which is now redundant and costs one
extra full sidebar render per page load. Left alone deliberately: it is
idempotent, and the comment there records the regression it was added to
fix (`test-channel-issue-1111-e2e.js`, case 2). Worth removing
separately with that e2e test watched.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:55:39 +02:00
n30nex 3b415fb007 fix(live): enable node filtering after its handlers are ready (#2103)
Red commit: `8974616` ([CI assertion
failures](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37064461392)).

Fixes #2094.

The Live node-filter input now starts natively disabled and becomes
editable after its input, keyboard and blur handlers attach. Previously
it accepted text while initial node loading was pending, then never
processed that text without another keystroke.

The change adds one HTML attribute and one initialization assignment.
Failed initial loading still reaches the existing error handler and
installs the controls. Saved filters, URL precedence, autocomplete and
keyboard selection keep their existing behavior. No new settings,
styling, dependencies, requests or packet-processing work.

## Validation

- The existing CI-wired Live browser suite delays or aborts the real
initial node request. It verifies rejected premature typing, later
editability, actual suggestions, keyboard selection and URL state. Both
new cases failed on the red commit; all six cases pass locally on
`11fab46`.
- All 183 standalone frontend suites passed, plus lint and XSS gates.
Separate browser checks passed saved-filter restoration and URL
override; desktop/mobile loading/ready screenshots were inspected.
- A broader local core run exposed the unchanged stale
observation-handle test tracked in #2104. It is outside this Live fix.
Full
[CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37067337557)
passed at `11fab46`, including Go, browsers and both container
architectures; three independent reviews are clear.

E2E assertion added: `tests/e2e/test-1110-live-filter.js:69`.

Preflight overrides: external script/OpenClaw profile unavailable;
repository checks and local Chromium used.
2026-10-03 12:18:33 +02:00
n30nex b9fb3d244c feat(analytics): split adverts using recorded route evidence (#2088)
Red commits: `dc7dcbe`, `76e3217` ([assertion
failures](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37059977385)).

Fixes #2041; supplies #2085's backend contract.

Records at most two route bits per retained advert. Node flood counts
now use the same evidence, including mixed and transport-flood adverts.
Analytics failures log/count errors while core observation, path and
liveness writes continue. The unrelated recent-advert limit clamp is
removed.

Direct adverts (empty path) describes the observed remaining path across
valid hash widths. Firmware can forward an advert down to an empty path;
this cannot establish original send mode or RF distance. The `zero_hop`
API spelling remains compatible.

Successful post-upgrade evidence writes survive observation replacement
and restart. Older overwritten frames and failed writes can leave gaps.
The UI explains automatic background backfill.

## Measured validation

Synthetic Windows workloads; timings are not production guarantees:

- 300K retained adverts, one-hour window: median 64.35→39.84 ms; 13.98
MB→145 KB allocated. Seven-day allocations unchanged; five paired
samples.
- 1M transmissions/11M observations: backfill 5m43s. Simple 10 Hz
writer: all 3,423 observations persisted; p99 28 ms, maximum 323 ms.
- Simulated pre-backfill cursor: retained evidence ready 4m21s; full
catch-up 6m41s, 122 cache invalidations. Normal completed-migration
startup bulk-loads current evidence.

Portable opt-in scale tests and window benchmarks are included. All 183
frontend suites, full server tests, targeted races, lint and real-API
desktop/mobile checks passed. Full [Linux
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37063210229)
passed at `36def9c`; three independent reviews are clear. Local Windows
testing hit the unchanged symlink-privilege test. No configuration
changes.

Preflight overrides: external script/OpenClaw profile unavailable;
repository checks and Chromium used.
2026-10-03 12:16:55 +02:00
Sylvain Rabot a4a447acea fix(packets): pack short columns, one expand arrow, Full Names toggle (#2090)
The packets table wastes most of its width on short columns, draws two
arrows on grouped rows, and cuts every observer and hop name short. This
fixes all three and adds a way to read the full path when it does not
fit.

## Why the columns were wide

`makeColumnsResizable` measured each column once on load and stored the
widths as **percentages** of the table. Percentages grow with the
screen: on a 2000px display "17s ago" sat in a 180px column. The
measurement also read the virtual-scroll spacer row — a single `colspan`
cell as wide as the table — as column 0, so the expand column came out
160px wide locally and about 950px on a live instance with grouped rows.

Measured on the fixture at 2000px:

| Column | Before | After |
|---|---|---|
| Expand | 162–230px | 32px |
| Time | 111px | 67px |
| Size | 64px | 46px |
| HB | 44px | 29px |
| Scope | 80px | 48px |
| Path / Details | ~180px each | ~650px each |

## The change

**Column packing.** A new `fitColumnsToContent` (`app.js`) sizes every
column except Path and Details to its widest cell, in px; those two
split what is left. Every visible column gets an explicit width and the
table gets their sum: a spacer row keeps a slot for each hidden column,
and leaving any column `auto` hands those slots a share of the slack as
a blank strip at the right edge.

When space is short (detail panel open, narrow window) the widest fixed
columns give way first, then Path and Details shrink to 60px, and only
then does the wrapper scroll. The split itself is a pure function,
`distributeColumnWidths`, with unit tests.

It re-lays out when the container resizes (detail panel), when
TableResponsive hides or reveals columns (a new `table-columns-changed`
event), and after sorting or a column toggle. After each render `grow()`
widens columns for the rows just inserted and never narrows them, so
nothing jitters while scrolling. Drag handles store px; double-clicking
one resets it. Path and Details have no handle.

`makeColumnsResizable`, still used by the nodes, observers and analytics
tables, now skips `colspan` rows when measuring; other rows are measured
as before.

**One expand arrow.** A CSS `::before` "▶" sat next to the SVG caret,
which pointed up when collapsed. The glyph is gone and the caret points
right, then down.

**Full Names.** A toolbar toggle next to Hex Paths shows observer and
hop names untruncated. It is saved per browser; a link can carry
`fullNames=1`, which applies to that page without overwriting the
visitor's saved preference. Paths stay on one line: the virtual scroller
positions rows at one fixed height, so wrapping would make scrolling
jump.

**The "+N" path pill, and a full-path popover.** Hops past the column
edge were meant to go behind a "+N" pill (#1124), but the pill was
appended after the overflowing chips and so sat past the clipped edge:
it never showed. It is now sticky at the right edge, opaque on hover,
and counts hop chips only (it used to count warning icons too). Hovering
or focusing it shows the full path, one hop per line and numbered;
click, Enter or Space pins it until an outside click or Escape, and it
closes when the page changes or its row scrolls away. Clicking the pill
no longer selects the row underneath.

**Two related fixes.**
- Rows drawn before `/api/observers` resolves show raw 64-char pubkeys.
`loadObservers` now re-renders and re-measures, instead of leaving
Observer at the pubkey's width (~600px under Full Names).
- Group child rows indented every cell by 20px, which misaligned them
with the headers and widened every column on expand. Only the Time cell
is indented now.

Anyone who had dragged packet columns gets the new defaults once: the
old percentage key is dropped.

## Performance

`grow()` runs after every `renderVisibleRows`, so it is on the hot path.
Measured in Chromium on the fixture, 300 iterations, three runs:

| Case | Cost |
|---|---|
| Full render, 63 rows measured | +0.88–0.93ms over the forced reflow
alone |
| Scroll step (incremental render) | ~73 `Range` rect reads, only the
inserted rows |
| Large jump / re-render | ~569 rect reads |

Before narrowing `grow()` to inserted rows, one scroll step with 92
rendered rows made 5,472 rect reads. The DOM row count is bounded by the
virtual-scroll window, so the cost does not grow with the packet count.
`distributeColumnWidths` is a binary search over at most ~10 columns.

## Tests

- `tests/e2e/test-packets-compact-columns-e2e.js`, 16 cases: short
columns stay under fixed px bounds at 1920px; Path and Details take the
freed width; hiding both still fills the wrapper; no blank strip and no
horizontal scroll, with and without the detail panel, at 1920, 1100,
1000 and 375px; responsive reveal and re-hide refit; stale percentage
widths dropped; drag follows the pointer and double-click resets;
expanding a group leaves widths alone; Full Names untruncates, keeps
paths on one line, persists and round-trips through the URL; the
Observer column shrinks back when observer names arrive late; the pill
popover lists every hop vertically, closes when the pointer leaves, pins
on click without selecting the row, closes on Escape and on navigation,
and opens on keyboard focus. Registered in `scripts/non-unit-tests.json`
and `deploy.yml`.
- On `master` the suite fails where it should, and restoring each fix's
old behaviour (child-row indent, the late-observer refit) turns its case
red.
- `tests/unit/test-packets.js`: `distributeColumnWidths` (room to spare,
rounding, widest-first capping, floors, dragged columns, the 60px
minimum, overflow), the single chevron and no `::before` glyph, Full
Names on flat and grouped observer cells, and the Full Names CSS.
- `tests/unit/test-xss-escape-sinks.js` now builds the real
`obsCellName` from source: escaping moved into it. Removing its
`escapeHtml` turns both observer-cell tests red.
- `test-issue-1189-composed-cell.js` and
`test-issue-2012-clear-filters-selection.js` evaluate packets.js
fragments with injected helpers and needed `obsCellName` /
`showFullNames` added.
- Existing packets E2E suites pass on this branch and on master against
a CI-prepared fixture: scope column, #1128 (layout and multi-viewport),
#1122, #1188, #1648, table sort, #1692, #1486.
- Unit: 182 of 183 suites pass locally;
`test-issue-1956-release-routing.js` could not create a temp directory
in my sandbox and is unrelated.

## Browser validation

Checked in Chromium against the fixture at 2000, 1920, 1280 (panel open
and closed), 1100 and 375px, and with the seeded grouped row expanded
and collapsed. Screenshots taken at each step; the table fills its
container exactly, grouped rows show one arrow, and the pill popover
lists full names.

## Not done, deliberately

- **No width cap on Observer under Full Names.** A cap brings back the
truncation the toggle removes, and Observer is already the first column
to give way when space is short.
- **Customizer.** The floors (60px flex minimum, 64px shrink floor, 32px
expand, 58px Rpt) are hardcoded, per AGENTS rule 8; exposing them in the
customizer can follow.
- **Wrapping paths.** Rejected because of the fixed row height above;
the pill popover is the way to read a long path.
2026-10-03 12:15:52 +02:00
n30nex 093e320c2b fix(map): keep Path Inspector candidates accessible and cover route replacement (#2082)
## Fix

Fixes #2060. Fixes #2081.

Path Inspector route drawing and candidate replacement now have
deterministic browser coverage. An idempotent SQL seed adds four
synthetic repeaters and four edges after fixture migration, without
changing packet ordering. The test uses the real API, normal clicks,
visible Leaflet geometry, and removal of every previous route object.

Restoring coverage exposed a desktop layout bug: drawing the first route
moved the map over the next candidate button. The route sidebar now
participates in the existing flex layout, including resizing and
collapse, while preserving the mobile bottom sheet.

The historical zero-candidate result did not reproduce on the current
baseline. No search thresholds or API behavior changed. No new
dependencies or customizer settings.

## Validation

- Local assertion-red history: `042db2b` requires seeded candidates;
`ca1d22a` exposes the blocked second click. Subsequent commits repair
layout and narrow-window behavior.
- All 183 standalone suites passed. Unchanged-base Go failures: #2083
readiness race (fixed in #2084) and Windows symlink privilege.
- Real Chromium: 11 Path Inspector/layout checks, 33 related map checks,
and core E2E (131 passed, 3 existing fixture skips).
- CSS-variable checks, XSS diff check, inventory, whitespace and PII
checks passed. Seed remains valid after periodic graph refresh.

Local browser validation used Chromium with a 60-second navigation
budget; the OpenClaw profile and external preflight script were
unavailable. Repository checks ran directly. [CI run
36343343569](https://github.com/Kpa-clawbot/CoreScope/actions/runs/36343343569)
records the initial assertion-red commit. Final CI must pass before
merge.
2026-09-30 11:40:49 +02:00
n30nex ded27a8312 fix(packets): stabilize fixture tests and preserve mobile empty states (#2080)
Fixes #2054 and #2079.

Packet-row tests could outlive the fixture's default 15-minute window.
An audit of 115 wired suites identified four needing explicit fixture
windows: 180 minutes on mobile, 1440 on desktop. Mobile rejects larger
saved windows, so a desktop-only preference was insufficient.
Packet-icon checks now require a real path-bearing packet and both
detail controls.

Default-window coverage exposed #2079: responsive column hiding also hid
the spanning empty-message cell. Skip structural single-cell spanning
rows when hiding individual columns, preserving status messages and
virtual-scroll spacer height. Ordinary and partial-span data cells
retain their behavior; application defaults stay unchanged.

- Red evidence: `bae5e78` fails the aged-fixture row assertion;
`9b75cf6` fails spanning-row visibility. Guards/setup make them pass.
- Validation: 183 standalone suites; 82 targeted browser cases on a
fixture aged over 30 minutes (one pre-existing node-gesture skip).
Broader browser run: 131 passed, 3 fixture-dependent skips.
- Browser verified: fixture-backed Go server and Chromium;
`coverage/2079-mobile-empty.png`. Real mobile scrolling retains spacer
height and has no horizontal overflow.
- E2E assertions: `test-observer-iata-1188-e2e.js` default-window cases
and `test-table-fluid-e2e.js:58` shared-helper regression, both under
`tests/e2e/`.
- Three independent reviews completed; packet-icon coverage feedback
addressed in `760057d`.
- Populated mobile axe coverage remains part of #1995.
- One constant-time guard per already-visited row; no new requests or
collections.

## Preflight overrides

- External `run-all.sh` unavailable; repository checks run directly.
Local runs use UTF-8, the release harness's `GITHUB_REF_NAME`, and a 60s
navigation budget; repository timeouts are unchanged.
2026-09-30 11:40:36 +02:00
n30nex 410c82c02c fix(ui): use consistent 48px navigation and channel buttons (#2078)
Fixes #2052.

Navigation (`.nav-btn`) and channel icon (`.ch-icon-btn`) buttons now
use the shared 48px house minimum. Remove competing 44px
component/coarse-pointer rules and the legacy 32px mobile rule, and
correct comments that confused the house preference with accessibility
requirements.

At 768px, user-added channel rows already clipped Remove; the larger
Share target exposed the same constraint. Allow only those rows'
controls to wrap so both actions remain within the sidebar. Navigation
height and normal network-channel rows are unchanged.

- Red `7787d9b`: rendered target assertions fail at 375/390/768/1280px.
Green `e6ed9c8` changes CSS only.
- Validation: all 183 standalone suites; 33 computed touch-target
checks; 15 actual-page layout/click checks. Broader browser suite: 131
passed, 3 fixture-dependent skips. Three independent reviews found no
required changes.
- Browser verified: local Chromium and fixture-backed Go server;
screenshots `coverage/2052-targets-375.png` and
`coverage/2052-targets-768.png`.
- E2E assertion added: `tests/e2e/test-channel-fluid-e2e.js:112`,
covering dimensions, containment, overflow and normal Filter/Share
clicks. Existing CI selection is retained; Chromium-required runs cannot
silently skip the touch suite.
- Test-only follow-up `5144503` waits for packet readiness before
measuring navbar controls. Five fresh mobile contexts passed; size and
click assertions remain intact.
- No new settings, requests, dependencies or runtime JavaScript.

## Preflight overrides

- External `run-all.sh` unavailable; repository checks run directly.
- Local unit runner uses UTF-8 and `GITHUB_REF_NAME=local-validation`
for the existing release harness. Browser navigation uses a 60s local
budget for slow assets; repository assertions/timeouts are unchanged.
2026-09-30 11:40:22 +02:00
n30nex f1edbbef3f fix(packets): preserve observation selection in detail URLs (#2093)
Red commit: `c165087` ([CI assertion
failure](https://github.com/Kpa-clawbot/CoreScope/actions/runs/36644030012)).

Fixes #2091.

Packet detail links now retain the selected observation when page
initialization or filter changes rebuild the URL. Initialization also
restores `obs` from the complete hash after the router strips its query.
Explicit ID links render the requested observation and retain it in Copy
Link state.

The shared updater reads selection from the current route, so returning
to the list or selecting another packet cannot resurrect an old
observation. Existing filter serialization and Clear Filters behavior
remain intact. No new requests, configuration, dependencies or layout
changes.

## Validation

- Real fixture browser checks use nondefault observation 502: hash/ID
load, type/observer/time-window changes, refresh, Clear, and another
refresh. Both URL and selected row are asserted.
- All 183 standalone frontend suites passed; focused filter browser
suite 11/11.
- Broader local core run reached the unrelated Live input readiness bug
#2094, reproduced on unchanged master. The complete CI browser suite
passed.
- ESLint 8: zero errors; 91 existing warnings. XSS diff, syntax and
whitespace checks passed.
- Three independent reviews found no production defects; assertions
additionally prove filter controls changed before checking selection.

E2E assertion added: `tests/e2e/test-filter-ux-e2e.js:179`.

OpenClaw profile/external preflight were unavailable; Chromium and
repository checks ran directly. Local navigation used a 60-second
budget. Final CI passed at `280ced5`, including Go, browsers, coverage
and both container architectures
([run](https://github.com/Kpa-clawbot/CoreScope/actions/runs/36646175352)).
2026-09-30 11:40:00 +02:00
n30nex dc4db17c48 fix(nodes): remove unsupported region fields from Heard By (#2077)
Fixes #2062.

The node health API does not emit observer region data, but Heard By
rendered a Region column containing only dashes and a Regions summary
that could never appear. Remove that column, its sort control, and the
unsupported region displays from the full node page and side panel.

Keep Observer, Packets, Avg SNR and Avg RSSI, along with existing
escaping, signal placeholders, relay counts and badges. No API,
dependency or configuration changes.

- Red commit `4396492` fails on the unwanted Region header and summary;
`a360711` removes the unsupported fields.
- Validation: 183 standalone suites and 7 focused browser checks passed.
Broader browser run: 131 passed, 3 fixture-dependent skips. Independent
reviews completed; the sorting-test finding was addressed in `1452981`.
- Unit coverage evaluates the real templates. The existing CI-selected
browser suite checks four-column alignment and sorting on desktop/mobile
using the actual API field names.
- Browser verified: local Chromium against a fixture-backed Go server;
screenshots recorded in `coverage/issue-2062-heard-by-1400.png` and
`coverage/issue-2062-heard-by-390.png`.
- E2E assertion added:
`tests/e2e/test-issue-1151-orphan-separators-e2e.js:125`, the two #2062
desktop/mobile cases.
- No added requests, loops or data structures.

## Preflight overrides

- External `run-all.sh` unavailable; repository syntax, whitespace,
CSS-variable, XSS and PII checks run directly.
- Local unit execution supplies UTF-8 settings and
`GITHUB_REF_NAME=local-validation`, required by the existing
release-routing test harness.


- Local browser navigation budget raised to 60s for slow local asset
responses; repository assertions and timeout settings unchanged.
2026-09-30 11:39:51 +02:00
efitenandClaude Opus 5 3caa847323 fix(nodes): the advert section is called "Recent Adverts", and says why (#2071)
Closes #2042, reported by @damn-simple-scripts.

## Which of the two fixes

The report offered widening the section to all packets the node
originated, or renaming it, and proposed the rename. Widening sounds
like the better fix, so I measured before agreeing. On a production
database:

| | |
|---|---|
| transmissions total | 1,251,967 |
| with `from_pubkey` populated | 191,237 |
| of those, `payload_type = 4` (ADVERT) | **191,237** |
| with `from_pubkey` and **not** an advert | **0** |

The ingestor fills that column for adverts only. Attributing a relayed
CHAN or TXT packet back to its sender is the path-resolution problem,
not a filter this section could apply — so "show all packets from this
node" is not a small change, it is a different feature resting on
attribution the data does not carry.

So the rename is correct, and the numbers say so rather than my
preference.

## The change

Both copies renamed — the full node page and the side pane. Both already
read `nodeData.recentAdverts`, so the field feeding them said "adverts"
while the heading said "packets".

The heading also gained a `title` naming `from_pubkey` as the reason it
is adverts only. Renaming without explaining invites the same report
from the next reader; the tooltip is where that explanation costs
nothing.

## Tests

`tests/unit/test-issue-2042-recent-adverts-label.js`, four cases: both
copies present and headed "Recent Adverts", no copy back to "Recent
Packets", the advert field still feeding them, and the explanation still
in place. The third matters most — it ties the label to its data source,
so pointing this section at a different field in future fails here
rather than silently making the label wrong again.

## Not done, deliberately

The reporter's follow-up idea, splitting flood adverts from zero-hop
adverts into two lists. They wrote that it can be dropped if an issue
should tackle one thing, so it is not here. It is feasible now: a
zero-hop advert is `ROUTE_TYPE_DIRECT` with an empty path, established
while fixing #2064. That deserves its own issue rather than a paragraph
in this one — say the word and I will open it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 00:08:28 +02:00
efitenandClaude Opus 5 2cfe9cbbc5 fix(a11y): raise the Scope Audit observed-chip contrast above 4.5:1 (#2070)
Closes #1996.

## Reproduced first

The chips tint their own background — `color-mix(in srgb,
var(--status-green) 16%, transparent)` — which darkens whatever surface
sits behind them. With `--status-green-text` (green-700, `#15803d`) on
top:

| surface | composited chip | ratio |
|---|---|---|
| `--surface-0` `#f4f5f7` | `#d2eddf` | **4.04:1** |
| `--card-bg` `#ffffff` | `#dcf6e5` | **4.38:1** |

Both under the 4.5:1 the #1719 gate requires for normal text. The first
row is the same ratio **and the same hex** @n30nex measured in the
browser, which is how I know the model in the test agrees with what a
visitor actually sees.

## Fixed on the text, not by thinning the tint

Dropping the tint to 12% reaches 4.52:1. That is two hundredths above
the line, and a margin that thin fails again the next time a surface
value moves — the same trap #2039 hit with a threshold sitting inside
the healthy band. One palette step darker gives **6.23:1** on white and
**5.74:1** on `--surface-0`, and keeps the tint that makes a chip read
as a chip rather than as plain text.

- `--palette-green-800: #166534` added. The greens ran 300–700 while the
blues already reach 900, so this fills the scale rather than inventing a
colour.
- `--sa-chip-observed-fg` defined per theme: green-800 in light, and the
bright `#22c55e` **kept** in dark, where the chip already passed at
6.48:1 / 5.73:1. Dark is deliberately untouched.
- The chip reads the variable, so the customizer still governs it and no
literal enters a component.

`.sa-chip-verified` needed no change: `scope-audit.js:106` only ever
adds it alongside `sa-chip-observed` or `sa-chip-unobserved`, and it
contributes an underline. So the fix covers both classes the issue names
— worth stating, since the title mentions both.

## Tests

`tests/unit/test-a11y-1996-scope-audit-chips.js`, 7 cases: both themes ×
both surfaces, that the chip still tints (so the suite cannot pass by
testing nothing), that its colour comes from a variable rather than a
literal, and a guard asserting green-700 **would** still fail — so a
quiet revert to `--status-green-text` turns this red instead of passing.

**Red-run confirmed:** with the old colour restored it fails at exactly
4.04:1 and 4.38:1, naming the composited `rgb(210,237,223)`.

Two deliberate choices in the test:

- **A separate suite, not a case in `test-a11y-1719`.** That suite's
`parseColor` handles hex and `rgb()` only; teaching it `color-mix()` is
a larger change than this fix. These chips are the only
contrast-critical user of the function today. If a second appears, the
two should merge, and the file says so.
- **Derived from the stylesheets, not a rendered page.** The default
Scope Audit fixture renders no chips at all, which is precisely why the
existing browser coverage missed this. A stylesheet-derived check cannot
be defeated by a fixture that shows nothing.

`check-css-vars` passes (180 definitions, 0 undefined) and
`test-test-inventory` passes with the new file classified.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 00:07:57 +02:00
435ac25dd6 feat(nav): show the running version in the nav-drawer footer (#2068)
Takes over #1985 by @SaarMesh-Bot, as offered there on 2026-09-16 and
2026-09-17. **Their commit is the first here, unchanged and under their
authorship**; the rest clears the two review points. Closing #1985 in
favour of this so the rebase and the fixes travel together, not to
reassign the work.

## The feature, unchanged

The frontend never surfaced which build was running, though
`/api/health` has reported `{version, commit, buildTime}` all along. A
footer on the nav drawer now renders `CoreScope <version>`, linking to
the releases page, with commit and build time in the tooltip. Colours
come from existing CSS variables, the label is set with `textContent`,
and a failed health call leaves a neutral label rather than an empty
footer — all as the author wrote it.

## Review point 1: fetch on open, not on page load

The `/api/health` call sat in `buildDom()`, which runs on page load. The
drawer may never be opened, and **cannot** be opened at ≤768px, where
the module is disabled by design. So every visitor's browser was
requesting the endpoint to fill a footer most of them would never see.

Moved into `open()`, after the width gate. `fetchVersion` still caches
its promise for the page lifetime, so re-opening costs nothing.

## Review point 2: the fallback is pinned

`tests/unit/test-nav-drawer-version-footer.js`, six cases:

- a rejected fetch, a non-ok response, and a 200 without `version` each
leave the neutral `CoreScope` label — not a blank footer and not
`CoreScope undefined`, which is what an instance shows exactly when
someone is trying to read its version
- a normal response renders the version, with commit and build time in
the tooltip
- a version carrying markup lands verbatim in `textContent`, and
`innerHTML` is never touched
- the endpoint is requested once however often the footer is filled,
pinning the cache from the other side

It **slices `fetchVersion` and `fillVersion` out of the shipped
`public/nav-drawer.js`** and evaluates them rather than copying them
into the test, so it exercises what ships — same approach as
`tests/unit/test-direct-rf-heard-by.js`. Both slice markers are
asserted, so a rename fails loudly instead of quietly testing nothing.
Each case re-evaluates the slice, because `versionPromise` caches for
the page lifetime and a shared sandbox would hand the second case the
first case's answer.

Listed in `test-all.sh`, which per #2036 is the only frontend runner.

## A note on the third commit

The first version of the test used `setImmediate` to settle the promise
queue. It ran fine under node and **failed eslint**, which treats these
as browser code. I ran eslint and committed in the same command and
pushed without reading its output. Fixed in the commit after, using
`setTimeout(r, 0)`. Recording it because the PR would otherwise show a
lint failure in its history with no explanation.

## Verification

6 of 6 in the new suite, eslint clean on both changed files,
`test-test-inventory.js` passes with the new file classified.
Cherry-picked cleanly onto current master.

---------

Co-authored-by: SaarMesh-Bot <bot@saarmesh.de>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-23 10:58:36 +02:00
liquidraverandClaude Opus 5 b695b979a2 fix(nodes): keep paginating past a page that post-LIMIT filtering shortened (#2061)
## Problem

`handleNodes` runs the geo-filter, `nodeBlacklist`, `hiddenNamePrefixes`
and area
passes **after** the SQL `LIMIT/OFFSET`, and rewrites `total` to the
filtered
length. A page that loses a row is therefore short **without being the
last
page**, and neither the page length nor `total` can tell a client
whether to ask
for another page.

#1606 added the pagination loop and chose the page length as the
canonical stop.
That is correct only where nothing is ever filtered. Everywhere else the
list
truncates at the first filtered page boundary and strands every node
behind it —
the #1598 symptom reached by a different route: a node that is relaying
right
now simply stops being in the list.

The comment at `app.js:240` rejects `total` for exactly the right
reason, then
picks the signal the same code path also breaks.

## Measured on a live 2346-node deployment

Page sizes for the query the map issues:

```
offset=0     returned=500     ← full, loop continues
offset=500   returned=499     ← one row filtered AFTER the LIMIT → loop STOPS
offset=1000  returned=500     ← never requested
offset=1500  returned=500     ← never requested
offset=2000  returned=345     ← never requested
```

| stop rule | requests | nodes reached |
|---|---:|---:|
| short page (master) | 2 | **999** |
| `has_more`, else empty page | 6 | **2344** |

**1341 nodes, 57%, unreachable through the UI.**

### One hidden node truncates the whole list

The deployment this came from has no `geoFilter`
(`/api/config/geo-filter`
returns `polygon: null`) and no `nodeBlacklist`. It has a single
`hiddenNamePrefixes` entry — a deliberate operator choice — and exactly
one node
whose name starts with it:

```
public_key   d4a46ea2…1054      (64 clean hex chars)
name         🚫🔥☀️
role         repeater
last_seen    2026-09-22T10:09:38Z
```

`handleNodes` drops that row in the `IsNameHidden` pass, which runs
after the SQL
`LIMIT`. The row is counted by the `LIMIT` and by `COUNT(*)`, so the
page it lands
in comes back exactly one short — and stops every client that treats a
short page
as the end.

Isolated against SQL on the same database, seconds apart:

```
SELECT lower(public_key) FROM nodes ORDER BY last_seen DESC LIMIT 500 OFFSET 500
  -> 500 rows
GET /api/nodes?limit=500&offset=500
  -> 499 rows
comm -23 sql.txt api.txt
  -> d4a46ea2e99cab132a3286ef3d9cce9099318790af7f25671fe83de453721054
```

Deterministic — `offset=500` returned 499 on three consecutive requests.
Not a
CDN artifact either: `cf-cache-status: DYNAMIC`, origin `cache-control:
no-store`, no `age` header, and four requests with deliberately unique
cache keys
all returned 499.

So **one deliberately hidden node makes 1341 of 2344 nodes
unreachable.** The
hiding feature does exactly what it was asked to do for that one node,
and takes
57% of the network with it, silently. A single `hiddenNamePrefixes`
entry is
enough; no geo-filter, blacklist or area filter is needed to reach this
state.

### The cutoff moves, which is why this reads as intermittent

The visible set is the sum of the pages up to and including the first
short one,
so the boundary sits wherever the unreturnable row currently sorts by
`last_seen`, and jumps a whole page as ingest reorders the list. Same
deployment, same code, same config, ~2h apart:

| dropped row's rank | first short page | nodes visible |
|---|---|---:|
| inside 0–499 | page 1 | 499 |
| inside 500–999 | page 2 | 999 |

A node is visible or invisible purely by where it lands relative to that
moving
line, so affected nodes appear to vanish and return on their own. Two
operators
on this deployment reported exactly that, independently, while I was
measuring.

### A named reproduction

`HU-ZA-Lentihegy` (`5287a33f…`), reported missing from the map by an
operator
whose companion had logged its advert at 04:20 local the same morning.

Ingest was fine. The row is in `nodes` with `last_seen`
`2026-09-22T02:20:35Z` — the same advert, to the second — valid GPS,
role
`repeater`, 1033 adverts, and `/api/nodes/search?q=lentihegy` returns
it.

```
rank by last_seen : 1081
cutoff at the time:  999
```

It missed by 82 positions. Walking the same live endpoint, same moment:

| stop rule | requests | nodes reached | Lentihegy |
|---|---:|---:|---|
| short page (master) | 2 | 999 | **not reached** |
| `has_more`, else empty page | 6 | 2340 | reached |

The practical shape of this on a busy mesh: 1081 nodes had been heard
more
recently than 9.4 hours, so on that deployment **anything last heard
more than
~9 hours ago was invisible**, alive or not.

`#/nodes` compounds it — its search box filters client-side over the
truncated
set, so the server-side `?search=` never runs and an operator cannot
find the
node by searching for it either, even though the endpoint would return
it.

## Change

**Server** — `NodeListResponse` gains `has_more`, computed from the raw
SQL page
against the real `COUNT(*)` before the filter passes run, so it survives
them:

```go
hasMore := offset+len(nodes) < total
```

Always emitted (no `omitempty`) so a client can tell `false` from an old
server.
No extra request in the fixed path: `has_more` ends the loop exactly,
where the
old rule needed a probe page.

**Clients** — `app.js` `fetchAllNodes`, `nodes.js` `loadNodes` and
`area-map.html`'s inline helper stop on `has_more`, falling back to a
zero-length
page against a server that predates it. An empty page always ends the
loop, so a
`has_more` against a concurrently-shrinking table cannot spin to
`safetyCap`.

Left alone: the three loops are still three copies. Collapsing them onto
`fetchAllNodes` is a bigger change than this fix needs, and `nodes.js`
has its
own inter-page progress UI. Happy to do it separately if you want it.

## Testing

- **Unit** (`tests/unit/test-fetch-all-nodes-pagination.js`): the
fixture now
models the real handler — a row counted by the LIMIT and by `COUNT(*)`,
then
removed from the page. Three new cases. Fails on the old rule at 499 of
1199.
- **E2E** (`tests/e2e/test-map-nodes-pagination-e2e.js`, already wired
into
  `deploy.yml`): the mock drops a page-1 row and emits `has_more`.
  Mutation-checked — restoring master's stop rule fails 3 of its steps.
- **Go** (`cmd/server/nodes_pagination_has_more_test.go`): asserts
`has_more`
stays true on a page filtering shortened. Mutation-checked — recomputing
it
  after the filter block fails the test.
- Full server suite `go test -race`: ok, 41.4s. `gofmt` clean, `go vet`
passes.
- **Against a real binary**, not just mocks: fixture DB migrated with
`corescope-migrate`, `hiddenNamePrefixes: ["SKCE"]`, `limit=3`. Page 1
returns
2 of 3 with `total` rewritten to 2 and `has_more=true`. Walking the real
server
with master's rule reaches 2 nodes; with `has_more`, all 199 visible of
200,
the hidden one still hidden. The real frontend against that server loads
199
  with no JS errors.

Two existing expectations changed, both deliberate:

1. `surfaces ALL nodes past the 500 server cap` — 3 → 4 requests. That
mock emits
no `has_more`, so the 200-row final page can no longer end the loop (a
short
page is exactly what a filtered page looks like) and a zero-length probe
   follows. Against a current server `has_more` still ends it at 3.
2. `rows missing public_key are NOT collapsed into one` — its stub
returned a
constant body, which would now be paged to `safetyCap`. It serves one
page
   then empties.

Local `test-all.sh` exits 1 on two XSS-gate self-tests
(`good-2-tested.js`, `good-4-tested.js`) via a `UnicodeEncodeError`
printing an
emoji under Windows cp1252. Identical on clean `origin/master` in a
scratch
worktree, so it is pre-existing and platform-local, not this branch.

There is a second identical filter block further down `routes.go` on
another list
endpoint. Likely the same class; not touched here.

If you would rather land your own version of this, say so and I will
close mine.

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:45:18 +02:00
efitenandClaude Opus 5 980c5c4515 fix(nodes): stop the Heard By empty state claiming the node is out of range (#2063)
Follow-up to #2057, which is correct in what it does and overclaims in
one sentence.

## The sentence

When no observer heard the node directly, the card said:

> No observer is within radio range of this node.

#2057's own rule cannot establish that:

- every **direct route** is discarded, because the firmware removes the
sender from the path before retransmitting (`Mesh.cpp`,
`removeSelfFromPath`), so the packet cannot say who transmitted it.
#2057 measured these at **38% of transmissions over 7 days**.
- **28.8% of flood observations with a path** are dropped because the
last hop resolves to more than one candidate, and the rule
under-attributes rather than guesses.

So an empty direct list is missing evidence, not evidence of missing
coverage.

## Why it matters in practice

Sampled 40 repeaters on a production instance after #2057 shipped:

| | |
|---|---|
| at least one direct observer | 24 |
| empty card, "no observer is within radio range" | **16** |
| of those 16, with a non-zero `relayObserverCount` | **16** |

Every node showing "nobody is in radio range" also showed "Seen via
relay by N observers" two lines below. An operator reading that about a
working repeater concludes they have a coverage problem they do not
have.

## The change

Wording only, in both copies of the card (full page and side pane):

> No observation proves a direct reception here, which is not the same
as being out of range.

with the reason in a `title`, so the card stays one line:

> Only flood-routed transmissions identify who was heard: a direct route
removes the sender from the path before retransmitting (firmware
`Mesh.cpp`, `removeSelfFromPath`), and an ambiguous relay hop is left
unattributed rather than guessed. So an empty list is missing evidence,
not proof of missing coverage.

No API change. #2057's rule, shape and performance work are untouched —
I verified its firmware derivation against the clone at `0679dbef`
before writing this: `Packet.h:83`, the forwarder appending with
`packet->getPathHashSize()` at `Mesh.cpp:349`, and `removeSelfFromPath`
on the direct path at `Mesh.cpp:89-105` all read as described.

## Test

`tests/unit/test-direct-rf-heard-by.js` slices this template out of
`public/nodes.js`, so it pins the shipped markup. It now asserts the old
sentence is gone and the qualifier is present.

Worth recording how that assertion was reached: my first version banned
the phrase "out of range" from the card, and it failed — on the new
line, which contains that phrase precisely in order to deny it. A word
ban was the wrong instrument. Matching the old sentence and requiring
the new qualifier is the assertion that actually distinguishes the two
states.

8 of 8 in that suite, eslint clean.

## Not in this PR

The **Regions** line and **Region** column on the same card read
`o.iata`, which `HealthObserverRow` does not emit, so both have always
been dead. #2057 named this and left it; it is now **#2062** with the
file and line references, rather than a remark inside a merged
description.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 23:56:02 +02:00
efitenandClaude Opus 5 d1b615fc0d fix(node-health): list only observers that heard the node on air (#2057)
Closes #2056.

## What changes

The node detail "Heard By" card now lists only observers that received
the node's **own transmission off the air**, and reports the rest as a
count.

```
HEARD BY — DIRECT (8 OBSERVERS)
OBSERVER                REGION  PACKETS  AVG SNR   AVG RSSI
BE-DUF-SiSCD-01         —        16276    7.9 dB   -108 dBm
BE-BRU-Moris  repeater  —        13775   -5.6 dB   -122 dBm
...
Seen via relay by 29 observers. Those observers heard a repeater that
forwarded this node's traffic, not this node.
```

and for a node nothing hears:

```
HEARD BY — DIRECT (0 OBSERVERS)
No observer is within radio range of this node.
Seen via relay by 2 observers. …
```

## The rule, and where it comes from

Read out of the firmware rather than assumed:

| | |
|---|---|
| `Packet.h:83` | `setPathHashSizeAndCount(sz,n) { path_len =
((sz-1)<<6) \| (n&63); }` — hash size rides in the packet's `path_len`
byte |
| `Mesh.cpp:649,678` | only `sendFlood()` sets it, so the **originator**
decides; `CommonCLI.h:69` defaults `path_hash_mode = 0`, i.e. one byte |
| `Mesh.cpp:349` | a forwarding repeater appends its hash with the
packet's size — it cannot upgrade a packet, and the **last hop is who
was heard** |
| `Mesh.cpp:89,103` | on a direct route a forwarder matches the head of
the path and calls `removeSelfFromPath` before retransmitting, so the
path is the **remaining** route and the transmitter is not in it |

So an observation credits exactly one node:

1. Route type must be `ROUTE_TYPE_FLOOD` or
`ROUTE_TYPE_TRANSPORT_FLOOD`. Direct routes never qualify (38% of
transmissions over 7 days).
2. Empty path → the originator, known only for ADVERTs.
3. Otherwise the last hop.
4. The hop must resolve to exactly one candidate. Same gate
`resolvePathForObsColdLoad` already applies: under-attribute rather than
guess. It drops 418,530 of 1,455,721 flood observations with a path over
7 days (28.8%), and it is what stops the wrong-band credits.

## Measured effect

| node | before | after |
|---|---|---|
| BE-BRU-Moris | 36 observers | 3 |
| BE-KRO-RP01 \| ON1KW | 40 | 3 |
| BE-BRE-ON8AR | 38 | 2 |
| NL-BXE-RP01 \| 433 | 35 | 0 |

Network-wide over 7 days, 234 of 1,860 nodes have at least one direct
observer (161 have exactly one, maximum 8). The direct list is therefore
empty for most nodes, with the relay count below it. That is the correct
reading: no observer is in radio range of them.

Independent corroboration on staging: for BE-WIL-3EIK-01 the eight
direct observers are exactly the top eight entries of its Neighbors
table by score and observation count.

## Perf justification

`GetNodeHealth` is fast today precisely because it never walks
observations — it uses one representative observation per transmission.
Direct-RF needs the per-observation path, and that cannot be a
per-request walk: the reference store holds **232,928 transmissions /
2,887,861 observations**, one node's `byNode` slice alone holds **55,458
transmissions / 1,450,544 observations**, and
`/api/nodes/bulk-health?limit=200` would multiply that.

So the aggregate is rebuilt by a background recomputer on the existing
`newAnalyticsRecomputer` pattern, published into an `atomic.Value`.
Reads are `O(direct observers)`, which is **cheaper than before** — the
old code built per-observer sums over every transmission in `byNode` on
every request.

Proof, `BenchmarkBuildDirectHeardIndex`:

```
BenchmarkBuildDirectHeardIndex-12    1    63067900 ns/op
```

3,000,000 observations (60,000 transmissions × 50 observations, 8-hop
paths, 64 candidate repeaters) in **63 ms**, once per recompute
interval.

Per observation the walk does one route-type check, one backward scan of
`PathJSON` for the last quoted token (no allocation, no
`json.Unmarshal`), one prefix-map lookup and one counter update.

Rebuilding wholesale also means eviction needs no bookkeeping: a pass
simply does not see evicted transmissions. The alternative — a field on
`StoreObs` updated incrementally — would have needed the call at five
construction sites (`store.go:942,1264,2854,3179`,
`chunked_load.go:609`), which is the duplication that caused #1558, plus
matching decrements at eviction.

## API

Both `GetNodeHealth` and `GetBulkHealth` carried a near-identical copy
of the observer loop; they now share one builder.

- `observers` — direct-RF only. Same field names, so no client
migration. Rows are a named `HealthObserverRow` instead of
`map[string]interface{}` (one fewer occurrence in a touched file, per
the AGENTS.md ratchet).
- `relayObserverCount` — new integer, observers that saw traffic through
the node without hearing it. `stats.totalPackets` and `stats.avgHops`
still count relayed traffic, so without this number the card would
contradict the figures printed beside it.

`docs/api-spec.md` is updated for both endpoints. It also documented an
`iata` field on these rows that the endpoint has never emitted; removed.

## Tests

- `cmd/server/direct_heard_test.go` — table test over the rule: flood
with empty path and known originator, flood whose last hop is the node,
flood whose last hop is another node, direct and transport-direct routes
(never credit), ambiguous last-hop prefix, listener-only candidate,
1-byte and 2-byte hop sizes; plus aggregation and row-building.
- `cmd/server/node_health_direct_rf_test.go` — end-to-end through the
handler: an observer that only saw relayed traffic must not appear in
`observers` but must be counted in `relayObserverCount`. Plus the
benchmark.
- `tests/unit/test-direct-rf-heard-by.js` — slices the card template out
of `public/nodes.js` and evaluates it, so it tests the shipped markup
rather than a copy: heading, empty state, relay line, singular/plural,
signal columns, listener/repeater badge tri-state.
- `cmd/server/node_health_can_relay_case_1290_test.go` — updated to seed
a genuinely direct reception, since a relay-only observer no longer
carries a badge.
- `cmd/server/analytics_recompute_after_load_test.go` — recomputer count
10 → 11.

Verified locally: `cmd/server` suite green, `sh test-all.sh` green (180
suites), `tests/e2e/test-e2e-playwright.js` 131/134 passed with 3
skipped and 0 failures against the seeded fixture, plus
`test-issue-1147-section-order-e2e.js`,
`test-issue-1151-orphan-separators-e2e.js` and
`test-issue-1281-location-row-e2e.js`, which all assert on this card.
`gofmt` clean, `vet` clean across all modules.

Browser-validated on staging: both the full detail page and the side
pane, on a node with 8 direct observers and on the 433 MHz node with
none. No console errors.

## What this does not do

`prefixMap.resolveWithContext` still guesses on ambiguous hops, so
paths, neighbor edges and analytics keep their current attribution.
Making it abstain is a much larger change and needs its own issue.

The "Regions" line and Region column on this card read `o.iata`, which
this endpoint has never emitted, so both have always been dead. Left as
found rather than widened into this change.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 07:55:37 +02:00
efitenandClaude Opus 5 5c016a210a fix(analytics): show a building state on the distance index's 202, and stop caching it (#2051)
Closes #1997.

## What was wrong

`/api/analytics/distance` answers `202 {status:"building",
retry_after_seconds:5}` with no `summary` until the lazy index (#1011)
has been built.

1. `renderDistanceTab` read `data.summary.totalHops` straight away. The
TypeError is caught by the tab's own try/catch, so it never reaches
`window.onerror`: it is painted into the tab as `Failed to load distance
analytics: Cannot read properties of undefined (reading 'totalHops')`.
2. `api()` caches any `res.ok` body, and `res.ok` is true for 202, so
the placeholder was stored for the `analyticsRF` TTL. Even a correct
retry read the cached "building" body back. That is the half that made
the broken state outlast the index build.

## What changed

- `public/app.js:173` skips the cache write when `res.status === 202`. A
200 still caches, unchanged.
- `public/analytics.js` renders a building notice and retries itself,
honouring `retry_after_seconds` clamped to [1s, 30s]. The timer is
cleared on tab switch and in `destroy()`, and a new render supersedes a
pending retry, so two renders cannot write into the same tab.

The notice uses `.text-center`/`.text-muted` rather than the `.spinner`
class used at `analytics.js:1683`, because `.spinner` has no CSS
anywhere in the repo and renders nothing.

## Tests

`tests/unit/test-issue-1997-distance-building.js` (7 assertions, wired
into `test-all.sh`) pins the two pure decisions the renderer makes and
`api()`'s refusal to cache a 202 while still caching a 200. Red-run on
the unfixed sources: 6 of 7 fail, and the "a 200 is still cached"
control stays green.

`tests/e2e/test-issue-1997-distance-building-e2e.js` (classified in
`scripts/non-unit-tests.json`, invoked from `deploy.yml` with
`CHROMIUM_REQUIRE=1`) serves both responses by route interception, so it
does not depend on whether the server under test has an index built. It
asserts: the building state appears, the tab does not paint the error
text, a retry arrives with no interaction, the retry replaces the
placeholder once the server answers 200, and no retry fires after
leaving the tab.

Check (2) deliberately asserts on the rendered text and not on
`pageerror`: the TypeError is caught, so a `pageerror` assertion would
pass on the broken build too.

## Verification

No local cgo toolchain here since #1992, so I could not build a server
to run the E2E against. Instead I ran its five steps in Playwright
against a live instance with this branch's `public/app.js` and
`public/analytics.js` injected in place of the deployed ones (both files
are byte-identical between that instance and upstream master, so the
injection is faithful):

| check | deployed build | this branch |
|---|---|---|
| (1) building state shown | fail | pass |
| (2) not rendered as data | fail | pass |
| (3) retried on its own | fail (1 request) | pass (2 requests) |
| (4) real payload after retry | fail | pass |
| (5) no retry after leaving the tab | pass | pass |

(5) passes on the broken build too: it schedules no retry at all, so it
is a control and only means anything together with (3).

The committed E2E suite has not been run as committed. CI is its first
real run.

## Not done

- The server still recomputes on every 202 poll rather than signalling
readiness.
- No other analytics tab was audited for the same assume-a-summary
pattern.
- One pre-existing unit suite (`test-preflight-xss-gate.js`) fails on
this Windows machine with a cp1252 `UnicodeEncodeError` from its Python
helper, on a clean tree as well as with this change. Unrelated, and
green on Linux CI.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:04:37 +02:00
liquidraver 84eba41592 fix(live): anchor the legend toggle to .live-page so the VCR bar stops eating its clicks (#2049)
The PACKET TYPES legend could not be dismissed on Live: clicking the palette toggle did nothing, while document.querySelector('#legendToggleBtn').click() from the console worked. That asymmetry was the diagnosis, since a synthesised click skips hit testing.

Residual half of #1833. That fix corrected the button's offset to calc(var(--vcr-bar-height) + 10px) but left the anchor: .legend-toggle-btn was position: fixed, so the offset resolved against the viewport, while the .vcr-bar it clears is absolute inside .live-page, whose height subtracts --bottom-nav-reserve (56px + safe-area at <=768). The two anchors disagreed by exactly the reserve, dropping the button into the bar's band, and .vcr-bar at z-index 1000 against the button's 500 took every real click. The window is 641-768px: below 641 both buttons are display:none, above 768 the reserve is 0, which is why neither the desktop nor the phone layout test saw it.

Both buttons are now position: absolute, resolving against the same containing block as the bar. .feed-show-btn carried the identical defect but missed the bar by 16px because --legend-toggle-stack parks it a row higher, so that change prevents a break rather than repairs one.

Three review rounds, each verified rather than argued:
- The new E2E was classified in non-unit-tests.json but had no deploy.yml line, so it would never have run (#2037). Now wired in, and it covers both buttons.
- The '.feed-show-btn is display:none at <=768' claim was wrong (it is <=640), which the author measured and corrected.
- Its first real execution failed on its own setup: getComputedStyle().getPropertyValue() on an unregistered custom property returns the unevaluated declaration ('calc(56px + 0px)'), so the non-zero check could not work. It now asserts the measured gap instead, and deliberately does not pin 56 because the gap measures 58 on CI.

Final run: 14 passed, 0 failed. The discriminating assertion is (f), each button's offset net of the bar being identical at 720 and 1440 (11px and 123px slack); under the old anchor those differ by the reserve. (g) pins the desktop layout unmoved.

Merged by the interim maintainer without a second human reviewer: CI and the review above are the independent checks.

Fixes #1833
2026-09-20 14:00:45 +02:00
n30nex 7f4b357d89 fix(map): keep inspector controls clickable and ignore stale loads (#2048)
Fixes #1998 and #2030.

Map Controls overlapped the Path Inspector toggle on desktop and intercepted its clicks; the controls are now offset by the collapsed and expanded inspector widths, with the mobile layout unchanged.

Leaving the Map page while its node data was loading could log an invalidateSize error or let an old response overwrite a replacement map. Node-loading continuations and the deferred initialization callbacks are now tied to the Leaflet instance that started them, and obsolete results return before touching state or rendering.

Reviewed against the code: the identity check is sound because destroy() clears the reference (map.remove() then map = null, public/map.js:2428-2433), so every guard is false after teardown and after replacement. Each await in loadNodes is followed by a check, as are the two deferred callbacks, the error branch and the finally. Moving 'nodes = data.nodes' after the observers await is deliberate: shared state is only mutated once the load is known to still own the map.

The tests are the strong half. Overlap is asserted through elementFromPoint on the toggle's centre across five viewport widths and both pane states, rather than a forced click that would pass over an overlapping control, and 641/640 pin both sides of the breakpoint. Both extended suites are invoked by deploy.yml, one line each, which was checked rather than assumed. The commit chain is test-then-fix twice: e675100/7d736d1 and fd56ea2/823668b.

One nit left to the author: the CSS repeats the pane widths from style.css:4532-4533 four times, so changing the pane width would silently misalign the controls, caught only as a waitForFunction timeout.

Merged by the interim maintainer without a second human reviewer: CI and the review above are the independent checks.
2026-09-19 21:11:53 +02:00
liquidraver bbf54cfe1b fix(rx-coverage): open at the configured map default, with its own saved viewport (#2033)
The coverage page opened at a hardcoded [51.0, 4.8] zoom 8 regardless of deployment, ignoring /api/config/map (#2032). It now follows the same precedence as the main map (URL hash, then saved position, then /api/config/map, then [37.6, -122.1] zoom 9) and persists its own position across visits, syncing lat/lon/zoom into the hash so a view is shareable.

The saved position lives under its own key, rx-coverage-view, and the page never reads or writes the main map's map-view: sharing the configured default was the bug, sharing the session position was not. Both suites assert map-view stays untouched after a pan, so reintroducing a shared write fails instead of passing quietly.

Also fixed here: selectedRx is now percent-encoded into the hash, and a generation counter stops a late /api/config/map response or a stale 150ms layout timer from building a map for a page that was already left.

Reviewed twice. Verified by mutation rather than by reading: writing map-view too, ignoring the saved coverage position, and dropping the /api/config/map fetch each make the unit suite exit 1, so it covers the feature, the fix and the rejected alternative. The deploy.yml invocation was confirmed to land inside Run Playwright E2E tests (fail-fast) by parsing the workflow, and CI run 35261689694 is the E2E suite's first real execution: Go prints 'RX coverage viewport regressions OK' and Playwright prints 'RX coverage viewport browser regressions OK'.

Worth recording for the next reviewer: that E2E asserted localStorage.getItem('map-view'), so wiring it into deploy.yml without updating the assertion would have turned the job red on its first ever run. It was registered in scripts/non-unit-tests.json but invoked by nothing, the gap tracked as #2037.

Merged by the interim maintainer without a second human reviewer: CI and the mutation checks above are the independent checks.

Fixes #2032
2026-09-17 22:02:36 +02:00
Sylvain Rabot cb994d9f9a feat(home): link My Mesh node cards to the node page (#2027)
## What

The node cards in the **My Mesh** grid on the home page could open the
full
health panel or the node's packets, but there was no way to reach the
node
detail page from them — you had to go search for the node again.

Each card now leads with a **Node page →** button that navigates to
`#/nodes/<pubkey>`, the same route the channels, live and analytics
pages
already link to.

## Details

- Wired through the existing `.mnc-btn` click delegation, so it inherits
the
`stopPropagation()` that keeps the card's own click-to-health handler
from
  firing as well.
- The error-state card (health fetch failed, including the 404 *"waiting
for
first advert"* case) gets the button too, on its own actions row. *Full
health* and *View packets* stay off that card — the health fetch is
exactly
what failed, but the node page still resolves for a node that has so far
only
  been seen in channel messages.
- `.mnc-actions` now wraps, so three buttons don't overflow a narrow
card.

## Testing

`node --check public/home.js` passes. Lint and the Playwright suite were
not
run: the worktree this was written in has no `node_modules`. The
existing home
e2e test targets `.mnc-btn[data-action="health"]` specifically, so the
new
button does not disturb it.
2026-09-14 11:13:32 +02:00
efitenandClaude Opus 5 5d3af168b3 fix(live): keep the space the user is typing in the node filter (#2028)
## Problem

Typing a node name with a space slowly into the Live page node filter
glues the words together: "Dan's Local" ends up as `Dan'sLocal`, and
`/api/nodes/search` then returns no suggestions.

The debounced input handler commits the trimmed value (`public/live.js`
`applyFilterFromInput`, ~1791), so after "Dan's " the filter key is
`Dan's`. `setNodeFilter` calls `updateNodeFilterUI`, which wrote
`nodeFilterKeys.join(', ')` back into the input whenever it differed
from the raw input value (~2912). That dropped the trailing space the
user had just typed, and the next keystrokes were appended to `Dan's`.

Found while reviewing #2026.

## Change

`updateNodeFilterUI` no longer writes into the node filter field while
it has focus, and otherwise only when the trimmed input differs from the
keys. Besides the typing debounce, it runs for every matching live
packet, which could also eat a character typed inside the debounce, and
it replaced a picked suggestion's name with the pubkey. Restores from
`?node=` or localStorage (field not focused) still write the keys.

## Tests

- `test-live.js`: a trailing space typed into a focused field is kept
(fails on master); a focused field that differs from the keys is not
overwritten; an unfocused field with different text is; an unfocused
field that differs only by whitespace is not.
- Mutation: each of the three guards (focus, trimmed comparison, write
when different) fails one test on its own.
- `node test-live.js`: 100 passed. `sh test-all.sh`: all standalone
frontend suites pass. `npx eslint public/live.js`: 0 errors.

## Browser validation

Local server with the e2e fixture, headless Chromium, typing `Dan's `
(60 ms per key), a 500 ms pause, then `Local`:

| Build | Input | Stored filter | Suggestions |
|---|---|---|---|
| this branch | `Dan's Local` | `Dan's Local` | `Dan's Local Repeater` |
| master | `Dan'sLocal` | `Dan'sLocal` | none |

## Not verified

- Other browsers than Chromium, and mobile keyboards with autocorrect.
- A trailing space typed and left there: the filter key stays trimmed
while the input keeps the space, which is the intended difference.



## Review follow-up (commit `4e86171d`)

An independent review found the change correct but pointed at the same
bug on a second path, plus a weak test:

- **Packets during typing.** `updateNodeFilterUI` also runs for every
matching live packet (`public/live.js` ~3393). A packet arriving inside
the 200 ms debounce still rewrote the field: with filter `ab12` and the
user typing `3`, the `3` was lost. The write is now skipped while the
input has focus.
- **Picked suggestion.** The same write replaced the name
`selectSuggestion` had just put in the field with the node's
64-character pubkey. With the focus guard the field keeps the name; the
filter key is still the pubkey.
- **Tests.** The old "writes a different key" test started from an empty
input, so a check that only writes into an empty field passed too. The
tests now cover a focused input that differs (not overwritten), an
unfocused input with different text (overwritten) and an unfocused input
that differs only by whitespace (not overwritten). Each of the three
guards was mutated on its own and fails one test. `test-live.js`: 100
passed; `sh test-all.sh`: all standalone suites pass; eslint: 0 errors.

Browser, local server with the e2e fixture: typing `Dan's ` then `Local`
keeps `Dan's Local` with one suggestion; picking it shows `Dan's Local
Repeater` while the stored filter is the pubkey; reopening
`#/live?node=<pubkey>` shows the pubkey in the field, as before.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 23:13:27 +02:00
efitenandClaude Opus 5 52b9474d7a feat(map): filter repeaters by region name (#1862) (#2022)
Fixes #1862

## What

Adds a **Region Scope** picker to the map controls: pick `#be` and the
map keeps the nodes that declare `#be` or were seen carrying `#be`
traffic. It combines with the #2006 scope-state filter and persists in
localStorage the same way. While a region is picked, a small "Region:
#be · reset" chip sits on the map itself, so the filter stays visible
when the controls panel is collapsed (the default on phones) and can be
cleared from there.

## API

`/api/nodes` and `/api/nodes/{pubkey}` gain two fields on repeater/room
rows:

- `declared_regions`: named regions from the node's newest
declared-regions answer, split by the same function the Scope Audit now
uses for `declaredRegions` (`splitDeclaredRegions`,
`cmd/server/scope_config_state.go`), so both pages list a repeater under
the same names. `[]` means it answered and named no region. Absent means
it never answered, other roles, no declared-regions source, or the
declared-regions lookup failed.
- `declared_regions_truncated`: present, and `true`, only when that
answer was flagged as truncated, so the list is partial. Never `false`:
the `nodes.configured_scope` source does not record truncation, so
absence does not mean the list is complete.

The observed side reuses `transported_scopes`. Documented in
`docs/api-spec.md` and the served OpenAPI spec.

### Why no `?hashRegion=` query parameter
The observed side lives in the in-memory store. Filtering it after the
SQL `LIMIT`/`OFFSET` would corrupt `total` and paging, and the map pages
through `/api/nodes`. Same reasoning as the Data path section of #2001.

## Map behaviour

- Filtering is client-side over the nodes `fetchAllNodes` already
loaded: no new request. One pass over the loaded nodes to build the
picker counts, one Set lookup per node per render. The marker filter is
`nodePassesMapFilters` (`public/map.js:219`) and the observer stand-down
`observerLayerShown` (`:212`), both exported and tested.
- The picker and hint count only nodes with a map position, the same
test the marker filter applies first, so a count never promises markers
the map cannot draw.
- The observer layer stands down while a region is picked, for the same
reason it does for the scope-state filter.
- The popup lists declared and observed regions separately. A truncated
declared answer carries the same `truncated` badge the Scope Audit
shows.
- Absence is not read as a finding: the hint under the picker says a
node left off the map is not proof it lacks the region.

## Tests

- Go: `node_declared_regions_api_test.go` covers `declared_regions` on
list and detail endpoints, the no-source case, agreement with
`/api/scope-audit`, `declared_regions_truncated` (truncated,
truncated-empty, complete, configured_scope-only, newer untruncated
answer, companion) and the OpenAPI schema.
- JS: `test-issue-1862-map-region-filter.js` (30 tests) covers the pure
pieces (evidence, counts, options, hint, popup rows,
`nodePassesMapFilters`, `observerLayerShown`) and, at page level, runs
the registered map page through `init()` and `loadNodes()` in a vm
sandbox: picker built from loaded nodes, markers filtered by a stored
region, observer pins standing down, popup rows, the change handler
persisting, and the chip showing, resetting and rendering its text as
text.
- Mutation-checked: removing the region check, the observer stand-down,
the picker build in `loadNodes`, the popup rows, the persist on change,
the chip reset, or the truncated flag (Go or JS) each fails a test.
`test-issue-2001-map-scope-state.js` still passes.
- `go test ./...` in `cmd/server` passes; `check-css-vars` and
`check-xss-sinks --diff` are clean.

## Staging validation
Build `c646310f`, Chrome, no console errors:
- Picking `#be`: chip "Region: #be · reset" at the top of the map, hint
"221 nodes with a map position have evidence for #be: 129 declare it,
191 seen carrying its traffic. Absence here is not proof: ...".
- Clicking reset: picker back to "All regions", stored choice cleared,
chip hidden.
- First version, same instance: `declared_regions` on 136 of 500
`/api/nodes` rows; returning to "All regions" blocked the main thread
5.4 s against 3.2 s for the existing Status filter returning to "All".
The review measured the region filter's own added work at about 0.08 ms
per render plus popup rows that are already built for every marker, so
most of that time is the existing full re-render.

## Not verified
- The chip placement at phone width, and on narrow desktop widths where
it may sit under the expanded controls panel.
- The truncated badge with real truncated answers (staging has none
today).
- `test-map-clustering.js` has one failing test on `upstream/master`
too; untouched here.
- Marker badges from the original issue body are not implemented; the
popup rows are the per-node display.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 21:09:51 +02:00
efitenandClaude Opus 5 dda04ae918 fix(packets): empty the observer and type selections on Clear Filters (#2012) (#2015)
## What was wrong

On the Packets page, Clear Filters reset `filters.observer`,
`filters.type`, localStorage and every checkbox in both multi-select
menus, but not the Sets that hold the selection: `selectedObservers`
(`public/packets.js:1793`) and `selectedTypes`
(`public/packets.js:1845`). The next `change` event
(`public/packets.js:1826`, `:1876`) added to the stale Set, so select
observer A, Clear, select observer B wrote `A,B` to the URL and the
trigger read "2 Observers". Types behaved the same way. Clear also
unchecked the "All Observers" / "All Types" rows although no filter was
active.

## What changed

- `public/packets.js:1999-2006`: the Clear handler empties both Sets and
rebuilds both menus through `buildObserverMenu()` / `buildTypeMenu()`
plus `updateObsTrigger()` / `updateTypeTrigger()`, replacing the
hand-written checkbox and trigger resets. All four functions are in the
same scope as the handler.
- `test-issue-2012-clear-filters-selection.js` (new): runs the real
multi-select section and Clear handler from `packets.js` in one function
scope against a small fake DOM. For observers and types it drives select
A, Clear, select B and asserts that only B is selected (filters,
localStorage, trigger text) and that the All row is checked after Clear.
- `test-clear-filters.js`: the handler body now uses names this test did
not provide, so menu-agnostic cases get empty stand-ins. The old
"unchecks every checkbox" case expected the All row to be unchecked (the
bug), so it now asserts that the Sets are emptied and both menus
rebuilt.
- Registered the new test in `test-all.sh` and the unit-test step in
`.github/workflows/deploy.yml`.

## Tests

- `node test-issue-2012-clear-filters-selection.js`: 4 passed. With the
`packets.js` change reverted: 0 passed, 4 failed (`obsA,obsB`, `4,5`,
All row `false`).
- `node test-clear-filters.js`: 6 passed, 2 failed, the same counts as
on master. The two failing `updatePacketsUrl` cases fail on master with
`location is not defined`. This file is not run by `test-all.sh` or CI.
- Also green: `test-packets-local-channels.js`,
`test-issue-1415-packets-layout.js`, `test-frontend-helpers.js`,
`test-observer-iata-1188.js`, `test-packet-filter.js`,
`test-packet-filter-ux.js`, `test-xss-escape-sinks.js`,
`scripts/check-css-vars.js`.

## Browser validation

Deployed together with #2013's, #1851's and #1868's branches to a
staging instance with live traffic (build `e84d2da6`), in Chrome:

- Observers: select BE-BRU-Moris, Clear (URL back to `#/packets`,
trigger "All Observers", All row checked), select BE-BRU-Bécodok: URL
holds only Bécodok's key, trigger shows Bécodok.
- Types: select one type, Clear (All row checked, "All Types"), select
another: stored filter holds only the second.

## Not verified

- Other ways of resetting filters (navigating back to `#/packets`
without params) were not checked for the same stale Sets.
- The full `test-all.sh` run was not done locally.

Fixes #2012



## Review follow-up (commit `4b14f54e`)

An independent review of this PR reproduced the bug on master and
confirmed the fix in headless Chromium against a real server. It found
one related pre-existing problem, now fixed here:

- The type multi-select change handler never called
`updatePacketsUrl()`, which is what shows or hides the Clear button
(`public/packets.js` ~779-783). With only a type selected the button
stayed hidden, so the Types half of #2012 could not be reached by a
click. The handler now makes that call, like the observer handler does
(`public/packets.js:1887`). Type is not part of the URL, so the call
only toggles the button.
- The regression test now runs the real `updatePacketsUrl()` and adds
two cases: picking only a type, and only an observer, shows the Clear
button, and Clear hides it again. The type case fails without the new
call.
- The test's fake DOM also provides `#observerList` and
`#observerSearchInput`, so it keeps working if #1884 merges first.
Checked against a local merge of #1884: 6 of 6 pass. That merge has one
conflict in the Clear handler; whichever PR lands second must keep both
the Set clear and the search box reset.
- Not mentioned before: the fix also resets the type trigger's `title`
tooltip through `updateTypeTrigger()`, which the old handler left stale.

Validated on staging together with the other follow-ups (build
`c646310f`). Not verified: mobile viewport, full Playwright suite.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 21:09:31 +02:00
efitenandClaude Opus 5 efdb3ea0b3 feat(analytics): retransmission pressure over time (#1699) (#2023)
## Summary
Adds `GET /api/analytics/retransmissions` and a "Retransmission Pressure
(proxy)" chart on the Analytics Topology tab, implementing the metric
agreed in #1699: for each flood, the number of distinct repeaters in the
union of the paths of all its observations (`[A]`, `[A,B,C]`, `[A,D]`
gives 4), averaged per time bucket.

Topology is the tab that already shows hop counts and repeaters in
paths, so the chart sits there instead of in a new tab.

## Definition
- Flood routes only (`route_type` 0/1); TRACE excluded. Direct routes
carry the route still to travel (firmware
`src/Mesh.cpp:78-106,334-342`), zero-hop sends are direct
(`src/Mesh.cpp:717-737`), TRACE path bytes are SNR values
(`src/Mesh.cpp:59-61`, refused by `sendFlood` at
`src/Mesh.cpp:637-641`). Firmware commit 0679dbef.
- **Flood events, not hashes.** `transmissions.hash` is UNIQUE and the
packet hash excludes the path (`src/Packet.cpp:41-50`), so when the same
bytes flood again the observations land on the same transmission.
Observations are sorted by time and split into events wherever two
consecutive observations are more than 5 minutes apart. Each event is
counted on its own and bucketed by its first observation.
- Why 5 minutes: a node holds a flood for at most 32 s
(`src/Dispatcher.cpp:11,243-251`) plus a random retransmit delay. On
live over 7 days, 72,806 of 74,347 flood transmissions span 60 s or
less, and of 1,372,283 consecutive observation gaps, 52 fall between 60
s and 300 s against 1,823 above 300 s.
- Events that start before the store retention floor (now minus
`retentionHours`) are left out for every request shape. The store keeps
older observations only for hashes heard again recently, so they do not
represent that period. Eviction of those transmissions is tracked in
#2024.
- A flood event heard only with an empty path counts as 0 repeaters.
- **Prefixes are not resolved to nodes, and a prefix counts once per
event**, whether it repeats across observations or inside one path. On
live (7 days), a repeated 2-byte prefix inside one path occurs in 1.08%
of flood transmissions and 6,178 of 6,596 such repeats match exactly one
known node; for 3-byte it is 0.69% and 104 of 104. That is one node
forwarding again after its 160-slot cyclic duplicate filter dropped the
hash (`src/helpers/SimpleMeshTables.h:9,52-57`). A repeated 1-byte
prefix (44.9% of 1-byte transmissions) is mostly two nodes; counting it
once keeps the value a lower bound. `summary.one_byte_packets` reports
how many events that affects.
- Observations are stored once per observer and path per hash, so a
later event of the same hash only holds pairs not stored before; its
count is a lower bound too. On live these are 1,466 of 75,356 events
(1.9%), and they are kept in the average.
- Resolution was not used: on live, 1-byte observations nearly all have
`resolved_path` NULL, and cold load refuses context-based resolution of
history (`cmd/server/neighbor_persist.go:155-168`).
- Buckets `5m|15m|1h|6h|1d`. `region` filters on observers like
`/api/analytics/rf`, after the event split; a region with no known
observers is not filtered, the same as the other analytics endpoints.
`area` is not supported.

## Implementation
- `cmd/server/retransmission_pressure.go:255` `addPath`: scans path JSON
directly into a generation-stamped hash set, no allocation per
observation.
- `cmd/server/retransmission_pressure.go:367`
`computeRetransmissionPressure`: one pass under `s.mu.RLock`. Per flood
transmission it sorts the observations by cached parsed time into a
reused scratch slice, splits events and counts each in `addEvent`
(`:319`). O(T + O log k + H).
- `cmd/server/retransmission_pressure.go:470`
`GetRetransmissionPressure`: default shape from the recomputer (#1659
warm-up gate). Other shapes come from a typed TTL cache (max 64 entries)
cleared on new paths and eviction (`cmd/server/store.go:2289,2336`);
concurrent misses on one key share one compute through singleflight
(`store.go:204`).
- `cmd/server/retransmission_pressure.go:515` handler,
`cmd/server/routes.go:331`, `cmd/server/openapi.go:108`,
`docs/api-spec.md:1283`.
- `public/analytics.js:761` card, `:855` `renderRetransmissionChart`
(CSS variables only, lines break at missing buckets, caption states it
is a proxy, names the observer coverage bias, the once-per-flood prefix
rule and the 5 minute event split), `:829` loader with stale-response
guard.

## Performance
- `BenchmarkComputeRetransmissionPressure`, 50k transmissions x 20
observations, `-cpu 1`, i5-1335U: median 161 ms/op (132 ms/op before the
event split); first pass after startup with timestamps not yet parsed
192 ms/op. About 22 KB and 281 allocations per op.
- On staging the default shape is served from the recomputer in 0.3 s;
the post-load recompute of this recomputer took 994 ms on a
121k-transmission store (log line quoted in #2025). A 336h store would
be about twice that, every recompute interval, under the store read
lock.

## Tests
- `cmd/server/retransmission_pressure_test.go`: union counting (reporter
example, overlaps, once per event for 1/2/3-byte, width/case, growth);
route/TRACE/zero-hop filter, bucketing, window by event start; event
split and the 5 minute settle gap (boundary, chained steps, unsorted
input), retention floor; region filter, region applied after the split,
unknown region, 1-byte share; recomputer read, TTL cache invalidation on
new paths and on eviction, cache expiry, singleflight, recomputer gate
wiring, handler, warm-up gate.
- `test-issue-1699-retransmission-chart.js` (33 tests, registered in
`test-all.sh` and `deploy.yml`).
- Mutation-checked: 18 mutations of the event split, floor, prefix rule,
bucketing, region order, cache clears, expiry, gate wiring and
singleflight, all killed.
- `go test ./...` in cmd/server passes; `scripts/check-css-vars.js` OK.

## Staging validation
Build `c646310f` (this rework plus #2025 and the other review
follow-ups), after a container restart and full load: default shape
74,974 flood events, average 27.08 repeaters, 169 hourly buckets from
2026-09-06 16:00 (the 168h floor) to the current hour, highest hourly
average 53.3. Before the rework the same instance showed buckets back to
2026-07-18, averages up to 148, and for the first minutes after a
restart only 5,911 packets.

## Merge order with #2025
#2025 fixes the recomputer startup for all analytics endpoints (the
stale first snapshot seen here). Whichever of the two merges second has
to add `recompRetransmissions` to `analyticsRecomputersLocked`, wire it
to that PR's `loadedGate` instead of `LoadComplete`, bump the recomputer
count in `TestAnalyticsRecomputers_PostLoadOrder` from 9 to 10, and make
`TestStartAnalyticsRecomputers_RetransmissionsGatedOnLoadComplete` call
`signalStartupLoadDone()`. That resolution is what ran on staging.

## Not verified
- Recompute timing on a production-size (336h) store; only extrapolated.
- Phone-width layout and dark theme of the reworked chart.
- E2E Playwright suite.

Fixes #1699

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 20:37:14 +02:00
914bd4cf0e feat(channels): show each message's region (#1851) (#2018)
## Show each channel message's region scope

Each message in the Channels view now shows the region scope it was sent
with, as a small chip in the meta line: the region name (for example
`#be`), `unknown scope`, or nothing.

This is the channel-message part of #1852 by @dborup, extracted as a
focused change. #1852 was closed unmerged because it had grown to the
whole fork diff. The implementation follows dborup's commits c686ae3f,
350bf7ee and a94d57ed on dborup/CoreScope, adapted to current master.
dborup is co-author on the commit.

### What changed
- `cmd/server/db.go:2099,2195`: `GetChannelMessages` selects
`t.scope_name` when the column exists and returns it as `scope_name`.
- `cmd/server/store.go:5654`: the in-memory `GetChannelMessages` returns
`scope_name`, so the field does not depend on which path serves the
endpoint.
- `cmd/server/store.go:2966,3244`: both WebSocket broadcast builders
carry `scope_name`, so a live message shows its region immediately.
- `public/channels.js:342,2278`: `messageScopeChipHtml` renders the chip
with the existing `.sa-chip-declared` / `.sa-chip-unmatched` styles from
`scope-audit.css`. The name goes through `escapeHtml`. No new CSS.
- `public/channels.js:678,695,1435,1487`: the decrypt path and the
WebSocket path keep `scope_name` on the message.

### Differences from #1852
- The field is `scope_name`, the name `/api/packets` already uses.
- No `routeType` field. `transmissions.scope_name` already tells the
states apart: NULL means no transport code, an empty string means a
transport code that no configured region key matched. The frontend uses
`??`, not `||`, so the empty string is kept.
- A chip instead of `scope: <name>` text. The area label from later
#1852 commits is not included.

### Perf
One extra column per observation row in the page query (at most `limit`
transmissions), and one extra map entry per broadcast observation. No
new queries, loops or API calls.

### Tests
- `cmd/server/channel_message_scope_name_test.go`: the three states
through the DB query, the store, `/api/channels/{hash}/messages` over
both paths, a schema without the column, and both broadcast builders. 5
of its 6 tests fail without the change; the sixth guards the
missing-column case and passes either way.
- `test-issue-1851-channel-message-scope.js`: the REST, WebSocket and
client-side decrypt paths, escaping, and the name / unknown / none
render. 4/4 fail without the change. Registered in `test-all.sh` and the
unit step of `deploy.yml`.
- Mutation checks: returning `nil` for `scope_name` in the DB path fails
the DB and endpoint tests; `||` instead of `??` in the WebSocket path
fails the WebSocket test.
- `go test ./...` in `cmd/server`: ok. gofmt and go vet clean.

### Browser validation
On a staging instance with live traffic (build `e84d2da6`), in Chrome:
- `/api/channels/{hash}/messages` carries the `scope_name` key on every
message in the 19 channels whose results I read. `#hamradio`, latest 50:
34 named, 1 empty string, 15 NULL.
- Opening `#hamradio` renders 104 chips: `#nl` 53, `#be` 32, `#de` 11,
`#eu` 6, `#bx` 1 and `unknown scope` 1, and no chip on unscoped
messages. Chip text `rgb(26, 26, 46)` on `rgb(238, 242, 255)` in the
light theme.

### Not verified
- Dark theme not checked.
- The real-decrypt branch of `decryptCandidates` has no test and was not
exercised in the browser; the already-decrypted branch is tested.
- Messages already in the client decrypt cache show no chip until they
are decrypted again.
- `go test -race` and the Playwright E2E suite were not run locally.

Fixes #1851



## Review follow-up (commit `50346589`)

An independent review found no correctness or XSS problem and confirmed
DB, store and WebSocket agree on the value. Changed:

- **Real decrypt branch tested.** A new test runs the real AES+HMAC
decrypt branch in `decryptCandidates` with one packet per scope state;
deleting `scope_name` there now fails 2 of 7 tests.
- **Tooltip wording.** The unknown-scope tooltip now says the scope
"could not be matched to a single region on this instance"
(`public/channels.js:338-347`). The ingestor stores an empty name both
when no key matches and when several match without exactly one
operator-configured key (`cmd/ingestor/region_keys.go:364-393`), so
"matches none of the configured keys" was wrong for the second case.
- **Old decrypt cache.** Decrypted messages cached before this change
had no `scope_name` key and stayed chipless as long as the candidate
count did not change. A cached message missing the key now forces one
full decrypt; a cache that has it still takes the delta path. A test
covers each case.
- **Docs.** `docs/api-spec.md` documents `scope_name` on the channel
messages response, with the null / empty string / name semantics.

Corrections to the description:

- **Test counts.** With the `db.go` and `store.go` changes reverted, 4
of the 5 top-level Go tests fail (6 of 7 counting subtests); only the
missing-column test passes.
- **Broadcast payload.** `scope_name` is added to `pkt`, which is copied
into `broadcastMap` and also nested as `packet` (`store.go` ~2974-2980,
~3252-3257), so the key appears twice per observation: 36 bytes for
`null`, 48 bytes for `"#belgium"`.
- **Side effect on the Packets page.** The live table reads
`m.data.packet` (`packets.js` ~1316-1318), so flat rows and expanded
group children now show Scope for live packets. In grouped mode a new
group copies a fixed field list without `scope_name` (~1384-1395) and
shows the empty placeholder until reload. Before this PR every live row
showed that placeholder, so this is not a regression.

The three copies of the three-state scope rendering (`app.js`,
`packets.js`, `channels.js`) are left as they are.

---------

Co-authored-by: dborup <3627142+dborup@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 20:36:34 +02:00
2c6d7bb6ae feat(packets): add filter to All Observer dropdown (#1884)
Lets users type a prefix to filter the observer checkbox list, instead
of scrolling a long list.

---------

Co-authored-by: efiten <erwin.fiten@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 20:36:13 +02:00
efitenandClaude Opus 5 a059299588 feat(node-analytics): hop-count statistics per node (#1812) (#2021)
## Summary
Adds per-node hop-count statistics so repeater operators can choose
`flood.max`, `flood.max.unscoped` and `flood.max.advert` from what their
node actually sees.

- New endpoint `GET /api/nodes/{pubkey}/hop_analytics?days=N`
(`cmd/server/routes.go:299`, `cmd/server/node_hop_analytics.go:312`),
separate from `/analytics` as requested in the issue.
- New card "Hop Count at This Node" on the node analytics page
(`public/node-hop-analytics.js`, wired at
`public/node-analytics.js:130,174`): histogram of hop counts with a box
plot on the same x axis, filters `flood.max` (default),
`flood.max.advert`, `flood.max.unscoped`, driven by the existing range
picker.
- The existing "Hop Distribution" chart is unchanged: it shows path
length at the observer, a different quantity.
- No `direct` tag, although the issue lists one: for DIRECT packets the
path is the remaining route and no flood limit applies, so there is no
hop count to report.

## Hop count definition (firmware 0679dbef)
- `src/helpers/RoutingPolicy.h:15-21`: limits compare
`getPathHashCount()`; `.unscoped` applies to route type FLOOD, `.advert`
to adverts.
- `src/Mesh.cpp:344-350`: `routeRecvPacket` checks with n hashes in the
path, then writes its own hash at index n. So hops = the node's
zero-based index in the path, no +1.
- `src/Mesh.cpp:265-285`: a node forwards a flood once;
`src/Mesh.cpp:651,680`: an originator never forwards its own flood.
- DIRECT packets are excluded: their path is the remaining route
(`src/Mesh.cpp:78-103,334-341`).

Response: `{timeRange, packets: [{hash, timestamp, hops, tags}],
ambiguous}`. Tags: `flood`, `scoped` or `unscoped`, `advert`. Documented
in `docs/api-spec.md:679` and `cmd/server/openapi.go:90`.

## Attribution
`cmd/server/node_hop_analytics.go:198-309`. The result depends only on
the observed paths, the prefix map and the neighbor graph, so it is the
same after a restart as after live ingest.

- Every observation of every flood packet in the window is read.
`byNode` holds the server resolver's pick at ingest and other picks
after a cold load; `byPathHop` indexes only each packet's longest path,
which for a busy relay often runs through another branch of the flood.
- A packet counts when the node's prefix sits at exactly one index
across its observations, and either the node is the only relay candidate
for that prefix (`prefixMap.relayCandidates`,
`cmd/server/store.go:6795`), or the hop resolves to the node under the
ingestor's strict rule (`cmd/ingestor/path_resolver.go:143-214`) in at
least one observation and to another node in none. Strict rule: earlier
hops identified without a tiebreak, exactly one candidate adjacent in
`neighbor_edges` to the previous hop (the originator for hop 0 of an
advert), nodes already on the path excluded.
- The server resolver's tiebreaks (affinity, GPS distance, advert count,
pubkey order) are not used.
- Everything else with the node's prefix goes to `ambiguous`. In
practice that is most packets with a colliding 1-byte path hash.

On a read-only 7-day dump of a 1,669-node mesh DB, for one busy
repeater: 23,081 packets attributed, 11,437 ambiguous. Taking candidates
from `byPathHop` instead gave 9,995 attributed, with the histogram mode
moved from 2 to 3-5 hops.

## Performance
Scans `s.packets` under the read lock, no SQL per packet. Per
observation: one substring test for the node's first prefix byte; the
hop scan only for observations containing it; the strict walk only for
colliding prefixes, with per-request caches for candidates and
adjacency. `BenchmarkNodeHopPackets` models one 7-day request at that
scale (73,782 flood packets, 1,430,280 observations): 44-87 ms/op, 13.4
MB, 40 allocs on a throttling laptop.

Response size for that repeater over 7 days: about 23k entries, 2.3 MB
JSON, 375 KB gzipped. `hash` and `timestamp` are 61% of the raw and 91%
of the gzipped bytes; they stay because the issue asks for them so a
client can join entries to packets and bin by time.

## Tests
- Go: `cmd/server/node_hop_analytics_test.go`: 12 unit tests, a
live-ingest test through `IngestNewFromDB` (a colliding prefix without
independent attribution goes to `ambiguous`, not to the node the
resolver picked), live ingest versus cold load of the same DB, route
test, benchmark. 15 mutations of the attribution logic each fail a test.
- JS: `test-node-hop-analytics.js` (filters, histogram, quartiles and
whiskers with a fixture that separates 1.5 IQR from 3 IQR, render),
registered in `test-all.sh` and `.github/workflows/deploy.yml`.
- `gofmt`, `go vet ./...`, `go test ./...` in `cmd/server`,
`scripts/check-css-vars.js` pass.

## Staging validation
Build `c646310f` (this PR's review follow-up together with the other
open follow-ups), after a container restart and full load, on a busy
Belgian repeater:

- `hop_analytics?days=7`: 23,302 packets, 11,548 ambiguous, median 4,
adverts never above hop 7 (matching the firmware default
`flood_max_advert = 8`, `examples/simple_repeater/MyMesh.cpp:922`), 1.2
s. The first version reported 23,035 packets and 86 ambiguous in 534 ms,
because it trusted the resolver's pick for colliding prefixes.
- The card rendered on the first version with no console errors; the
rework does not touch the frontend beyond a test fixture.

## Not verified
- Response time and lock hold for 30 days on the busiest node on a
14-day store.
- Server relay candidates exclude companions and listeners while the
ingestor's prefix index does not, so a few strict attributions can
differ from the ingestor's persisted `resolved_path`.
- Identical numbers across a second container restart were shown in a Go
test, not repeated on staging.
- Dark theme, phone width, and switching the range picker in the
browser.
- Filter state is not reflected in the URL hash (the range picker is not
either).

Fixes #1812

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:59:56 +02:00
efitenandClaude Opus 5 7f8f71e773 fix(live): reconnect when the websocket goes silent (#1074) (#2020)
## Problem

#1074 reports that after a proxy dropped the WebSocket, live updates
only came back 8 to 10 minutes later.

The client only reconnects from `onclose` (`public/app.js:791` on
master). A half-open connection (a proxy or NAT dropping state without a
FIN reaching the browser, a laptop that slept) can keep a WebSocket OPEN
for minutes without `onclose`, and nothing retries in the meantime.

The server does ping every 30s (`cmd/server/websocket.go:252` on
master), but ping frames are answered by the browser below the page and
JS cannot observe them. The only app-level frames are packet broadcasts
(`websocket.go:337, 343, 366`), which stop on a quiet mesh. So the
client had no signal to tell a quiet mesh from a dead socket.

## Change

Server (`cmd/server/websocket.go`):
- On the existing ping tick, `writePump` also writes the text frame
`{"type":"heartbeat"}` (`:283`, bytes at `:80`). One 20-byte frame per
client per 30s, from the goroutine that already writes the ping, no hub
lock.
- The interval moves to `Hub.pingInterval` (default 30s, `:118`) so a
test can shorten it.

Client (`public/app.js`):
- Every frame refreshes `wsLastMessageAt` (`:841`). One timer
(`checkWSLiveness`, `:806`) fires at last message + `WS_STALE_MS` (75s,
one late or lost heartbeat of slack) and replaces the socket if it is
still silent. It is armed at socket creation, so a stuck handshake is
covered too.
- `dropWS` (`:796`) detaches the old socket's handlers before `close()`,
so a late close event cannot schedule a second connection.
- `connectWS` (`:818`) cancels a pending reconnect and drops the
previous socket, so the watchdog, resume checks, `onclose` and
pull-to-reconnect cannot stack sockets. Before this, `pullReconnect` on
a non-open socket left a third socket 3s later. The 3s `WS_RECONNECT_MS`
delay after `onclose` is unchanged (`:837`).
- `visibilitychange` (to visible) and `online` run the check immediately
(`:861`), because a hidden or sleeping tab's timers can run late.
- Heartbeat frames are matched by exact bytes (`:842`) and are not
pulsed or dispatched to `onWS` listeners.

Compatibility: tabs loaded before the deploy dispatch heartbeats to
their listeners until reloaded. Every current listener filters on
`msg.type`, so the visible effect is a logo pulse and a `/stats` cache
refresh every 30s.

Perf: one `Date.now()` and one string compare per WS message on the
client; one extra 20-byte write per client per 30s on the server.

## Tests

- `test-ws-stale-watchdog-1074.js`: real `app.js` in a vm with a fake
clock, timers and WebSocket. 12 tests: silence past the threshold
replaces the socket exactly once; a handshake that never opens is
replaced; heartbeats and packet traffic keep the socket; heartbeats are
not dispatched; resume and `online` after silence reconnect immediately,
with recent traffic they do not, and hiding does not trigger a check;
repeated resume events open one socket; after `onclose` only the
reconnect timer is pending; pull-to-reconnect leaves one socket. 9 of 12
fail on master. 12 of 12 source mutations (threshold, reconnect path,
detaching, timer cleanup, resume wiring, heartbeat filter) are caught.
Registered in `test-all.sh` and the deploy.yml unit step.
- `TestWritePumpSendsAppHeartbeat`: fails with a read timeout without
the heartbeat, even with pings every 20ms. `TestHubDefaultPingInterval`
pins the 30s interval that `WS_STALE_MS` assumes.
- `go test ./...` in `cmd/server` passes; gofmt and go vet are clean.

## Browser validation

On a staging instance (build `139e484e`, together with #1979's branch),
in Chrome, no console errors:

- A `{"type":"heartbeat"}` frame arrived on the open socket within the
observation window.
- Silent socket: after `ws.onmessage = null`, the page replaced the
socket after 76.2s (threshold 75s plus a 250ms poll); the old socket
ended in CLOSED, the new one OPEN.
- Normal close: `ws.close()` led to a new OPEN socket after 4.1s, and
exactly one new `WebSocket` was constructed.

## Not verified

- The reporter's proxy setup was not reproduced; that their delay was a
half-open socket is a hypothesis consistent with the symptom. Hence
`Refs`, not `Fixes`.
- Laptop sleep and the `visibilitychange` / `online` resume path were
only covered by the unit test, not in a browser.
- Behaviour under Chrome's intensive background-timer throttling and
mobile tab freezing was not measured; a frozen but healthy tab may do
one unnecessary reconnect on resume.
- Go tests were run without `-race`.

Refs #1074



## Review follow-up (commit `72e5e906`)

An independent review found no blocking bug: all data writes stay on the
write goroutine, pong-based dead-client detection still works, and no
ordering of onclose, watchdog, resume checks and pull ends with two live
sockets or none. It reproduced the silent-socket case in headless
Chromium through a blackholing TCP proxy (replacement 75.0 s after the
last frame). Changed:

1. **Startup wiring tested.** The first resume test now boots through
the page's real `DOMContentLoaded` listeners, so removing
`setupWSResumeCheck()` from startup makes it fail.
2. **Wall clock stepping back.** If the clock steps back after the last
message, the watchdog no longer re-arms for the size of the step (a 1 h
step used to delay detection by about an hour). A negative silence
reading is treated as stale, so the socket is replaced within
`WS_STALE_MS` of the step (`public/app.js:810-814`). A step in either
direction costs at most one extra reconnect on a healthy socket.
`Date.now()` stays the clock so a tab resumed after sleep is still
checked against real elapsed time.
3. **Pull-to-reconnect at once.** On an OPEN socket, pull-to-reconnect
now replaces it through `connectWS()` instead of closing it and waiting
for onclose, which took 63 s on a half-open connection in the review's
measurement (`public/app.js:927-934`). This was slow on master too; it
is safe now that `connectWS()` detaches the old socket.

Tests: 12 to 16 in `test-ws-stale-watchdog-1074.js`;
`test-pull-to-reconnect.js`, `test-pull-to-reconnect-1091.js` and
`test-live.js` pass.

Correction to the compatibility note: tabs opened before the deploy
treat the heartbeat like any other message. Besides the logo pulse,
`app.js` runs `updateNavStats` on every message and invalidates the
cached `/stats` and `/nodes` responses 5 s later; `packets.js` also
pushes every message into `pauseBuffer` unfiltered (~1310-1313), so an
old tab with Packets paused sees its counter rise by 2 per minute.
Cosmetic: heartbeats are filtered out on replay, and a reload ends it.

Not verified: real hidden-tab or mobile freeze behaviour, Firefox and
Safari, and the reporter's proxy setup.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:59:18 +02:00
efitenandClaude Opus 5 0fea3f2a75 feat(analytics): break scope adverts down by node role (#1979) (#2019)
## Summary

Adds a breakdown of flood adverts by sender role to `/api/scope-stats`
and the Scopes tab, in the descriptive shape agreed in #1979: per node
role, how many flood adverts were unscoped, scoped with an unnamed
region, or scoped with a named region. It reports what was sent, not
why.

## Changes

- `cmd/server/db.go:3114-3144`: one grouped query in `GetScopeStats`.
ADVERT packets on flood routes (TRANSPORT_FLOOD 0, FLOOD 1) in the
window, `LEFT JOIN nodes` on `from_pubkey`, split by the three
`scope_name` states (NULL, empty string, name). A missing or empty role
becomes `"unknown"`. Ordered by total descending, then role. Zero-hop
adverts are excluded because firmware sends them as
DIRECT/TRANSPORT_DIRECT (`src/Mesh.cpp:717-730`, `Mesh::sendZeroHop`),
so they would inflate "unscoped".
- `cmd/server/types.go:116-133`: `ScopeAdvertRoleCount` and
`ScopeStatsResponse.AdvertsByRole` (`advertsByRole`, always an array).
- `public/analytics.js:4760`: `scopeAdvertsByRoleHtml` renders a table
under the time-series chart with the count per state and its share of
the row. Role text is escaped. It reuses the existing `/scope-stats`
response, so there is no extra request.
- `docs/api-spec.md:1763-1786`: documents the new field.
`/api/scope-stats` is in `openapi_known_gaps.json`, so there is no
`openapi.go` entry to update.

API addition (existing fields unchanged):

    "advertsByRole": [
{ "role": "repeater", "unscoped": 7741, "unknownScope": 7, "named": 1562
}
    ]

## Performance

The query runs inside `GetScopeStats`, so it shares the existing 30s
cache per window. The unary `+` on `payload_type` keeps SQLite on the
`first_seen` range index. Without it the planner picked the
`payload_type` index and walked every stored advert whatever the window.
Read-only timing on a production DB (1,063,345 transmissions, 161,634
adverts, sqlite3 CLI 3.45.1):

| Window | payload_type index | first_seen index (this PR) |
|---|---|---|
| 7d | 0.231s | 0.059s |
| 24h | 0.214s | 0.008s |
| 1h | 0.213s | 0.001s |

## Tests

- `TestGetScopeStatsAdvertsByRole` (`cmd/server/db_test.go:2295`): the
three states, flood-only routes, non-advert and out-of-window exclusion,
`unknown` for a missing node row, an empty role and a NULL
`from_pubkey`, and ordering. Mutation checked: widening to routes 0-3
and dropping the empty-role fallback both fail it.
- `TestGetScopeStatsAdvertsByRoleEmpty` (`:2372`): empty result is `[]`,
not null.
- `test-issue-1979-scope-adverts-by-role.js`: renders the real
`analytics.js` helper in a vm sandbox. Covers row order, totals and
shares, columns, escaping (mutation checked), the empty state, and the
non-causal caption. Registered in `test-all.sh` and the deploy.yml unit
step.
- `go test ./...` in `cmd/server` passes, gofmt and go vet are clean,
`check-css-vars.js` OK, `check-xss-sinks.sh --diff` exits 0.

## Browser validation

On a staging instance with live traffic (build `139e484e`, together with
#1074's branch), in Chrome, no console errors: `/#/analytics?tab=scopes`
shows "Flood adverts by node role" under the time-series chart with its
caption, and a table of 6 roles for the default window, for example
`repeater 1.361 | 1.109 (81.5%) | 2 (0.1%) | 250 (18.4%)`. Shares in
each row add up to 100%. `/api/scope-stats?window=7d` returns
`advertsByRole` with the same six roles.

## Not verified

- Timings are from the sqlite3 CLI, not the modernc driver in the server
process.
- Role is the sender's current `nodes.role`. A node whose advert type
changed within the window is counted under its latest role. The data
also contains a raw `type-13` role, shown as is.
- Switching the window in the browser was not exercised; the API was
checked for 7d.
- No Playwright E2E added.

Fixes #1979



## Review follow-up (commit `43af46b8`)

An independent review found no correctness, security or performance
problem: counts are per transmission, the window matches the rest of the
Scopes tab, zero-hop adverts are DIRECT per firmware, and the
`+t.payload_type` hint holds on modernc SQLite 3.46.0. It found two test
gaps and a docs gap. Changed:

- **Ordering.** The Go fixture gave companion, repeater and unknown 3
adverts each, so ordering by total was never checked (`ORDER BY COUNT(*)
ASC` still passed). The fixture now has repeater 4, unknown 3, companion
2, sensor 2, so the expected order differs from alphabetical and the
companion/sensor tie checks the role-name tie-break. Reversing the count
order, dropping it, or reversing the tie-break now fails the test.
- **Column positions.** The JS test only checked that each cell string
appeared somewhere in the row, so swapping two columns passed. It now
compares each row's cells and the header cells by position; both swaps
fail.
- **Docs.** `docs/api-spec.md` and the `ScopeAdvertRoleCount` comment
now name every source of `unknown`: a NULL `from_pubkey` (legacy rows
the #1143 backfill has not reached), a sender with no `nodes` row,
including one moved to `inactive_nodes` by node retention (inside the 7d
window only with `retention.nodeDays` below 7), and an empty role.

Not added: a query-plan test pinning the `+t.payload_type` hint. The SQL
is inline in `GetScopeStats`, so the test would have to copy it or the
query would have to move into a constant; left for a follow-up if
wanted. The raw `type-13` role in the table is the ingestor's
placeholder for reserved advert types 5-15
(`cmd/ingestor/decoder.go:1229`, #1279), shown as is.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:59:00 +02:00
3ca176a676 fix(packets): decode CONTROL discover fields for humans (#1868) (#2017)
## What

CONTROL `DISCOVER_REQ` / `DISCOVER_RESP` packets on the Packets page now
show their fields in a readable form:

- Node type and the `DISCOVER_REQ` type filter bitmask render as
Companion / Repeater / Room Server / Sensor instead of a number or hex.
- SNR renders in dB (wire value / 4, sign kept: `-11` shows `-2.75 dB`)
instead of the raw wire byte.
- The responder pubkey renders as the node name (detail header, and the
row preview once the node index is loaded) or a link to the node (detail
field table) when the node is known, and as the first 8 hex chars when
it is not. The full key no longer appears in the row.
- The detail field table has Subtype / Type Filter / Tag / Since and
Node Type / SNR / Tag / Public Key rows with byte offsets, instead of
one generic `Raw` row. Unknown sub-types keep a `Raw` row.

## Credit

Port of @dborup's fix in dborup/CoreScope@ad1680ca (merged on their fork
as 601f1e4), adapted to current master. dborup is co-author on the
commit.

Differences from that commit:
- Name lookup uses the existing bulk node index (`HopResolver`) instead
of `/api/nodes/{pubkey}` on every opened packet, per AGENTS.md rule 10.
New `HopResolver.nodeForKey` (`public/hop-resolver.js:357`) does an O(1)
lookup by full key or 8-byte prefix; an ambiguous prefix returns null.
- `renderDetail` loads that index for CONTROL packets with a pubkey
(`public/packets.js:3293`), because zero-hop packets have no path to
trigger it.
- The ADVERT app-flags row and CONTROL share one type label map
(`advTypeLabel`, `public/packets.js:2992`).

## Firmware references (meshcore-dev/MeshCore @ 0679dbef)

- `docs/payloads.md:259-282`: field layout; DISCOVER_RESP `snr` is
"signed, SNR*4"
- `examples/simple_repeater/MyMesh.cpp:798-799`: sub-type values `0x80`
/ `0x90`
- `examples/simple_repeater/MyMesh.cpp:817`: `filter & (1 <<
ADV_TYPE_REPEATER)`
- `examples/simple_repeater/MyMesh.cpp:820-821`: node type in low
nibble, `data[1] = packet->_snr`
- `src/Dispatcher.cpp:206`: `_snr = getLastSNR() * 4.0f`
- `src/Packet.h:51,92`: `int8_t _snr`, `getSNR()` returns `_snr / 4.0f`
- `src/helpers/AdvertDataHelpers.h:7-11`: `ADV_TYPE_*` values

`cmd/ingestor/decoder.go:750-803` (`decodeControl`) already emits
`ctrlSNR` as `int(int8(buf[1]))` and `ctrlPubKey` as 64 or 16 hex chars,
so this is display-only. No backend change.

## Performance

Row preview adds one object lookup per CONTROL row (O(1)). The 8-byte
prefix index adds one entry per node at `HopResolver.init`. No new API
calls.

## Tests

- New `test-issue-1868-control-decode.js` (14 cases: type names, filter
bitmask, SNR 16 and -21, known full key, known 8-byte prefix, unknown
key, escaping of node names). Registered in `test-all.sh` and the unit
step of `.github/workflows/deploy.yml`.
- Written first: 14/14 failed before the change, 14/14 pass after.
- Mutation checks: removing `/ 4` fails 3 cases; treating the byte as
unsigned fails 2; disabling the prefix index fails 1.
- `test-packets.js`: CONTROL DISCOVER_RESP assertion updated from full
pubkey to 8-char prefix. Its other 13 failures are unchanged from
master.

## Browser validation

On a staging instance with live DISCOVER traffic (build `e84d2da6`), in
Chrome, no console errors:

- `4d67731357dac2ce`, raw payload `92 39 80 6C B8 41 CC 14 67 5D ...`:
the field table shows Subtype `DISCOVER_RESP` (flags `0x92`), Node Type
`Repeater`, SNR `14.25 dB` (wire `57 / 4`), Tag `0x41B86C80`, Public Key
linked as `BE-JBE-Permeke`.
- `99c6121e3fca3782`: SNR `-2.75 dB` (wire `-11 / 4`), Public Key
`BE-RIN-SPECTRUM-ESP-01`, link target `#/nodes/6b8c65b7...`, which
matches that node's key in `/api/nodes`.

## Not verified

- The row preview shows the 8-char prefix until the node index has
loaded, which a page listing only zero-hop packets does not trigger.
Seen on staging: the row kept `pubkey=cc14675d` while the detail panel
showed the name.
- `DISCOVER_REQ` packets were not opened in the browser; only the unit
test covers them.
- eslint and the full `test-all.sh` were not run locally.

Fixes #1868



## Review follow-up (commit `ee1a3887`)

An independent review re-derived every DISCOVER field and offset from
the firmware and found them correct, but found untested offsets and
three small display gaps. Changed:

- **Bytes past the last decoded field are kept.** The CONTROL field
table now adds a Raw row at the real offset for any payload bytes after
the last decoded field (`public/packets.js:3802`, `:3844-3846`). Before,
a truncated DISCOVER_REQ such as `80 04 12`, or a DISCOVER_RESP with a
partial or oversized key, dropped those bytes, while master showed them
in a generic Raw row. UNKNOWN subtypes use the same path now.
- **Unknown filter bits are visible.** Type filter bits outside ADV_TYPE
1..4 (ADV_TYPE_NONE and the reserved 5..15 range,
`src/helpers/AdvertDataHelpers.h:7-12`) are shown as hex next to the
names: `filter=Repeater+0x20` in the row, `Requesting: Repeater +0x21`
in the detail.
- **prefix_only and since=0.** The DISCOVER_REQ Subtype row decodes
`prefix_only` (flags bit 0, `docs/payloads.md:270`,
`examples/simple_repeater/MyMesh.cpp:818`), and `since=0` renders as "0
(no filter)" in the detail (`MyMesh.cpp:811-817`).
- **Unknown responder key in full.** A key with no known node now
appears in full (64 or 16 hex) in the field table; the row preview keeps
the 8-character prefix #1868 asked for.
- **Tests: 14 to 35.** They check the Offset column of every REQ and
RESP row, the since, prefix_only and key-length labels, the UNKNOWN and
truncated or oversized payload cases, an ambiguous 8-byte prefix in
`HopResolver.nodeForKey`, and that `renderDetail` loads the node index
for a zero-hop DISCOVER_RESP. Every mutation listed in the review
(offsets, Since row, Raw row, labels, resolver loading, ambiguous
prefix) now fails the suite, and so do 7 more on the new code.

Firmware references re-checked at `0679dbef`. Not verified: how the
wrapped 64-hex key looks in the detail pane.

---------

Co-authored-by: dborup <3627142+dborup@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:57:55 +02:00
n30nex cd9b4c04d0 test: unify frontend test runs and prevent inventory drift (#1965)
Make `test-all.sh` the authoritative standalone frontend runner for npm
and CI. Restore stale assertions and reject missing, duplicate, removed
or undocumented inventory entries.

Fixes #1858.

Rebased onto `a2f039d4`. Retains release-routing, map scope-state and
Scope Audit stylesheet tests, adds `test-packets-local-channels.js` to
the sorted runner, and classifies `test-neighbor-map-btn-clip-e2e.js`
under browser. Its separate CI browser step is preserved. Inventory: 280
root suites, 167 standalone, 113 requiring separate setup.

The icon repair fixes two suites red on master:
`test-issue-1648-m2-emoji-scan.js` and
`test-issue-1648-m6-final-sweep.js`. Node/live configured-scope
confirmations now use the existing accessible Phosphor check sprite.
Values, visibility conditions and scanner assertions are preserved.

- Red evidence: `89e45a9` inventory assertions; `4e255df` accessible
confirmation assertion. The latest rebase also reproduced both
unclassified-file failures before adding their entries. This follow-up
only changes runner/classification configuration and counts; no test
files modified.
- Local validation: all 167 standalone suites; 27 M2 browser checks and
16 neighbor geometry checks in Chromium. Syntax, whitespace, PII,
CSS-variable and XSS checks passed.
- Browser coverage includes populated/empty/null configured scopes in
node and live views.
- No new dependencies, requests, application settings or Go changes.
Workflow outside the unit step matches master.
- Windows validation uses process-local UTF-8 settings. Encoding and
node-reach confirmation follow-ups remain separate, as requested.

## Preflight override

External `run-all.sh` is unavailable; applicable repository checks were
run directly.
2026-09-13 19:19:39 +02:00
nullrouten0 a2f039d4c7 fix(packets): show browser-added channels in the channel filter (#2009)
Verified against production: ingestor writes enc_<HH> (db.go:2282), store.go:3536 filters on it, live values are uppercase two-digit hex with thousands of packets (enc_28: 2708), /api/channels omits them, and /api/packets?channel=enc_28 returns rows. New test passes at 14 and fails when the dedupe guard is disabled.
2026-09-12 12:40:44 +02:00
nullrouten0 6d7490e7da fix(tables): stop the trailing action column cropping its own button (#2010)
Reproduced on a live instance: 41px column for a 53px button at 1440px, 29px at 1024px, max-width:0 and overflow:hidden on the cell. Injecting the .col-action rule took it to 65px with nothing cropped. The new test fails 13 of 16 checks when only the CSS block is removed.
2026-09-12 12:38:58 +02:00
efiten 51a2a7dd2f fix(scope-audit): ship the stylesheet the page paints its badges with (#2005)
Fixes #2004. Two review rounds; findings and evidence on the PR. Verified by injecting the stylesheet into a running deployment and reading computed styles before and after: the badges gain background, size, uppercase and padding; at 430px the Config column stays visible; the sorted column keeps its accent. The new test fails on the eight unstyled classes against the pre-fix tree, on .ns-truncated against the first fix, and on a re-added column-hiding rule.
2026-09-11 15:35:52 +02:00
efiten 296456f9c1 feat(map): colour and filter repeaters by scope-configuration state (#2006)
Closes #2001. Two review rounds plus a re-review; findings and evidence on the PR. Verified on a deployment against live data: the field over 1653 nodes, marker tints per filter state, the marker title and popup Scope row reaching the DOM, and the colorblind-preset cascade. The audit and the map are held to the same classification by an end-to-end test that fails when either side's wildcard handling drifts.
2026-09-11 15:07:31 +02:00
efiten b5c7612166 fix(live): use the shared WebSocket instead of opening a second one (#1991)
Closes #1980.

`app.js` opens a WebSocket on every page load and fans messages out
through `onWS()`/`offWS()`. `live.js` ignored that channel and opened
its own socket to the same endpoint. `Hub.Broadcast` does no per-client
filtering, so both carried the identical full packet stream and **every
viewer on the live map pulled it twice**. That page is the one people
leave open for hours, so it was a standing multiplier on origin
bandwidth rather than a burst.

## Measured, before and after

Against a running instance, leaving the live map and returning while
counting WebSocket constructions:

| | new sockets on re-entry | constructed by |
|---|---|---|
| before | 1 | `at connectWS (live.js:3315)` |
| after | 0 | nothing |

And with one viewer on the live map, the server now reports **one**
WebSocket client for that viewer, with the live feed counter climbing
normally (7 to 31 over one navigation cycle, 71 on a fresh load).

## The change

The live map subscribes to the shared channel like every other view, and
unsubscribes in `destroy()` rather than closing a socket the rest of the
app still needs.

`connectWS()` drops any existing registration before adding a fresh one.
An early return looks like the natural guard against double
subscription, but it keeps the previous visit's closure registered, and
re-registering without dropping the old one renders every packet twice.
Dropping first is idempotent either way and always binds the current
page.

Reconnection becomes `app.js`'s business, since it owns the socket. That
moved `WS_RECONNECT_MS` out of the only place that honoured it, so
`app.js` now uses it too. It comes from `roles.js` and operators set it
as `wsReconnectMs`; after this change it applies to the one socket
everyone shares, or to nothing at all.

## Tests

Three, in the sandbox that already loads `live.js` with a Leaflet mock:

- the page registers exactly one listener on the shared channel and
constructs no WebSocket of its own
- re-entering leaves exactly one listener, and it is the new one rather
than the previous visit's
- the handler ignores messages that carry no packet

`node test-frontend-helpers.js` passes (726 assertions), `node
test-packet-filter.js` passes.

## One note on the history

The second commit on this branch claims the early-return guard broke
rendering on re-entry, citing a real measurement. The measurement
happened, the attribution was wrong: the zero counter came from the
WebSocket constructor patch I had installed to count sockets, which
interfered with the page it was measuring. The third commit records that
rather than rewriting it away. The change is kept because dropping the
old registration first is the clearer contract, not because the guard
was broken.

## Rule 0

Strictly less work than before: one socket per viewer instead of two,
one JSON parse instead of two per packet, and no second reconnect loop.
Nothing is added to the hot path; a listener already existed for every
other view.
2026-09-09 23:16:13 +02:00
efiten 8c164c5315 feat(scope-audit): verify a declared region against the repeater's own traffic (#1990)
Follow-up to #1987, and the point of counting that traffic in the first
place.

A region this instance holds no `hashRegions` key for is **unnameable,
not absent**. #1987 says so with a caveat chip. This settles it wherever
the evidence allows: derive `SHA256("#region")[:16]` from the repeater's
own declaration and HMAC that repeater's own unmatched packets with it.
Same computation the ingestor performs at ingest, with the candidate set
narrowed from every configured key to this repeater's handful of
declarations.

Where it fires, a grey "declared but not observed" chip becomes a green
one and the caveat count shrinks by the packets it explained.

## Two packets, not one

`code1` is two bytes, so an unrelated name matches a given packet with
probability 1/65536. Across ~400 unmatched packets and ~124 declared
names, chance alone produces roughly one false match per refresh. Two
matches on the same region for the same repeater is (1/65536)², about
one in four billion.

Lowering the threshold to one would not make this noisy, it would make
it **unsound**, so the constant carries that arithmetic and a test
rather than a comment. A region with exactly one hit stays grey and
reports its single hit, so the page can say why it is still shown as not
observed instead of leaving the reader to wonder.

## What it deliberately does not do

**It writes nothing.** Read-time only. A wrong answer expires with the
window instead of sitting in `transmissions.scope_name` until someone
runs a repair, and `cmd/server` stays read-only per the invariant in
AGENTS.md.

**`notObserved` remains the single source of chip colour.**
`regionEvidence` says only HOW a region was established. Two fields that
can disagree about the same fact is how this column got confusing in the
first place.

## Rule 0, including the part that was wrong at first

The naive shape is `targets × names × packets` HMACs: 205 × 124 × 400 ≈
10M.

Caching per `(region, transmission)` pair cuts the HMACs to ~50k. **That
measured 501ms**, because the HMACs had become a rounding error while
the *iteration* stayed cubic at 10.2M map lookups. Re-keyed per region,
holding the set of matching transmissions, it is **36ms** at the same
worst-case shape: a region is HMACed over every packet once, and a
target then asks one question per declared region instead of one per
(region, packet). Most declared regions match nothing, so the common
case is a single map lookup and no packet loop at all.

`hmacCount` exists so a test can assert the first mistake cannot come
back; the benchmark exists because only it caught the second.

## Both axes are bounded, because neither is bounded by the data

The "~400 packets in a 7 day window" this was sized against is a
property of one instance's configuration, not of the feature: the
ingestor stores the unnameable state for every transport-scoped packet
no configured key names, so an instance with few or no `hashRegions`
entries — the stock state, and the one this helps most — has **every**
scoped packet in that set.

- the window query takes the 4096 most recent candidates and reports
truncation, which the handler logs, so a grey chip on a sampled refresh
is not read as "not forwarded"
- the declared list is capped at 32 names per repeater: it arrives from
a collector that validates each entry's shape but never how many entries
there are
- measured at the cap: **306ms** for 205 targets over 124 names, against
29ms for the shape a real network produces

Because both caps make the evidence a sample, the response carries
`observedUnmatchedSampled`. Without it a client subtracts a capped
numerator from an uncapped total and overstates the unexplained traffic
with no way to know it is doing so. The chip subtracts only evidence for
regions **absent** from `notObserved` — a single-hit region the server
refused to accept is not called explained either — and says "at most N"
when the count was sampled.

## Verified on live data

Six repeaters clear the threshold in a 7d window on a real instance. One
of them: `nl-nb` green with 3 corroborating packets and the tooltip
stating the count, `belml` still grey on 1, and the caveat chip reading
31 of 34 packets unexplained rather than 30.

## Tests

`scope_verify_test.go` covers the HMAC-input walk against a real
transport-flood packet captured from a live instance (a hand-built
fixture would only prove the parser agrees with itself), that
`regionCode` does not fold case, the threshold in both directions, the
memo's HMAC count, both bounds with their truncation flag, and the
benchmark at cap size. Handler-level tests cover a region verified into
green, a single hit left grey with its count reported, and the
sample-size field.

`cd cmd/server && go test ./...` passes (77s), frontend 723 assertions
pass, `go vet` and `gofmt -l` clean.
2026-09-09 17:58:09 +02:00
efiten 0605b1703a feat(scope-audit): count and surface the traffic this instance cannot name (#1987)
Follow-up to #1986, and the second half of the same problem.

`ScopeAuditForwarding` drops rows whose `scope_name` is the empty string
with a bare `continue`. That empty string is the ingestor's
"transport-scoped, but no configured region key matched `code1`" state
(`scopeNameForDB`), so those packets name no region and can never
satisfy a declared one. The consequence is on the page: **a repeater
forwarding a region this instance holds no `hashRegions` key for is
reported exactly like a repeater forwarding nothing at all.** The audit
presents a gap in the reader's own configuration as a finding about
someone else's hardware.

This counts them per target, exposes the count as
`observedUnmatchedPackets`, and renders it as a caveat chip beside the
scope chips.

## Why it is not a rare edge

Measured on a live instance before this landed: of 613 `notObserved`
entries across 205 repeaters, **260 named a region that never appeared
under any name in the whole 7-day window**. Two of them (`behss`,
`fm-112`) were hash-verified as genuinely forwarded traffic the instance
simply could not name: packet `0a065d41d51f1f77` decodes to
`code1=9209`, which is exactly the code `#fm-112` derives over that
packet's own payload.

That instance had 16 region keys configured against 124 distinct region
names its repeaters declare. A stock install has fewer.

## What the counter is not

It is deliberately **not** folded into `unscopedPackets`. The two are
opposites:

| | meaning | what governs it |
|---|---|---|
| `unscopedPackets` | the packet carried no scope at all (`scope_name`
SQL NULL) | the `*` wildcard |
| `observedUnmatchedPackets` | the packet IS scoped, this instance holds
no key for that region | nothing the repeater declares |

For the same reason the new count never feeds `wildcardContradiction`,
which counts only plain unscoped floods. `scopeNameForDB` in the
ingestor is the source of truth for that three-state encoding, and the
comments point there rather than restating it.

It is also distinct from `ambiguousHops`, and the distinction is the
point of the chip: that one is a pubkey-prefix collision between two
repeaters and is nobody's fault, this one is a missing entry in the
reader's own configuration and they can act on it. Saying which is which
is what stops someone investigating an innocent repeater.

## Frontend

The chip reuses the muted dashed treatment of `.sa-chip-ambiguous` on
purpose: both are caveats on the row's finding rather than findings
themselves, and neither may compete visually with the red/green scope
chips beside them.

It renders nothing for a non-numeric count. The value is
server-supplied, and a truthiness check would put the literal string
`NaN forwarded packets` on the page if that ever stopped holding.

## Docs

`docs/api-spec.md` had **no entry for `GET /api/scope-audit` at all**,
so this adds one: query parameter, full response shape, and the notes a
client needs (the three traps the per-node endpoint documents apply here
identically, `*` is never a scope, and "never asked" is not "declared
nothing"). The new field is documented there rather than in isolation.

## Tests

- the counter on a last-hop and on a mid-path hop
- an unmatched packet enters neither `agg.scopes` nor `unscopedPackets`,
which is the confusion this field exists to prevent
- the field on the API row
- six frontend cases: zero renders nothing, a missing field renders
nothing (older server), the chip carries its count and class, singular
and plural are both grammatical, the title names the cause and the fix,
and a non-numeric count renders nothing rather than `NaN`

`cd cmd/server && go test ./...` passes, frontend 712 assertions pass,
`go vet` and `gofmt -l` clean.

Rule 0: the counter is one increment on a branch that already existed as
a `continue`, inside a loop this PR does not change. No new query, no
new pass over the data.
2026-09-09 17:05:29 +02:00
efiten 2c8c1161b5 feat(#1975): network-wide Scope Audit page, fed by confirmed scopes (#1976)
One row per repeater whose configured region list is known, answering a question
no other view answers: you declare these regions, but were you seen forwarding
them? default_scope says what a node's adverts were observed under and
transported_scopes (#1751) says what it carried, but nothing lined the declared
list up against observed forwarding.

The declared side merges every confirmed-scope source the database carries,
newest answer per node wins, rather than naming one. On a stock install only
nodes.configured_scope (#1865/#1971) exists and it degrades to the one-source
case; deployments that collect the same fact another way keep working. Reading a
single hard-coded source would have rendered an empty page on the very instance
the evidence came from.

Declared and observed are compared through normScope, so a leading "#" and a
bare region name are one region. Unobserved regions render neutral, not red:
absence over a short window is weak evidence, which the page header already
states in words.

Ported from a long-running fork deployment with its 17 server tests, rewired to
the upstream data source, plus 14 frontend cases asserting rendered markup.
2026-09-06 23:04:42 +02:00
efitenandClaude Opus 5 5b689f75fe feat(#1845): filter nodes by how long they have been silent (#1973)
Closes #1845 for the question in its title. The alerting ask that came
later in the thread is deliberately **not** in here; see the bottom.

### The gap

@Jonher937 asked to flag repeaters that stopped communicating for x
days, to find remote gear that has died. Today the Nodes page has
Active/Stale with thresholds fixed at 72h for infra and 24h for
everything else, plus a Last Heard filter that selects nodes heard
**within** a window. Neither answers "show me what has been quiet for
over a week".

### What this adds

A `Silent for` select beside Last Heard: 1d, 3d, 7d, 14d, 30d, each
labelled with the number of nodes it would select.

```
[ All ] [ Active ] [ Stale ]   Last Heard: Any v    Silent for: over 7d (23) v
```

Counts are computed **before** the silence filter is applied, so the
dropdown keeps showing what the other windows would select instead of
collapsing to the one already chosen. The choice persists in
`localStorage` like the neighbouring filters and is mirrored into the
URL as `?silent=7d`, so the view can be pasted to whoever owns the
silent gear. The URL sync is wrapped in try/catch, because it is a
convenience and must never stop the filter working.

### The part worth reviewing: one definition of freshness, not two

`getNodeStatus` has been relay-aware since #1598, while `nodes.js`
separately computed `statusAge` from the ADVERT timestamp alone.
Filtering on the latter would have listed a repeater as silent for ten
days while its own badge on the same row said active, and it would have
done so for **exactly** the nodes #1598 exists to protect.

So the freshness rule is extracted into `window.getEffectiveHeardMs` in
`roles.js`, and `getNodeStatus` now calls it. Behaviour is unchanged,
there is now one source. Reviewers should look hardest at that refactor
rather than at the select.

A node never heard from at all scores `Infinity`, so it matches every
window instead of silently dropping out of the filter.

### Why the thresholds are fixed values and not derived

I measured the alternative before writing this, on a 1179-repeater mesh,
and posted it on #1611: replacing a fixed threshold with `3 x per-node
advert median` fixes 8 false "silent" flags and newly mis-flags **28
currently-active nodes**, because a 3h-median node gets a 9h threshold.
Raising the global default to 144h rescues 7 and hides 27 genuinely dead
repeaters. Both are net-negative. A user-chosen window sidesteps the
whole question: the operator picks what "too long" means for their mesh,
which is what @Jonher937 asked for in the first place.

### Verification

- `test-frontend-helpers.js`: **666 → 680 passed, 0 failed**. Fourteen
cases covering `NaN` rather than `0` when nothing is known (0 is a real
timestamp and would sort as very old rather than unknown), the full
`_liveSeen > _lastHeard > last_heard > last_seen` precedence, a recent
relay beating a stale advert, a stale relay **not** dragging a fresh
advert backwards, relay alone sufficing, `room` counting as infra while
`companion` does not (a `last_relayed` on a companion is meaningless and
must not rescue it), case-insensitive roles, the legacy `(role, ms)`
call shape, and the 72h boundary asserted at 71h and 73h.
- One of those failed on first run and **the test was wrong, not the
code**: `9e7` ms is 25h, which is correctly active for infra. Fixed, and
the boundary is now asserted explicitly so nobody repeats it.
- `eslint` on the changed files: 0 errors. The warnings present are the
same ones master already reports.
- No server change, no new API, no new column. No cache-buster bump
needed: `__BUST__` is substituted at startup in
`cmd/server/main.go:570`.

### Not done

**No alerting.** @fokcuk asked on the thread for notification when a
repeater they look after goes silent, which is a different product:
subscriber identity, an evaluation loop and delivery, none of which
exist today. That deserves its own issue and a design call rather than
being shimmed into `nodes.js`, which is also what the triage concluded.
This PR gives the operator the view; it does not push to them.

Sizes and boundaries (1d/3d/7d/14d/30d) are a judgement call. Say the
word if a different set fits real operator habits better.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 21:43:20 +02:00
efitenandSaarMesh-Bot 9d6f08c144 feat(#1865): ingest observer /neighbors as confirmed scope evidence (#1971)
Carries SaarMesh-Bot's implementation from the closed #1867 forward onto current
master, 67 commits later, and surfaces the result on the per-node Reach report.

The declared region list a repeater answers with is now stored on the node as
configured_scope, normalised to the same leading-# syntax default_scope already
uses so the two are directly comparable. That normalisation is the point
@cwichura raised on #1865 and @dborup agreed with before the original PR closed.

Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com>
2026-09-06 21:06:12 +02:00
n30nex 43d83ee8ca fix(nodes): dispose map timers with their owning view (#1970)
Red commit: `2f1a50e` (local Chromium: 2 passed, 7 assertion failures).
Ownership regression: `059ab49` (9 passed, 4 assertion failures). CI:
[run](https://github.com/Kpa-clawbot/CoreScope/actions/runs/33988139479)
awaits maintainer approval (`action_required`); 0 jobs started.

Rapid navigation or closing node detail could leave a delayed resize
targeting a removed or replacement map. Disposal now cancels its timer,
each resize captures its own map, and delayed responses respect the view
owning the current map. Stale side-pane responses are ignored before
rendering.

Fixes #1972.

- E2E assertion added: `test-issue-1206-resize-observer-leak-e2e.js:216`
and `:292`. This existing CI-selected suite covers navigation,
replacement deadlines, close/Escape, no-location rendering, and late
error/success responses. Existing observer-growth assertions remain
intact; readiness waits replace fixed sleeps.
- Browser verified: local fixture with real Chromium and Leaflet; 13
browser checks passed after push. Evidence:
`data/node-map-validation/post-push-browser.log` and
`data/node-map-validation/evidence.md`.
- Validation: frontend unit suites 99/18/666 passed; JavaScript syntax,
CSS variables, whitespace, PII and XSS checks passed. Backend unchanged;
Go suites not rerun.
- Independent adversarial, expert and TDD reviews found no required
changes. Two original navigation checks initially timed out; the
unchanged parent rerun passed 13/13 (`parent-browser-confirm.log`).
- Performance/config: one timer handle, no packet/node loops or new
requests. Tests enforce zero stale invalidations and one resize at the
surviving map's deadline. Existing 100ms delay retained; no new settings
or throughput claim.

Fix commits: `2492a65`, `1dc090d`.

## Preflight overrides

- External `run-all.sh` is unavailable. Scoped branch, red/green, PII,
CSS, XSS and whitespace checks were run directly; no migrations, SQL
attribution or image markup are added.
2026-09-06 21:04:09 +02:00
n30nex a938176f83 fix(nodes): clearly mark the selected node in path chains (#1968)
Red commit: `0988bc0` (local browser: 3 passed, 8 behavior assertion
failures before the fix).

The selected node now has a compact outline in long “Paths Through This
Node” chains, in the side panel and full detail page. Matching uses
complete public keys without case sensitivity; same-prefix siblings and
unresolved hops stay unmarked. Existing links, escaped names, warnings
and ambiguity underlines are preserved.

Fixes #1153. Its prerequisite #1144 is already merged.

- E2E assertion added: `test-issue-1146-path-link-contrast-e2e.js:220`.
The existing CI-selected harness passes 11 checks across 18-hop paths,
both themes, desktop/mobile, and the renderer fallback. Review follow-up
`bd8118f` verifies the marked ambiguous hop's dashed underline.
- Browser verified: `http://127.0.0.1:55635`; desktop/mobile path
screenshots were inspected. The broader smoke runner exited successfully
with fixture-dependent skips.
- Required frontend checks pass: 99 filter, 18 aging, 666 helpers. CSS
variables, seven CSS self-tests, 31 XSS sink checks, 17 XSS gate
self-tests and XSS diff preflight pass.
- Three independent reviews found no blocking issues. Traversal remains
linear with no new requests, settings, dependencies or cache
invalidation; styling uses the existing customizer token.

## Preflight overrides

- The external preflight runner is absent; corresponding scoped gates
passed. Red browser evidence is local, with upstream CI approval tracked
separately under the process in #1922.
- Existing rapid-navigation map resize timer errors remain visible in
browser logs and are outside this change.
2026-09-06 21:04:03 +02:00
n30nex 108ea020f7 fix(nodes): remove misleading aggregate SNR headlines (#1969)
Red commit: `2bf9be8` (local Chromium: 3 passed, 3 intended assertion
failures). CI:
[run](https://github.com/Kpa-clawbot/CoreScope/actions/runs/33988112224)
awaits maintainer approval (`action_required`); 0 jobs started.

Remove the unqualified aggregate Avg SNR row from node side-panel
Overview and full-detail stats, following option 3 in #1149. Heard By
retains each observer's SNR reading.

Fixes #1149.

- E2E assertion added: `test-issue-1281-location-row-e2e.js:224`. Three
new browser cases cover desktop side/full and mobile full views with a
numeric aggregate and distinct positive/negative observer readings.
Existing packet-location assertions remain intact.
- Browser verified: local Chromium; 6 cases passed after push.
Screenshots: `coverage/issue-1149/issue-1149-desktop-side-panel.png`,
`coverage/issue-1149/issue-1149-desktop-full-detail.png`, and
`coverage/issue-1149/issue-1149-mobile-full-detail.png`.
- Validation: packet filter 99/99, aging 18/18, frontend helpers
666/666; XSS, CSS-variable, syntax, whitespace and PII checks passed.
- Independent reviews: adversarial, lifecycle expert and TDD reviewers
found no required changes. One initial browser navigation timed out; the
unchanged parent rerun passed 6/6.
- Performance/config: two production row deletions; no new requests,
loops, timers, settings or customizer implications. Backend unchanged;
Go suites were not rerun.

Fix commit: `d7c68f3`.

## Preflight overrides

- External `run-all.sh` is unavailable on this host. Scoped branch,
red/green, PII, CSS, XSS and whitespace checks were run directly. The
diff adds no migrations, SQL attribution or image markup.
2026-09-06 21:03:58 +02:00
n30nex eb1d733998 fix(analytics): preserve selected hash size in links (#1967)
Red commit: `5a5ecb6` (local browser: 14 passed, 8 behavior assertion
failures before the fix).

Hash Issues links now restore `bytes=1|2|3` for the selected control and
its matrix/collision data. Missing or malformed values default to one
byte. Selector clicks, section/top links, tab-bar changes, filters and
theme refreshes retain the chosen view through the existing URL helper.

Fixes #1914.

- E2E assertion added:
`test-issue-1306-collisions-terminology-e2e.js:242`. The existing
CI-selected harness passes 23 checks, including distinct nonempty
collision rows for each byte size. Its original assertions remain.
- Browser verified: `http://127.0.0.1:55634` with the local fixture API,
plus reviewed matrix/risk screenshots. Region refresh passed; area
coverage skips because the fixture has no areas.
- Required frontend checks pass: 99 filter, 18 aging, 666 helpers; URL
helpers pass 18. Three independent reviews found no blocking issues;
their coverage suggestion is included in `fb482fe`.
- Added work parses URL state and updates six links. Rendering and bulk
requests are reused; no backend, configuration, dependency or CI-list
changes.
- A broader smoke run timed out at Live autocomplete (#1110); full-suite
success is not established.

## Preflight overrides

- The external `run-all.sh` is absent. Corresponding scope, PII, syntax,
whitespace and CSS checks passed; no SQL, migration or image changes
require those gates.
- Red browser evidence is local. Upstream CI execution remains a
separate approval gate, as discussed in #1922.
2026-09-06 21:02:14 +02:00
TeTeHacko 6ae7971da0 test(#1356): assert the rendered label, not where identifiers sit in map.js (#1933)
Follow-up to the review on #1912, where this assertion cost a round
trip. Independent of that PR — this branch is off current `master` and
touches no code path it changes.

## The problem

`#1356 V3.e`, `V3.f` and `V3.g` all describe what
`makeRepeaterLabelIcon` **produces**, but all three assert it by
grepping `public/map.js`. V3.e also bounds the distance between two
identifiers:

```js
assert(/MB_GLYPHS\[[^\]]+\][\s\S]{0,200}shortHash|shortHash[\s\S]{0,200}MB_GLYPHS\[/.test(mapSrc),
  'makeRepeaterLabelIcon prepends MB_GLYPHS glyph to the hash text');
```

Three separate failure modes, all observed:

**1. It fails on edits that change nothing.** #1912 inserts one variable
declaration in that function; the markup is byte-identical and the build
went red.

**2. It cannot tell code from prose about code.** My first attempt at
fixing #1912 added a comment explaining the constraint — and the comment
mentioned both identifiers, so it satisfied the grep by itself. With
that comment present I moved the hash assignment away from the glyph,
reintroducing the exact defect, and the test still reported green. A
check that a comment can satisfy is worse than one that is merely
brittle.

**3. It does not assert the thing it is named after.** On `master` the
match is not the declaration order at all. It is `MB_GLYPHS[...]`
reaching the *later* `shortHash` inside `ariaStatus`, 212 characters
downstream. Whether the glyph is actually prepended to the hash is
incidental to whether this passes.

That third point also corrects something I said on #1912, and it
corrects it against myself: both the 312 you quoted and the 299 I
"corrected" it to are the distance between the two **declarations**,
which is not the distance the regex uses. Measuring the one it does use:

| tree | `MB_GLYPHS[` → next `shortHash` | assertion |
|---|---|---|
| `master` | 212 | pass |
| #1912 before the fix | 277 | fail |
| moving `unknownWidth` below the glyph | **343** | fail |
| moving `shortHash` below the glyph | 54 | pass |

So moving `unknownWidth` down does not merely fall short — it makes the
gap *worse*, because it lands between the glyph and `ariaStatus`. My
earlier "233, still 33 over" was the wrong metric on the wrong pair.
Apologies; the conclusion happened to hold but the reasoning did not.

## What this does

Loads `map.js` in the same DOM-less `vm` sandbox
`test-map-clustering.js` already uses, exposes `makeRepeaterLabelIcon`
through the existing `window.__meshcoreMapInternals` hook, and asserts
the emitted markup:

- glyph, `U+2009` thin space, hash — in that order and adjacent;
- no glyph and no thin space when there is no multi-byte status;
- `aria-label` exactly `multi-byte <status>, hash <ID>`, and `repeater
hash <ID>` without one;
- the visible span carries `aria-hidden`.

No browser, so it stays in the JS-unit-tests step rather than moving to
Playwright.

## Mutation-tested, not eyeballed

| mutation | old V3.e/f/g | new |
|---|---|---|
| glyph moved after the hash | **all silent** | caught |
| plain space instead of `U+2009` | **all silent** | caught |
| span loses `aria-hidden` | V3.g caught | caught |
| `aria-label` loses its comma | **all silent** | caught |
| 200 chars inserted between the identifiers (no behaviour change) |
V3.e **fails** | passes |

Full JS unit list from `.github/workflows/deploy.yml`: 65/65.

## Notes for review

- V3.a–V3.d (MB_GLYPHS definitions, CSS variables, the border rule) are
left as source/CSS greps. The glyph values are now covered implicitly by
the rendered-output assertions, but converting the CSS ones needs a
different approach and did not belong here.
- The sandbox loader has no `try`/`catch` that warns and continues. If
`map.js` stops loading, the suite must fail rather than silently skip
every assertion below it.
- If this lands, the ordering comment in #1912 becomes obsolete and I
will drop it there. I deliberately did not touch it from this branch so
the two do not conflict textually.
2026-09-05 15:05:43 +02:00
TeTeHackoandClaude Opus 5 75dfa1f4fb fix(map): render an unobserved hash size as unknown, not as 1 byte (#1912)
## The bug

`map.js` turns a missing `hash_size` into `1`:

```js
var hs = node.hash_size || 1;
```

That field is **evidence**, not a default — `computeNodeHashSizeInfo`
populates it only from adverts it could read a size out of, so a node
with no countable advert in the retention window has no value at all.
Rendering that absence as `1` states a 1-byte configuration nobody
observed, and it does so in the one place where a reader is most likely
to act on it.

It is also inconsistent with the rest of the UI for the *same field on
the same node*:

| view | code | renders |
|---|---|---|
| node detail | `nodes.js:683` | `Hash Prefix: **Unknown**` |
| analytics prefix table | `analytics.js:1553` | `(**?**B)` |
| map popup + label + filter | `map.js:140`, `:1588`, `:1775` | `C8
**(1B)**` |

On analyzer.meshcore.cz right now, **701 of 1007 nodes** have
`hash_size: null`, so the map's 1-byte bucket is mostly nodes that were
never measured. The Byte Size filter has the same problem from the other
end: picking "1-byte" returns measured 1-byte nodes *and* every unheard
node, which makes it hard to use for the thing it exists for.

## The fix

- `roles.js`: shared `hashPrefixInfo(node)` → `{known, bytes, prefix}`,
so the map stops re-deriving the prefix in three places and the
"unknown" rule lives in one.
- `map.js`:
- **label** still draws a 1-byte prefix (it has to draw *something*) but
carries `.hash-unconfirmed` and its `aria-label` says `…, hash size
unknown`;
  - **popup** says `Unknown`, matching `nodes.js` wording;
- **filter** gets its own `Unknown` bucket instead of folding unmeasured
nodes into 1-byte.
- `style.css`: dotted underline for the unconfirmed prefix — a shape cue
rather than a colour one, so it survives forced-colors and colour-vision
differences, consistent with the #1356 approach for these labels.

`nodes.js` and `analytics.js` are left alone: they already behave
correctly, and switching them to the helper would widen the diff without
changing behaviour. Happy to do it in a follow-up if you'd rather have
the call site count at zero.

## Tests

`node test-frontend-helpers.js` → **635 passed, 2 failed**; the two
failures are `favStar`, pre-existing on master (baseline run before this
change: 625 passed, 2 failed — same two).

10 new cases: `hashPrefixInfo` across missing / null / 0 / 1 / 2 /
3-byte inputs plus missing pubkey and a null node, and a guard asserting
`map.js` contains no bare `hash_size || 1` so this cannot quietly come
back.

## Browser validation

Headless Chromium against a live instance carrying real mesh data, same
viewport (`#/map?lat=50.038502&lon=14.570556&zoom=17`), unpatched vs
patched:

| | before | after |
|---|---|---|
| `aria-label` | `repeater hash C8` | `repeater hash C8, hash size
unknown` |
| label class | `mc-mb-label` | `mc-mb-label hash-unconfirmed` |
| filter buttons | `all,1,2,3` | `all,1,2,3,unknown` |

The four nodes in that viewport that *do* have evidence (`157E`, `381E`,
`FA74`, `C029`) render exactly as before.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:59:20 +02:00
efitenandClaude Opus 5 83134d6e70 fix(#1896): say that the naive-clock notice clears itself (#1962)
Closes #1896.

The banner told operators their clock is naive but not that the notice
goes away on its own, so people went looking for #1480 to find out. One
sentence:

> Clock is naive — per-packet timing clamped to ingest time. **Clears
itself 24h after the last skew event.**

## Verified before writing it into the UI

The issue asserts the 24h self-clear. Rather than repeat that, I checked
it:

- `cmd/server/observer_naive_clock.go:8` — `const
observerNaiveClockWindow = 24 * time.Hour`
- `applyObserverNaiveClock` applies the decay at read time and leaves
`clock_naive` false once the last event is older than the window
- its own comment: *"any event older than observerNaiveClockWindow is
treated as absent so the chip and banner clear automatically without a
background sweep"*

So "24h after the last skew event" is accurate, including the fact that
it needs no sweep and no restart.

No test pinned the old string.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 17:05:01 +02:00
efitenandClaude Opus 5 e6b31fc764 fix(#1927): stop plotting the usefulness composite on the traffic axis (#1960)
Closes #1927.

The scatter's **"Traffic share"** axis and the **"Traffic"** table
column both fell back to `usefulness_score` when `traffic_share_score`
was absent, with nothing marking the substitution. Two points on one
axis could therefore measure different things.

## They really are different metrics

Checked rather than assumed, in `cmd/server/usefulness_composite.go`:

```go
node["traffic_share_score"] = trafficRaw   // :147  the single traffic axis
node["usefulness_score"]    = composite    // :152  0.30*bridge + 0.25*coverage + ...
```

`openapi.go:178,182` describes them the same way. So the fallback put a
**composite** under a column and an axis that both promise share of
non-advert traffic.

Worth flagging, because it is easy to conclude the opposite: **#1456,
which introduced the fallback, was a rename of the display label** from
"Usefulness" to "Traffic share". That makes the two field names look
interchangeable, and I nearly stopped there. They are not: #672 later
gave `usefulness_score` its own composite meaning.

## Fix

Your first preference in the issue: drop the fallback rather than mark
it or relabel the axis.

A node with no `traffic_share_score` now reads as unknown, so the table
shows an em dash and the point is dropped from the plot by the existing
`plottable` filter (`analytics.js:2728`), exactly the way a node with no
bridge score already is. **No other change was needed** for that, which
is what makes this the cheap option of the three.

## Tests

The unit test that pinned the old behaviour is updated rather than
deleted, so the expectation is now recorded the right way round:

```js
assert(mapped[1].traffic === null && mapped[1].fav === false,
  'a node with only usefulness_score has no traffic value; it must not be substituted');
```

`test-repeater-metric-scatter.js`: 31 passed, 0 failed.

Also corrected a comment above `_toScatterPoints` that still documented
the removed fallback chain.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 17:04:48 +02:00
efitenandClaude Opus 5 9488e9c31c fix(#1898, #1900): carry observer_id into replayed packets (#1957)
Closes #1898. Closes #1900.

Two issues, one omission.

With a region filter active, VCR replay on the Live map rendered
**nothing at all** (#1898), and the Replay button on packet detail
**silently did nothing** (#1900).

## Cause

`packetMatchesRegion` (`public/live.js:80-92`) matches a packet group by
looking up `packets[i].observer_id` in the observer roster map. A packet
whose `observer_id` is null is skipped, and when none match it returns
`false` and the caller drops the whole group (`live.js:3374`).

`dbPacketToLive()` returned `observer` (the resolved name) but never
`observer_id`. So every replayed packet was skipped, and every group was
dropped. The Replay button had the same gap: both branches passed
`obsName(o.observer_id)` and threw the id itself away.

**The value was there the whole time.** The VCR builds its entries with
`Object.assign({}, p, obs, ...)`, so the observation's `observer_id` is
on the input, and the server has returned `observer_id`, `observer_name`
and `observer_iata` per packet since `cmd/server/db.go:345-347`. Only
the object literal dropped it.

## Fix

Carry `observer_id`, and `observer_iata` alongside it so
`obsIataBadgeHtml` (`live.js:102-108`) can use the direct field for
replayed packets instead of falling back to the roster map.

Three lines of behaviour, in two files.

## Verification

Four regression tests in `test-live-region-filter.js`. **Three fail
without the fix**, checked by reverting `live.js` and re-running:

```
❌ #1898: dbPacketToLive carries observer_id through
❌ #1898: a replayed packet survives an active region filter
✅ #1898: dropping observer_id is what broke it (guards the regression)
❌ #1898: observer_iata is carried so the badge needs no roster lookup
```

The one that passes either way does so on purpose: it asserts a packet
carrying **no** `observer_id` is still dropped, pinning the mechanism so
a future change cannot make the filter match everything.

That test's sandbox needed `getParsedDecoded` and `getParsedPath`.
`live.js:14` captures those from `packet-helpers.js` at load time and
the sandbox does not load it, so they are stubbed in the sandbox
definition rather than assigned afterwards. Assigning later is too late
for that capture, which cost me two attempts.

Other suites unaffected: `test-live.js` 95 passed,
`test-packet-filter.js` 99, `test-frontend-helpers.js` 656.
`test-1110-live-filter.js` fails identically on unmodified master with
`ERR_CONNECTION_REFUSED`; it is an E2E test needing a server on port
13581.

## Note

Both issues were filed separately and neither names the other. They are
the same root cause in sibling code paths, which is why they are fixed
together rather than in two PRs.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:03:08 +02:00