mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 02:40:07 +00:00
master
3046
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1ae4c74913 |
feat(observers): rolling 1 h TX airtime and a duty-cycle warning (#661) (#2208)
Fixes #661 (the narrowed scope from the 2026-10-14 comment). ## What changes - `GET /api/observers/metrics/summary` adds `tx_airtime_pct_1h` and `rx_airtime_pct_1h` per observer: the airtime share over the hour before now, whatever `window` is, from the cumulative `tx_air_secs` / `rx_air_secs` counters in `observer_metrics`. - A counter that goes down marks a reboot; only the stretch after the last reset counts. - Under 15 minutes of samples (one sample, or a reboot late in the hour) returns `null`. - Clamped to 0..100, rounded to 2 decimals. - The response also carries `duty_cycle_warn_pct`, from the new config key `dutyCycleWarnPct` (default 10, the EU 868 MHz g3 sub-band limit). Added to `config.example.json`. - RF Health tiles show `TX 1h: x%`; the observer list gets a sortable numeric `TX 1h` column. Both use the shared `public/observer-airtime.js` helper and flag values at or over the threshold with `.airtime-over` (`var(--status-red-text)`). - The handler now returns a named `MetricsSummaryResponse` struct instead of `map[string]interface{}` (one occurrence fewer). ## Why the warning matters Repeater and room-server firmware default to `airtime_factor = 1.0` (`examples/simple_repeater/MyMesh.cpp:890`), which `Dispatcher.cpp:26` turns into a 50% TX budget per 1 h window. The device therefore does not keep itself under the EU 10% limit; this view is where an operator can see it. ## Cost - Server: one extra query per summary request, `SELECT observer_id, timestamp, tx_air_secs, rx_air_secs FROM observer_metrics WHERE timestamp >= now-1h`, on the existing `idx_observer_metrics_timestamp`, then one O(rows) pass. On our instance that is 35 observers x about 12 samples (5 min interval). - Observer list: one extra bulk call `summary?window=1h` per load (client TTL 30 s, failure ignored), not a call per observer. ## Tests - Go: `TestRollingAirtimePct` (steady, reboot mid-hour, single sample, span under 15 min, reboot leaving under 15 min, nil counters, clamp, rounding), `TestGetMetricsSummaryAirtime1h` (samples older than 1 h ignored inside a 24 h window, single-sample observer gets null), `TestMetricsSummaryDutyCycleWarnPct` (default 10, config override, `observers` is `[]` not `null`). Full `cmd/server` suite passes locally (skipping `TestSaveGeoFilterPreservesFileMode`, which fails on Windows on clean master too). - Frontend unit: `tests/unit/test-issue-661-observer-airtime.js` (8 assertions, real file in `vm`), added to `test-all.sh`. `test-observers-headings.js` updated for the new column. Full `test-all.sh` passes locally. - E2E: `tests/e2e/test-issue-661-airtime-e2e.js`, registered in `deploy.yml` and `scripts/non-unit-tests.json`. The fixture DB has no `observer_metrics` table, so the summary is served through `page.route` with known values for the fixture's own observer ids. It checks the column header, over/under/unknown cells, sort order and the RF Health tile. 4/4 locally. It caught a real bug during development: `loadRFHealthData` declares a local `const window`, so the tile calls the helper as the global `ObserverAirtime`. ## Validation Deployed to a staging instance with live MQTT data (35 observers with metrics) and checked in the browser: - The observer list shows the column for 42 rows (33 with a value, 9 without enough samples). Sorting puts the highest share (5.3%) first. - RF Health shows `TX 1h` on all 35 tiles. With a fourth stat the line wrapped inside items on narrow tiles; the stats row now wraps between items only. - No observer was over 10% at the time, so the red state was checked by rendering one cell client-side at 12.5% (computed colour `rgb(185, 28, 28)` in light mode), and by the E2E test. ## Not done - The threshold is config-only; exposing it in the customizer is a follow-up. - No airtime comparison across observers (that is #671) and no per-packet airtime (Relay Airtime Share covers it). - Dark mode was not checked in the browser; `--status-red-text` is defined for both themes. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7c7c510fb8 |
docs(readme): correct stale claims and add the optional features (#2205)
## Summary The README is the repo's front page. I checked each claim against `master` and fixed what no longer holds. | README said | Now | Source | |---|---|---| | `POST /api/packets` for manual injection | route is GET only; points at `POST /api/decode` | `cmd/server/routes.go:317`, `:320` | | `PORT` env var overrides the port | not read anywhere; documents `port` and `-port` | `cmd/server/main.go:96` | | 5 store indexes | 7 (`byPathHop`, `byPayloadType` added) | `cmd/server/store.go:222-231` | | 11 analytics tabs | 17 `data-tab` values | `public/analytics.js` | | VCR up to 4× | up to 8× | `public/live.js:681` | | 14 Go modules | 16 `go.mod` files (also fixed in the `Makefile` header comment) | | | 327 / 53 / 380 Go tests, 150+ Node, 49 Playwright | 1827 / 710 / 2742 `func Test`, 199 unit files, 131 `test(` calls; counts removed so they stop going stale | | | live instance + Swagger on analyzer.00id.net | times out (from two networks, 2026-10-13); links analyzer.on8ar.eu, `/api/docs` and `/api/spec` return 200 there | curl | Added: Scope Audit in the feature list, and an "Optional Features" section for user accounts and mobile RX coverage, each linking its existing doc. ## Not checked - the performance figures (~300 MB for 56K packets, < 1 ms queries) - the "AES-128-ECB with rainbow table" line - whether the screenshots still match the current UI - the "-or-later" in the license line (`LICENSE` is GPL v3) Docs only, no tests run. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fbeb9e4d71 |
ci: frontend coverage from every E2E suite, merged across the shards (#2204)
Fixes #2203. ## Problem The frontend coverage badge (35.16% on run 38413268001) came from two sources on shard 2: one dump part-way through `test-e2e-playwright.js` and the `collect-frontend-coverage.js` tour. The other E2E suites run against the instrumented build but never save `window.__coverage__`, including the suites written for single pages (`path-inspector.js` 4.72%, `touch-gestures.js` 14% on that run). ## Change 1. `9d3bfad5` `test(e2e): red:` `tests/unit/test-e2e-coverage.js`, with fake pages, contexts and browsers (no Chromium needed). On the placeholder module the first assertion fails. 2. `c00bbb12` `feat(e2e):` the collector. - `scripts/e2e-coverage-hook.js` is a preload (`node --require`) that wraps `chromium.launch` from `playwright`. `playwright` re-exports `playwright-core`, so a suite's own `const { chromium } = require('playwright')` gets the same, wrapped object. - `scripts/e2e-coverage.js` reads `window.__coverage__` before every call that replaces or ends a page's document: `goto`, `reload`, `setContent`, `goBack`, `goForward`, `close`, and context and browser `close`. Pages from `newPage`, `newContext` and the context `page` event are all tracked. - Each read zeroes the page's counters, so a document read twice is not counted twice. Maps travel once per file; later reads carry counts only. - The page returns a JSON string. Returning the object cost about 10 s per suite, because Playwright serialises it node by node. - A read is given up after 5 s, so a busy page cannot hold up a suite's cleanup. - On exit the hook writes one file per suite to `.nyc_output/`, or nothing without coverage. A failed write is logged and never changes the exit code. 3. `64487c4c` `ci:` the workflow. - On master pushes the E2E run step sets `NODE_OPTIONS=--require ./scripts/e2e-coverage-hook.js`. On PRs it is empty, so PR runs are unchanged. - Each shard runs `nyc merge` (one file per suite is about 6 MB, mostly the same maps) and uploads `coverage-shard.json` with its results instead of `.badges/`. - The `e2e-test` job merges the three shards and makes the frontend badge (it used to come from shard 2 alone). - The merge, setup-node and badge steps are `continue-on-error`. When `nyc report` gives no statement percentage the step warns and writes no badge, so a registry hiccup cannot publish 0%. - `test-e2e-playwright.js` no longer writes its own `e2e-coverage.json`: the hook covers that suite completely, including the tests after the old dump. `collect-frontend-coverage.js` stays as an extra source. ## Performance Measured locally against an instrumented server (Windows, Chrome): | Suite | Without hook | With hook | |---|---|---| | `test-path-inspector-coverage-e2e.js` | 1.9 s | 2.2 s | | `test-touch-gestures-coverage-e2e.js` | 17 s | 16 s | | `test-e2e-playwright.js` (stops at the same local-fixture failure in both runs) | 52 s | 52 s | | `browser.close()` on a page in a 30 s busy loop | 153 ms | 5.2 s | The hook only loads on master pushes. ## Testing - `tests/unit/test-e2e-coverage.js` covers: - reads before each document-replacing call and before close; - pages opened by the page itself; - context and browser close; - summing without double counts; - a failed read, and a read that never answers; - the file writer, including a failing write. - Nine deliberate breaks of the library each turn it red: - dropping the counter reset; - dropping the `page` event, the `goto` read, the `goForward` read, the `close` read or the browser `close` read; - removing the timeout or the write guard, or writing an empty store. - The test also fails if it stops before its end. Without that check, a read that never returns would let node exit 0 half-way. - Real suites with the hook, measured on their own (the badge run had all suites): | File | Badge run 38413268001 | Own suite with the hook | |---|---|---| | `path-inspector.js` | 4.72% | 52.75% | | `touch-gestures.js` | 14% | 72.96% | - Exit codes and pass counts were unchanged with the hook for these suites (the first three were run before the 5 s read timeout was added): - `test-slideover-1056-e2e.js` (6 contexts); - `test-a11y-axe-1668.js` (`@axe-core/playwright`); - `test-issue-1150-404-state-e2e.js`; - the three above. - The badge step, run locally against two merged shard files: - It gives the same per-file numbers as `nyc report` on the inputs. - With no shard coverage, or with `nyc report` failing, it warns and writes no badge. - `sh test-all.sh` passes locally, apart from `test-issue-1956-release-routing.js`, which needs the workflow's `GITHUB_REF_NAME` and was skipped. The test inventory classifies 321 suites. `actionlint` 1.7.7 reports only the self-hosted runner label, as on master. **Not tested:** the shard and badge steps in CI. They only run on a master push, so the first master run after merge is the real test. I do not know what the merged number will be. I will compare the badge with the merged report in that run's `e2e-test` log. **Not counted:** a full page load the suite does not start through one of the wrapped calls, such as a link that leaves the SPA or `location.reload()` from page code. ## Not in this PR - The `tests/unit/` suites, which load frontend files through `vm.createContext` without instrumentation (noted in #2203). - Coverage on PR runs. The overhead above is small, so this could follow if wanted. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
59708ee72e |
ci: publish the README badges to a badges branch with GITHUB_TOKEN (#2202)
Fixes #2136. ## Problem The README badges are stale: `e2e-tests` shows 821 passed (1161 when #2136 was filed), both Go coverage badges have not changed since 2026-03-30 and frontend coverage since June. There are two causes. 1. **The PAT.** The `publish` job wrote the badge JSON to `master` through the Contents API with `BADGE_PUSH_TOKEN`, a PAT that could push past the branch protection on `master`. That token returns 401: run 38360160932 on 2026-10-13 logs `gh: Bad credentials (HTTP 401)` for every badge. The step is `continue-on-error` and only echoed `Failed to update`, so every run stayed green. 2. **Old badges inside `e2e-badges`.** `.badges/` is tracked on `master`, so every e2e shard checkout and the aggregate checkout already hold the old files. The shard upload and the aggregate `cp` shipped them in `e2e-badges`, and `publish` downloaded `e2e-badges` after `go-badges`, so the old values overwrote the fresh ones. In run 38407143126, `go-badges` holds 85.8% and 77.1%, while `e2e-badges` holds 85.4%, 70.2% and frontend 36.64%, the same as `master`'s `.badges/`. Fixing only the token would have left three of the four README badges frozen. ## Change 1. `5f430966` `ci:` the `publish` job pushes the badges to an unprotected `badges` branch with its own `GITHUB_TOKEN`. - Job-level `permissions: contents: write`. No other job gains a permission. The checkout uses `persist-credentials: false`, since the push passes its own token. - The branch is a single orphan commit, force-pushed, so it does not grow. Master push runs are already serialised by the workflow `concurrency` group. - Each run starts from the branch as it is and overwrites only the badges it downloaded, so a missing artifact keeps those badges at their previous value. The first time, when `git ls-remote --exit-code` reports the branch absent (exit 2), it starts from `master`'s `.badges/`. - No downloaded artifacts, or any other `ls-remote`, fetch, extract, commit or push failure, ends in a `::warning::` in the run summary and leaves the branch alone. The step stays `continue-on-error`, so a badge problem never turns the pipeline red. - `go-badges` is downloaded after `e2e-badges`, so its files win. 2. `2134c1e5` `docs:` the four README badge URLs read `.../CoreScope/badges/<name>.json` instead of `.../master/.badges/<name>.json`. 3. `47e57482` `ci:` the frontend-coverage step in the e2e shards and the aggregate badge step start from an empty `.badges/`, so `e2e-badges` carries only what the run produced. This also stops shard 3's old `frontend-coverage.json` from replacing the one shard 2 generates. No PAT is involved any more, so nothing expires. ## Testing - The publish step script, taken from the YAML, run against a local bare repository standing in for `origin`: | Run | Downloaded | Result on `badges` | |---|---|---| | 1, branch absent | a, b | a, b new; c from `.badges/`; one commit | | 2 | c only | a, b kept from run 1; c new; still one commit | | 3 | none | `::warning::`, branch unchanged, step exit 0 | | 4, `origin` unreachable for the read | a | `::warning::`, branch unchanged, step exit 0 | | 5, push to a missing remote | a | `::warning::`, branch unchanged, step exit 0 | - The artifacts of run 38407143126 land flat in the download path, so `badges-new/*.json` matches them. - `shellcheck` at style level on the step script: no findings. - `actionlint` 1.7.7 (shellcheck off): one finding, the self-hosted runner label `meshcore-runner-2`, which `master` reports as well. **Not tested:** the e2e change and the push with a real `GITHUB_TOKEN`. Both only run on a push to `master`, so the first master run after merge is the real test. The `badges` branch already exists (`80fda6c0`, a copy of `master`'s `.badges/`), so the README badges keep working from the merge on, with the old values until that run. After it, I will check the `badges` branch contents against that run's go and frontend numbers, the step log and the rendered README. ## Not in this PR - Removing the now unused `.badges/` from `master`: the first run seeds the branch from it, so that goes in a follow-up once the branch exists. - Deleting the `BADGE_PUSH_TOKEN` secret: that needs a repository admin. - `frontend-tests.json`: no CI step produces it and no README badge uses it. It is carried over to the branch unchanged. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b8e571c9c5 |
perf(store): build the relay-filtered candidate lists once (#2195)
## Problem `relayCandidates` runs once per hop of every observation the cold loader resolves, and again from the direct-heard and neighbor analytics. Once any listener-only observer was marked (#1290), every call built a filtered copy of the candidate `nodeInfo` structs, even when none of the candidates was a listener. On a week of a busy regional network that accounted for 1.1 GB of the 5.4 GB allocated over one startup, the biggest single source of startup churn. ## Change `markNonRelay` now builds a map once, holding the filtered list for each prefix whose candidates include a listener. A listener can only be in the lists of its own prefixes and its full key, so only those are filtered: at most 9 lists per listener, not every list in the prefix map. `relayCandidates` returns either that list or the prefix map's own list, without copying. Every caller only reads the slice (checked: direct-heard, neighbor persist, node hop analytics and `resolveWithContext`). ## Measured on a copy of that week | | before | after | |---|---|---| | allocated over startup | 5.44 GB | 4.32 GB | | server CPU over the run | 175.6 s | 169.1 s | | peak RSS | 1074 MB | 1077 MB (unchanged; the GC limit sets it) | ## Cost per rebuild against cost per lookup `markNonRelay` now does once per node cache rebuild (as often as every 30 s, `nodeCacheTTL`) the filtering that master did on every `relayCandidates` call. 5,000 repeaters and 3 listeners, median of 6 runs (`BenchmarkMarkNonRelay5000`, `BenchmarkRelayCandidates256`): | | master | first version of this PR | this PR now | |---|---|---|---| | `markNonRelay`, once per rebuild | 0.59 µs, 320 B, 3 allocs | 14.8 ms, 6.57 MB, 36,285 allocs | 20 µs, 20.6 KB, 33 allocs | | one `relayCandidates` lookup | ~7.8 µs, 6.7 KB | ~30 ns, 0 B | ~30 ns, 0 B | | 256 lookups (one per 2-character hop) | 2.0 ms, 1.71 MB, 1,453 allocs | 7.7 µs, 0 B | 7.7 µs, 0 B | The extra ~20 µs per rebuild is paid back after about 3 lookups. Every ingested observation looks up each hop of its path, so a running server makes far more than that between rebuilds, and a cold load makes millions (the 1.1 GB above). ## Tests - `TestRelayCandidatesDoesNotAllocate`: zero allocations per call, and listeners still filtered. - `TestRelayCandidatesAfterSecondMark`. - `TestMarkNonRelayVisitsOnlyListenerPrefixes`: at most 80 allocations for 3 listeners among 5,000 repeaters (36,285 before the fix). It has its own red commit. - `BenchmarkMarkNonRelay5000` and `BenchmarkRelayCandidates256` give the table above. - `TestMarkNonRelayMatchesFilteringEachList`: every prefix of the 5,000-repeater map matches filtering its list on the spot. - Red commit first, then green. - `go test ./cmd/server` passes. No config or UI changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8d2ae405e8 |
perf(store): cache an observation's parsed time in 8 bytes (#2196)
Refs #2148. ## Problem `StoreObs` is the store's most numerous struct: 400k of them on a week of a busy regional network. Its lazy timestamp cache (#1481) is a `time.Time`, a `sync.Once` and a `bool`, together 40 bytes. On current master that makes the struct 200 bytes, which puts every observation in the 208-byte size class. ## Change The cache is now a single `atomic.Int64`: - `0` before the first call; - the parsed time as UTC nanoseconds after it; - or one of two sentinels: one for empty or unparseable strings, and one for times it can't hold exactly (a non-UTC offset, or outside `UnixNano`'s range). Those are re-parsed on each call so their location is preserved. Concurrent first calls may both parse; they store the same value. `StoreObs` is now 160 bytes, in the 160-byte size class: 48 bytes × 400k observations ≈ **19 MB** less heap. The first parse costs the same; cached reads now build the `time.Time` from an int. ## Tests - `TestStoreObsFitsIn160Bytes`: red commit first. - `TestStoreObsParsedTimeMatchesParse`: Z, nanosecond, offset, SQL, empty, garbage and epoch inputs give the same time and location as `time.Parse`, on the first and the cached call. - `TestStoreObsParsedTimeConcurrentFill`. - The existing `storeobs_parsedtime_test.go` still passes. - `go test ./cmd/server` passes, and `-race` passes on the ParsedTime, retransmission and observer analytics tests. ## This PR or #2149 #2149 fixes #2148 by reordering these same three fields. The two conflict, so here is how they compare. Sizes are `unsafe.Sizeof(StoreObs{})` on each branch, with Go's heap size class beside them: | | struct | heap per observation | saved per observation | saved at 400k observations | |---|---|---|---|---| | master | 200 B | 208 B | – | – | | #2149 (field reorder) | 192 B | 192 B | 16 B | ~6.4 MB | | this PR (one 8-byte field) | 160 B | 160 B | 48 B | ~19 MB | - **#2149:** a pure field reorder with no change in behaviour. It is the smallest, safest change and the easiest to review. It saves a third of what this PR does. - **This PR:** three times the saving, but more code. It adds two sentinel values, and re-parses on each call any timestamp with a non-UTC offset. The store writes UTC (`Z`), so that path is rare. It is tested against `time.Parse` on edge cases, concurrently, and under `-race`. Options: 1. **Take this PR.** #2149 then has nothing left to reorder. 2. **Take #2149 now, and this PR after it** as a follow-up for the further 32 bytes per observation. I'll rebase onto #2149 if it lands first. 3. **Take only #2149** if the smaller, simpler change is preferred. No config or UI changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
478f74a452 |
fix(config): write the geo filter through a symlinked config.json (#2137)
Found while reworking #2125, unrelated to that PR's change. In the Docker image the entrypoint symlinks `/app/config.json` to the bind-mounted `/app/data/config.json`. `SaveGeoFilter` takes the first path `os.Stat` resolves, so `configPath` stays the symlink, and the tmp+rename then **replaces the link with a regular file inside the container**: - the mounted `config.json` keeps the old `geo_filter`, so the filter the operator just saved appears nowhere on the host; - server and ingestor read the detached file from then on, so the UI keeps showing the new filter and nothing looks wrong; - the next container start reverts it: the entrypoint's `ln -sf` puts the link back, and re-creating the container drops the file anyway. **Fix:** resolve a symlink at `configPath` before the write, so the save lands in the file it points at and the link stays a link. Only a regular file is followed — a link to a device, fifo or directory is left alone and handled as before. This is deliberately the opposite of `writeStatsAtomic` (refs #1170), where replacing a destination symlink is the point because the default stats path can be world-writable `/tmp`. `config.json` is different: the link is one the image itself creates, and the directory is not world-writable. Happy to narrow it further (e.g. only follow a link that stays inside `configDir`) if you'd rather keep the two paths consistent. **Verification:** `TestSaveGeoFilterSymlinkedConfig` fails on master — ``` --- FAIL: TestSaveGeoFilterSymlinkedConfig config_symlink_test.go:47: .../config.json is no longer a symlink (mode -rw-------) — the rename replaced it config_symlink_test.go:60: geo_filter missing from .../data/config.json: {"port":3000,"_comment":"keep me"} ``` — and passes here. `TestSaveGeoFilterKeepsOwner` covers keeping the file's owner, and `TestSaveGeoFilterFifoSymlinkTarget` checks that only regular files are followed. `go test ./...` in `cmd/server` passes. **Single-file mounts:** when `config.json` is mounted as a single file, the save now fails with 500 (`EBUSY`) instead of writing a copy that the next start discards. Our own instance has no geo filter configured, which is why this went unnoticed there; its `/app/config.json` is still a symlink. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e53c055d6e |
perf(store): share one copy of each observer string across observations (#2183)
Part of #1870: this interns the three fields the issue names (`ObserverID`, `ObserverName`, `ObserverIATA`), plus `Direction`. ## Problem Each `StoreObs` kept the row scan's own copy of its observer ID (64 hex characters for most observers), observer name, IATA code and direction. A busy regional network's 7-day window holds about 400k observations from a few hundred observers, so a few hundred distinct values were stored hundreds of thousands of times. ## Change - `internStr` returns the canonical copy of each value from a map guarded by an `RWMutex`, so every observation shares one copy. - The copies are held for the life of the process. That is what makes the sharing survive garbage collections. An earlier version of this PR used `unique.Make`, which holds its copies weakly, so a value interned again after a GC came back as a second copy (thanks to the review for catching that). - Observer strings number in the hundreds. The map is capped at 65,536 entries as a guard (AGENTS.md: no unbounded structures); past the cap a string is returned as it came, so nothing breaks. - A hit takes only the read lock and allocates nothing. A new value is cloned, so it doesn't pin a larger buffer it may be a slice of. - All five places that build a `StoreObs` use it: `Load`, `loadChunk`, `scanAndMergeChunk`, and both ingest paths. - The ingest paths intern while scanning rows, **before** taking `s.mu`, so interning adds nothing to the time API readers wait on the store. - `cmd/server/go.mod` stays at `go 1.22`. The earlier 1.27 bump was only there for `unique`, and is reverted here; the module upgrade belongs in its own PR. ## Scaling `BenchmarkInternStr` (a hit, the steady state): | | alone | 12 cores (per call) | allocs | |---|---|---|---| | plain `Mutex` map (the first version of this PR) | 20 ns | 57 ns | 0 | | `unique` (the second version, withdrawn) | 48 ns | 8 ns | 0 | | **`RWMutex` map (this PR)** | 17 ns | 26 ns | 0 | ## Measured on a copy of that window (`GOMEMLIMIT=750MiB`) The same database and harness, one run each, the `unique` version against this one: | | `unique` version | this PR | |---|---|---| | live heap at the last GC after load | 581 MB | **535 MB** | | string bytes allocated by row scans still live (`bytealg.MakeNoZero`) | 106 MB | **80 MB** | | peak RSS | 777 MB | 781 MB | | load to ready | 15 s | 15 s | Peak RSS is set by the load itself, not by these strings. Against master, the first measurement of this PR found the strings retained from row scans went from 84 MB to 58 MB. An earlier version of this PR (the `unique` one) also ran on a staging instance (live MQTT ingest, `GOMEMLIMIT=1GiB`) through five back-to-back restarts and a two-hour soak, with no OOM kills and every request succeeding. This version has not had a staging soak yet. ## Tests - `observer_strings_intern_test.go`: - one copy of each value across 50 observations, through `Load`, `LoadChunked` and `loadChunk`; - **a value interned again after `runtime.GC()` is the same copy** (new: red against the `unique` version, green now); - 16 goroutines interning concurrently get the same copy; - a hit allocates nothing; - `BenchmarkInternStr` (serial and parallel). - Red commits first, then green. - `cd cmd/server && go test ./...` passes at `go 1.22`, and `-race` passes on the intern tests. Backend only: no UI, API shape or config change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b43fd4a470 |
feat(#2107): backfill unresolved channel/scope rows when the key set grows (#2133)
Part 1 of #2107, scoped as discussed there: the backfill on its own, no catalogue promotion. ## Problem Ingest decides a GRP_TXT's channel and a transport packet's region scope once, at insert time. A row stored before its key was known keeps `channel_hash = 'enc_XX'` / `scope_name = ''` forever, even after the key is added — via `hashChannels` plus a restart, or at runtime when the `autoRegionKeys` tier picks up a newly declared region. On live.saarmesh.de we have been closing that gap with an out-of-tree script that rewrites the DB and restarts the container; this moves it into the ingestor and removes the restart. ## What this does **Ingestor** — `cmd/ingestor/key_backfill.go` - Runs after the write path is ready, and again whenever the key set grows (kicked after every `autoRegionKeys` refresh; a no-op when nothing is new). - Touches **only unresolved rows**: `enc_*` GRP_TXT and transport rows with `scope_name = ''`. Every `UPDATE` carries the unresolved value in its `WHERE`, so a row resolved meanwhile by live ingest keeps its value. A decrypted channel or named scope is never rewritten. - **Same decision as ingest**: `DecodePacket` with the full channel key map → `PayloadJSON`, and `regionKeySnapshot.match()` with the full snapshot — including abstaining on an ambiguous collision (#1609). The scope-match counters are not touched; they describe live traffic. - **Bounded**: each batch covers a fixed window of 5000 transmission ids. Unresolved rows are sparse (hundreds in a million), and the ingestor has a single SQLite connection, so a "next N matching rows" query would scan most of the table in one statement while ingest waits. A fixed id window keeps every read a short primary-key range scan. The writer is yielded between windows. - **Resumable and transactional**: one transaction per window holds the rewrites, their change-feed rows and the advanced cursor (`key_backfill_state`). A cancelled or crashed run resumes at its last commit; nothing is applied twice. - **Key-set growth**: a run is tied to the fingerprint of the key set it started with. If the set changes mid-run, the run restarts from the beginning, so a key added halfway still sees all of history. Each key gets one complete pass (`key_backfill_done`); a restart with an unchanged key set does no work. A key that leaves the set forgets its pass and is replayed if it returns. - **Derived field**: a named ADVERT fills `nodes.default_scope` / `inactive_nodes.default_scope` only where it is still empty. A known `default_scope` is newer evidence than a backfilled advert and is never overwritten. **Change feed** — `internal/dbschema/key_backfill.go`, `cmd/server/tx_rewrite_feed.go` - Each rewrite appends a row to `tx_rewrite_feed` in the same transaction as the rewrite. - The server tails it exactly like `advert_route_evidence`: cursor captured before `Load`, absent table re-probed (not required by `AssertReady`, so an older ingestor simply means no feed), polled from `IngestNewObservations`. It refreshes `DecodedJSON` / `ScopeName` of loaded transmissions, resets the `ParsedDecoded` cache, recharges tracked bytes, and invalidates the channel caches. **No restart needed.** - The feed carries only the transmission id; current values are read from `transmissions`, so replaying a row is harmless. Rows older than 24h are pruned. ## Tests Ingestor (`key_backfill_test.go`) — fixtures are real wire frames: GRP_TXT encrypted AES-128-ECB with the firmware MAC, transport frames with the real region HMAC. - decrypts only unresolved rows; already-decrypted and unknown-key rows untouched - backfilled `decoded_json` / `channel_hash` are byte-identical to what `BuildPacketData` stores at ingest - unique match named; a constructed two-name collision on the same payload stays unmatched; named and non-transport rows untouched - ingest scope-match counters unchanged - ADVERT fills only an empty `default_scope` - idempotent: second pass writes nothing, done keys recorded, state cleared - cancel between commits → resume from the committed cursor, every row exactly once, in order - stale run fingerprint (key added mid-run) → restart reaches rows below the old cursor - a later key only adds its own rows; a removed key is replayed over the gap when it returns - a row resolved between decide and commit keeps its value and gets no feed entry - channel hash byte derivation matches the builtin Public key (0x11) - old feed rows pruned Server (`tx_rewrite_feed_test.go`) - rewrite reaches the loaded store through the normal `IngestNewObservations` poll, including a previously parsed `ParsedDecoded` (verified to fail without the `sync.Once` reset) - cursor starts at the load watermark - absent feed is a no-op and is picked up once created, without restart - failed read keeps the cursor and retries - feed row for an unloaded transmission advances the cursor and is ignored `gofmt` clean, `go vet` clean, no `go.mod` / `go.sum` changes. ## Validation against production data Ran the backfill on a snapshot of live.saarmesh.de taken **before** last night's run of our out-of-tree script (13,100 unresolved rows, 164 channel keys, 24 region keys incl. 2 derived), with the current config. It rewrote **the same 31 transmissions with the same channel names** as the script did on production (compared by id), named 0 scopes (script: 0), and a second pass wrote nothing. Browser validation (AGENTS rule 2): this changes no UI. The visible effect — a decrypted message or named scope appearing without a restart — is covered by the server feed tests; I have not yet run it against a deployed instance. ## Notes for review - Window size (5000 ids), inter-batch yield (10 ms) and feed retention (24h) are constants for now; per rule 8 they can move to config/customizer later if anyone needs to tune them. - `client_rx_observations.scope_name` is not backfilled here; happy to add it in a follow-up if wanted. - The orphaned "scope-repair" mention in `region_keys.go`'s `scopeMatch.Candidates` comment predates this PR; I left it alone. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BKaogNXgy8dEQruqzR5foP --------- Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
abc6ab9b59 |
fix(analytics): apply nodeBlacklist and hiddenNamePrefixes to Route Patterns (#2184) (#2189)
Fixes #2184. ## Problem `nodeBlacklist` and `hiddenNamePrefixes` did not apply to Route Patterns: - **`subpaths` and `subpaths-bulk`:** `filterBlacklistedFromSubpaths` asserted `data["subpaths"].([]interface{})`, while the store builds `[]map[string]interface{}`, so it returned its input unchanged. It then read `hops` as a list, which is an `int` (the run length). It also ran only when `nodeBlacklist` was set, so `hiddenNamePrefixes` alone never filtered anything. - **`subpath-detail`:** the check compared each raw hop (a prefix) with full keys, so it matched only a client that sent a full key. - **Parent paths** in the detail response were not filtered. ## Change All in `cmd/server/routes.go`; `store.go` is untouched, so this doesn't overlap #2182. - **Lists:** an entry is dropped when a name in its resolved `path` is the name of a blacklisted node (names looked up in the store's node cache) or has a hidden prefix. A subpath carries names and prefixes, not keys, and the store picks a node per prefix from context, so re-resolving a prefix in the handler would not be guaranteed to land on the same node. Matching the names the reader would see avoids that. Trade-off: another node with exactly the same name as a blacklisted one is hidden too. - **Detail:** 404 when a hop resolved to a blacklisted node (by the resolved key in `nodes[].pubkey`, so a node without a name is caught and one that only shares its name is not) or to a hidden-prefix name; parent paths that show one are dropped (by name, like the lists). The existing raw-hop check stays. - The filter runs when either `nodeBlacklist` or `hiddenNamePrefixes` is set. - `dropHiddenPaths` replaces `filterBlacklistedFromSubpaths` and returns a copy: the input can be the store's cached response, which the old function mutated in place. - `totalPaths` and `pct` are unchanged, so percentages stay relative to every stored path, the filtered ones included. **Perf:** per request, one pass over the node cache (about 2k nodes on the deployment below) to collect blacklisted names, then at most 8 lookups per entry on at most `limit` entries. It runs outside the store lock and only when a filter is configured. `map[string]interface{}` count in `routes.go`: 3 removed, 3 added. ## Tests - `subpaths_blacklist_2184_test.go`: on the seeded path `["aa","bb"]` (first hop resolves to TestRepeater), for `subpaths`, `subpaths-bulk` and `subpath-detail`: - unfiltered, the repeater is listed (so the test is not vacuous); - a blacklisted key, also in capitals, removes it and the detail returns 404; - a hidden name prefix removes it; - a different key with the same 1-byte prefix leaves it listed; - clearing the blacklist afterwards shows it again (the cached response was not changed). - subpath-detail: a blacklisted node without a name returns 404, a different node that only shares its name does not hide the detail. - Red commits first (`0ebee485`, `4acfb70a`, failing on assertions), then green. - `cd cmd/server && go test ./...` passes (`TestSaveGeoFilterPreservesFileMode` skipped, a Windows-only failure on master too); `go test -race -run 2184` passes; `go vet` clean. ## Validation on a live-fed staging instance `nodeBlacklist` temporarily set to the key of the node in the top Route Patterns entry (5848 occurrences), then restored: | | blacklist set | restored | |---|---|---| | `subpaths?limit=200` entries showing the node | 0 (185 entries left) | 15 (200 entries) | | `subpaths-bulk` groups 2-2 / 3-3 / 5-8 showing it | 0 / 0 / 0 | | | `subpath-detail` on a path through it | 404 | | | `subpath-detail` on a path without it | 200 | | ## Not done - Other analytics responses that carry node names (for example topology) were not audited for the same gap. - The UI still shows `pct` relative to all paths; whether a filtered list should renormalise is left open. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
45680a22cf |
fix(#2191): apply the channel filter to live WebSocket packets (#2192)
Fixes #2191. ## Problem On the Packets page the channel filter only applies to the initial load (`/api/packets?channel=` → server-side `transmissions.channel_hash` match). The WebSocket handler re-checks time window, type, observer, region and node, but not `filters.channel`, and the channel filter is not re-applied at render time. With a channel selected, every new live packet is appended regardless of its channel. ## Fix - New pure helper `packetMatchesChannel(p, channel)` in `public/packets.js`, exported as `window._packetsMatchesChannelForTest` (same pattern as `_packetsBuildChannelOptionsForTest`). - It derives the value the server filters on from `decoded_json`, because WS packets don't carry `channel_hash`: - `channel` when the server decrypted the `GRP_TXT`, - otherwise `'enc_' + channelHashHex.toUpperCase()`. - Only `payload_type === 5` can match, mirroring the server (the column is only set for `GRP_TXT`). If a packet ever does carry `channel_hash`, that wins. - The WS filter calls it: `if (filters.channel && !packetMatchesChannel(p, filters.channel)) return false;` Checked the derivation against a production DB (live.saarmesh.de): it equals `channel_hash` for all 3,193 `GRP_TXT` rows of the last two days, 0 mismatches. ## Performance WS hot path, but the new check runs only while a channel filter is active, and for non-`GRP_TXT` packets it returns before any parsing. For `GRP_TXT` it is one `JSON.parse` of the already-received `decoded_json` string, O(1) per packet. WS messages are already debounced/coalesced by `debouncedOnWS`, so at typical rates (a few packets per second) this is negligible. No new state, no allocations beyond the parse, no API calls. ## Tests New `tests/unit/test-packets-live-channel-filter.js` (registered in `test-all.sh`), 10 cases: - no filter accepts everything - decrypted name match / mismatch - non-`GRP_TXT` rejected while a channel filter is active - `enc_<HH>` match incl. hex case normalisation and `enc_00` - `channel_hash` passthrough takes precedence - already-parsed `decoded_json` object - malformed / missing `decoded_json` doesn't throw and doesn't match - source check that the WS filter block actually applies the helper Without the `packets.js` change the new test fails; with it all pass. Existing `test-packets.js` (117), `test-packets-local-channels.js` (14), `test-packet-filter.js` (99) and `test-frontend-helpers.js` still pass. ## Browser validation The same change is deployed on live.saarmesh.de (hot-patched `packets.js`, server restarted so `__BUST__` refreshed; the served file contains the fix). I have not taken a screenshot of the filtered live table myself, so treat the in-browser check as pending until a reviewer or operator confirms. No config or customizer implications. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HJcbiRjQC5cSmydVFXbPbR Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
60af9bd469 |
fix(hops): narrow only the directly heard hop to the observer region (#2098) (#2188)
Fixes #2098. ## Problem `HopResolver.resolve()` narrowed every hop of a path to the observer's IATA region (300 km around the code's coordinates). Only the last hop was heard by the observer directly; every earlier hop can be anywhere the mesh reaches, so the region says nothing about it. This stayed invisible on deployments whose observer codes were missing from `/api/iata-coords`: there `nodeInRegion()` returns `null` and nothing is filtered. A Belgian deployment's 42 observers use 10 codes and none was in the table. Filling the table alone would have switched on a filter that is wrong for every hop but the last. ## Why only the last hop #2098 lists three options and argues against simply repopulating the table, because the filter it would switch on is not sound. This PR does fill the table, but only after limiting the filter to the one hop where it is sound: - In a MeshCore path the last hop is the repeater the observer received the packet from, so it is within radio range of the observer. Measured below: p99 30 km for directly heard multi-byte hops. - An earlier hop says nothing about the observer's position. On the Belgian deployment nearly every observer hears nearly every flood, so a region filter on earlier hops would drop the right candidate whenever the packet started outside the observer's region. This was decided while preparing the PR, not on the issue thread, so the reasoning is recorded here. ## Change - **`public/hop-resolver.js`:** `resolve()` takes an optional `opts.directHops`, the hops the observer heard directly. Without it, the input is one path and its last hop is the direct one. The region check runs only on direct hops. An earlier hop gets `regional: false` and, with an observer, `regionSkipped: true`. `globalFallback` now means checked against the region with no candidate in it, so it is only set on a direct hop. - **`public/hop-display.js`:** the badge counts every candidate for `globalFallback` or `regionSkipped`. The dashed underline and the popover's "(global fallback)" stay for `globalFallback` only, so an earlier hop is not labelled as matched outside the region when no region check ran on it. - **`public/packets.js`:** `resolveHopsForPackets()` resolves the last hop of each path apart from the earlier hops. `hopCacheKey(h, observerId, direct)` keeps the two in separate cache entries, because the same prefix can be the last hop in one packet and a relayed hop in another. `renderPath()` reads the direct entry for the path's last hop, and the detail-pane resolve writes it. The region-narrowed entry never goes to the bare (observer-less) key, in the list or in the detail pane. The detail pane resolves and caches the shown observation's path with that observation's observer (`effectivePkt.observer_id`), the observer `renderPath` already used; before, it resolved with the packet row's observer, so when the first observation came from another observer the last hop missed its direct entry and showed unfiltered. The route button uses the same observer. - **`cmd/server/routes.go`:** `iataCoords` gains 104 entries from OurAirports `airports.csv` (public domain): every IATA code in Belgium, the Netherlands and Luxembourg, and the airports with scheduled service in Germany and France. AMS, CDG, FRA and MUC were already present and keep their values. ## Measured before building On 5000 packets from the Belgian deployment (3661 with a path): - 1-byte last hops: 1212, all ambiguous, 1204 of them without a server `resolved_path`, so they go through the client resolver. - Distance from the observer to a directly heard node, on 1892 unique multi-byte last hops: p95 25 km, p99 30 km, max 82 km. - With this change, on 906 1-byte last hops from observers whose code is now in the table: the badge counts 6.1 candidates on average instead of 6.6. The displayed name changes for 52, all from observers without their own lat/lon, where distance to the region now orders the candidates instead of list order. For observers with a position, no displayed name changes, because the #2097 observer anchor already picked the nearest candidate. ## Tests - `tests/unit/test-issue-2098-last-hop-region.js` (new, in `test-all.sh`, 25 assertions): - HopResolver: the last hop of a path is narrowed and an earlier hop is not; an earlier hop keeps every candidate and reports `regionSkipped`, not `globalFallback`; a direct hop with no candidate in region reports `globalFallback`; `directHops` overrides the position; a one-hop path is narrowed. - HopDisplay (real file): an earlier hop keeps a badge with every candidate but no fallback class or popover text; a direct hop outside the region keeps both. - packets.js (real file, real resolver and display): with one observer, a prefix that is the last hop of one packet and an earlier hop of another renders narrowed in the first and with a badge in the second; after the detail pane resolves a path, the observer-less render does not show the narrowed last hop; when the shown observation comes from another observer than the packet row, its last hop is narrowed to the region of the observer that heard it. - `TestIataCoordsIncludesBelgianObserverCodes`: ANR, BRU, GNE, KJK, LGG, OBL, OST and MST with their OurAirports coordinates. - Red commits first (`26000a22`, `bb91ff9d`, `189f55d7`, failing on assertions), then green. Mutation check on the packets.js tests: inverting `isDirect` fails 3 assertions, swapping the `directHops` argument fails 4. - `test-issue-2097-hop-ambiguity-badge.js`: two structural regexes now allow the new `direct` argument; every assertion still holds. - All 198 `node tests/unit/*` suites in `test-all.sh` pass. `cd cmd/server && go test ./...` passes (with `TestSaveGeoFilterPreservesFileMode` skipped, a Windows-only failure on master too); `go vet` clean. ## Browser validation Staging (this branch on top of the deployment's current build), headless Chrome on `#/packets/<hash>` for packets whose path ends in a 1-byte hop: the last hop's popover lists only candidates within the region with their distances (for example `7E`: 3 candidates at 46, 207 and 296 km), earlier hops list every candidate (`1D`: 13). The same prefix `7E` showed 3 in region as the last hop of one path and 4 as a relayed hop of another. No page errors. `/api/iata-coords` returns 158 codes. ## Browser validation of the follow-up Staging again (this branch at `4e129b49` on top of the deployment's build), headless Chrome: - Packets list, 41 paths of 2+ hops: the global-fallback class appears on 2 last hops and on 0 earlier hops. - Detail pane, 5 packets whose path ends in a 1-byte hop (1 to 13 hops shown): no earlier hop carries the class or "(global fallback)" in its popover, and every ambiguous earlier hop keeps its badge. Every last hop was checked against the region: 4 show "N candidates in region", 1 shows "(global fallback)" with the class. - Before the observer fix, 2 of 5 detail panes showed a last hop with neither label, because the shown observation came from another observer than the packet row (`b66a8f4c`: row from one observer with 13 hops, shown observation from another with 9). - No page errors. ## Not done - The 300 km radius is unchanged. The measured direct-hearing distances suggest it is wide, but a smaller radius would also have to hold for less dense networks, and I have no data for those. - `live.js` passes no observer, so the region filter does not run there, before or after this change. `map.js` passes the observer of the chosen path in path order, so once the resolver knows the observer regions it narrows that path's last hop only. - The "Cross-observer aggregate" line in the detail pane renders the longest path with the packet's own observer, so its last hop is narrowed to that observer's region even if another observer reported that path. The per-observer keying there predates this PR (#2097). - Two of the Belgian deployment's codes, HEP and NRW, are not airport codes and are not in OurAirports; those observers stay without a region filter. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
38ed9d6ce6 |
perf(store): count path hops without decoding the JSON (#2187)
## Problem
`pathLen` is how `pickBestObservation` chooses each transmission's best
(longest-path) observation, so it runs for every observation on every
load and ingest, and again from the resolved-path index. It decoded the
path JSON into a `[]interface{}` only to take its length.
## Change
- `countStringArray` counts the usual array of plain hex strings in
place, with no allocation (helpers `skipJSONSpace` and
`skipPlainJSONString`).
- Anything else falls back to the decoder exactly as before: escapes,
non-string elements, `null`, or malformed input. So the result is always
what decoding gives, including 0 for invalid JSON.
## Perf justification (AGENTS.md rule 0)
- **Complexity.** O(len(path_json)), a single pass with no allocation.
The old version was also linear but went through reflection and
allocated per element.
- **Scale.** It runs once per observation on every load and ingest (400k
observations in a busy regional network's 7-day window), plus each time
the best observation is re-picked.
Throwaway benchmark (not committed), current master vs this branch:
| `path_json` | master | this PR |
|---|---|---|
| `[]` | 215 ns, 32 B, 2 allocs | 4 ns, 0 allocs |
| 6 hops, 1-byte | 1825 ns, 488 B, 18 allocs | 27 ns, 0 allocs |
| 10 hops, 3-byte | 2815 ns, 936 B, 27 allocs | 72 ns, 0 allocs |
On a copy of that 7-day window:
| | master | this PR |
|---|---|---|
| allocated over startup (the whole server) | 5.41 GB | 5.10 GB |
| server CPU over the run | 175.6 s | 169.2 s |
`pathLen` accounted for about 275 MB of the startup allocations.
## Tests
- `TestPathLenDoesNotAllocate`: zero allocations for typical paths. Red
commit first (2–11 allocations), then green.
- `TestPathLenMatchesDecoding` compares the result with `json.Unmarshal`
into `[]interface{}` on 27 edge cases and 20,000 random inputs built
from JSON punctuation, escapes, `null`, objects and numbers.
- `cd cmd/server && go test ./...` passes.
- SonarQube: quality gate passed, no issues on added lines.
Backend only: no UI, API shape or config change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
a48dc32b8a |
perf(store): build hop context without a map or lowered copies (#2186)
## Problem `buildHopContextPubkeys` (#1197) runs for every transmission in each topology and distance pass, inside `buildAggregateHopContextPubkeys` for every subpath query, and for each matched packet in subpath detail. On every call it: - made a de-dup map sized for 16 keys, to de-duplicate what's usually 2–5 keys; - lowered every hop into a new string just to look it up in the prefix map. Hops are upper-case hex on live data, so `strings.ToLower` always allocates; - lowered the observer ID twice, once to check it's hex and once to add it. ## Change - Duplicates are found by scanning the result slice. - Hops are lowered into a 64-byte stack buffer for the prefix-map lookup. `m[string(buf)]` doesn't allocate, and non-ASCII input falls back to `strings.ToLower`, so lookups are unchanged. - The observer ID is checked as hex in either case (`isHexAnyCase`) and lowered once. - The result is the same keys in the same order: sender, observer, then unambiguous hop anchors. ## Perf justification (AGENTS.md rule 0) - **Complexity.** The de-dup scan is O(k²) for the k keys of one transmission. k is at most 2 + path hops; MeshCore paths are under 64 hops and real values are 2–10. It replaces a map allocated on every call, and nothing grows with the number of transmissions or nodes. - **Scale.** It runs for every stored transmission in each analytics pass (topology and distance) and per request in subpath analytics, so the saving recurs on every recompute, not just at startup. Benchmarks, current master vs this branch, same machine: | | master | this PR | |---|---|---| | `BenchmarkBuildHopContextPubkeys` | 830 ns, 1192 B, 4 allocs | 507 ns, 128 B, 1 alloc | | `BenchmarkBuildAggregateHopContextPubkeys` | 5.6 ms, 6.65 MB, 20.4k allocs | 3.7 ms, 1.33 MB, 5.3k allocs | On a copy of a busy regional network's 7-day window (64k transmissions): | | master | this PR | |---|---|---| | this function's allocations over startup and the first analytics pass | 317 MB | 80 MB | | server CPU over the same run | 175.6 s | 165.6 s | ## Tests - `TestBuildHopContextPubkeysAllocatesOnlyItsResult` uses a fixture cased like live data (upper-case hops and observer ID): at most 2 allocations, the result and the lowered observer ID. Red commit first (9 allocations), then green. - `TestBuildHopContextPubkeysOrderAndDedup` pins the same keys in the same order. - The existing hop-context and disambiguation tests (#1198, #1202) pass. - `cd cmd/server && go test ./...` passes. - SonarQube: quality gate passed, no issues on added lines. Backend only: no UI, API shape or config change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8d39a9d465 |
perf(store): count subpaths per request instead of indexing every run (#2182)
## Problem `spTxIndex` kept every 2–8 hop run of every stored path as a map key, with one transmission pointer per occurrence. On a busy regional network's 7-day window (59k transmissions) that's about 0.9–1.07 M keys and 130–160 MB of live heap, roughly a quarter of the store. It was also rebuilt at the end of every startup, which is when memory peaks. Two smaller issues came with it: - Eviction never decremented `spTotalPaths`, so the percentages were divided by paths that were no longer stored. - Route Patterns took 1.2–3.6 s uncached, because the fast path still split, resolved and re-joined about a million raw keys per request. ## Change - **Count on request.** Subpaths are counted when the analytics are asked for, from the stored paths. Results stay cached for `rfCacheTTL`, as before. - **Pointer-free tallies.** `tallyNamedSubpaths` counts runs in one pointer-free `map[uint64]subpathTally`, keyed by a `maphash` of the resolved name built in a reused buffer. The GC doesn't scan the map, and counting allocates nothing but the map. - **Names only for the top entries.** Each tally records the first transmission and offset where its run appeared, so names and raw hops are built only for each group's top entries. `topSubpathTallies` picks those with a `container/heap` min-heap of `limit` entries instead of sorting every name, and the existing `rankSubpaths` formats the response, so no new response maps are added. - **Hash collisions.** If two names shared a hash their counts would merge; the odds are about 1 in 10⁷ for a few million names. - **Subpath detail.** `GetSubpathDetail` finds the run with `txsWithSubpath`, a scan of the stored paths. - **Counts unchanged:** runs of 2–8 hops, counted once per occurrence, case-folded. The tests pin this. - **Totals.** `spTotalPaths` stays, and eviction now decrements it. - **Memory estimate.** The O(path²) subpath term is dropped from the estimate. ## Perf justification (AGENTS.md rule 0) - **Complexity.** A cold Route Patterns request is O(Σ runs), about 1.5 M for this window: one pass over the stored paths, with one map lookup per run against a reused buffer. Ranking is O(tallies · log limit). Results are cached for `rfCacheTTL`, so this runs at most once per TTL per group set. - **Subpath detail** is O(stored hops), one pass with no allocation per packet. It's an on-click endpoint, not a hot path. - **Ingest and eviction** no longer touch a subpath index at all. Removal used to be a linear search of every key's transmission list. **Measured on a copy of that window**, upstream master vs this branch, same queries: | | master | this PR | |---|---|---| | peak RSS (`GOMEMLIMIT=750MiB`) | 948 MB | 767 MB | | in-use heap after load (750MiB) | 592 MB | 456 MB | | `/api/analytics/subpaths` cold (750MiB) | 3.56 s | 0.64 s | | `/api/analytics/subpaths-bulk` cold (750MiB) | 3.33 s | 0.70 s | | `/api/analytics/subpaths` cold (1GiB) | 1.18 s | 0.61 s | | `/api/analytics/subpath-detail` | 0.6 ms | 8 ms | | cached responses | 0.6 ms | 0.5 ms | At 750 MiB, master's request time is mostly GC pressure from the index. On the production-shaped fixture that `subpath_index_memory_test.go` builds (41k transmissions): | | master | this PR | |---|---|---| | retained by the store for subpaths | 197 MB | 0 MB | | allocated per bulk request | 336 MB | 111 MB | This has also run on a staging instance with live MQTT ingest for about nine hours, with Route Patterns answering in about 1 s. ## Tests - `subpaths_on_demand_test.go`: - per-occurrence, case-folded counts; - the 8-hop cap; - bulk matches single; - detail counts, including repeats, case and the length limits; - eviction drops evicted paths from the totals. - `TestSubpathIndexRetainedHeap`: the bound is now 5 MB (it was 225 MB). - `TestSubpathRequestAllocations`: 150 MB per request. - Each change has its red commit first, failing on assertions. - Tests that reached into `spTxIndex` now assert through `txsWithSubpath` and the public counts. - `cd cmd/server && go test ./...` passes. - SonarQube: quality gate passed, no issues on added lines. **Overlap with #2181:** both edit the same memory-estimate comment block in `store.go`. Whichever lands second needs a trivial rebase there; there's no code dependency. Backend only: no UI, API shape or config change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ca62790c81 |
fix(store): load the observer IATA in the background chunk loader (#2185)
## Problem With `hotStartupHours` set, startup loads the hot window first. `loadChunk` then fills the rest of the retention window in the background. Its query selected the observer ID and name but not the IATA code, and scanned no IATA, so every packet it loaded had an empty `ObserverIATA`. On a 7-day window with a 24 h hot start, that's six days of packets after every restart that: - have no `observer_iata` in the packets API; - never match the `iata` filter (#1188). `Load`, `scanAndMergeChunk` (the hot window) and both ingest paths already join `observers` to get the IATA code, so only background-loaded packets were affected. **Seen on a live instance** (`hotStartupHours: 24`, restarted about 18 h earlier), sampling `/api/packets?until=…`: | packets | with `observer_iata` | |---|---| | 20–30 h old (hot window) | 200 / 200 | | 46 h old (background-loaded) | 0 / 200 | | 60 h old (background-loaded) | 0 / 200 | With this fix on a staging instance, packets older than its hot window are 300 / 300. ## Change The chunk query selects `COALESCE(obs.iata, '')` for both schemas: the v3 query through the observers join it already had, and the legacy query through a new join on the observer ID, as `Load` does. `loadChunk` scans the column into `ObserverIATA`. ## Tests - `TestLoadChunkKeepsObserverIATA`: red commit first (fails on its assertion), then green. - `cd cmd/server && go test ./...` passes. - SonarQube: quality gate passed, no issues on added lines. **Overlap with #2183:** both edit the same lines of `loadChunk`'s `StoreObs` literal (#2183 interns the observer fields). Whichever lands second needs a trivial rebase; if this lands first, #2183 should intern `ObserverIATA` there too. There's no code dependency. Backend only: no UI, API shape or config change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
dc7ebcd6f5 |
perf(store): check observation duplicates without per-transmission maps (#2181)
## Problem Every `StoreTx` carried two maps for observation de-duplication: - `obsKeys`, keyed by `observerID + "|" + pathJSON` (a concatenated string allocated for every observation); - `observerSet`, for `UniqueObserverCount`. On a busy regional network's 7-day window (64k transmissions, 400k observations), these maps and their concatenated keys held about 60 MB of heap. Most transmissions are heard a handful of times; the busiest in that week was heard 40 times. The maps cost a few hundred bytes per transmission to de-duplicate lists that are usually under ten long. ## Change - `hasObservation` and `hasObserver` scan `tx.Observations`, comparing the observer ID and path strings the observations already hold. These are the same two values the old map key was built from, so the answers are identical. - Once a transmission passes 64 observations (`obsIndexThreshold`), it builds the maps. They're keyed by a struct that shares the observations' strings, so no key bytes are allocated, and busy transmissions keep O(1) checks (the case #543 / #355 addressed). - The five places that appended an observation now call `addObservation`, which keeps `UniqueObserverCount` and `ObservationCount` in step. Those three lines were previously copied at each site. - The memory estimate no longer charges for the per-transmission maps or the concatenated keys. ## Perf justification (AGENTS.md rule 0) - **Complexity.** - Below the threshold, a duplicate check is O(k) for a transmission with k < 64 observations, so building it costs at most 63·64/2 = 2016 string-header comparisons. - At and above 64 observations, checks are O(1) map lookups, as before. - Nothing is O(n²) in the number of transmissions or observations overall: the scan is bounded per transmission. - **Scale.** Real data peaks at 40 observations per transmission. The scan compares string headers (length first, then bytes), and 64-character observer IDs differ early. `BenchmarkObsDedup` (a miss, the common case on ingest): | observations on tx | check used | time | |---|---|---| | 4 | scan | 15 ns | | 16 | scan | 70 ns | | 40 | scan | 151 ns | | 63 | scan | 233 ns | | 64+ | map (struct key) | 15 ns | For comparison, the old map lookup was about 15 ns too, plus building and allocating the `observerID|path` key string on every check. At 40 observations that's about 3 µs extra to build a whole transmission, against roughly 2 KB of maps no longer allocated for it. **Measured on a copy of that 7-day window** (`GOMEMLIMIT=750MiB`, same queries): | | before | after | |---|---|---| | in-use heap after load | 612 MB | 568 MB | | HeapAlloc after load | 660 MB | 564 MB | | peak RSS | 1077 MB | 1009 MB | | startup to ready | 31 s | 21 s | It has also run on a staging instance (live MQTT ingest) for about nine hours alongside other store changes, with no change in observation or observer counts. ## Tests - `obs_dedup_test.go`: - duplicates (same observer and path) are rejected, while a new path or a new observer is kept; - a transmission under the threshold carries no maps; - across the threshold the maps take over with the same answers; - empty observer IDs aren't counted as observers; - `BenchmarkObsDedup`. - Red commit first (stubs, failing on assertions), then green. - `cd cmd/server && go test ./...` passes. - SonarQube: quality gate passed, no issues on added lines. Backend only: no UI, API shape or config change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
955386b63f |
test(map): settle the router's focus frame before the teardown snapshot (#2179)
## Summary Fixes an intermittent failure in `tests/e2e/test-map-nodes-pagination-e2e.js`, the "late response after map teardown" cases added for #2030 in #2048: ``` ✗ late /observers response after map teardown: old response must not change the destination page ``` It failed on #2164 ([run 38080812509](https://github.com/Kpa-clawbot/CoreScope/actions/runs/38080812509), shard 2/3), a Go-only change, and passed on the rerun. ## Cause `checkMapTeardown` compares `#app`'s HTML before and after releasing the old map's pending response. When it failed, the only difference was on the destination page's heading: ``` before: <div class="tools-landing"><h2>Tools</h2>… after: <div class="tools-landing"><h2 tabindex="-1">Tools</h2>… ``` That attribute comes from the router, not the old response. Since #630 (`app.js`, "SPA focus management") it focuses the new page's first heading in a `requestAnimationFrame` after the page renders. The before-snapshot was taken as soon as `#leaflet-map` detached, sometimes ahead of that frame. ## Fix The test now waits two frames before the before-snapshot, the same settle it already does before the after-snapshot. The router's frame is queued by then, so it always lands first. The change is test-only: no app code changes, and every assertion stays as it was. ## Tests Run locally with the e2e fixture (`tools/freshen-fixture.sh`, migrated) served by this branch's server, headless Chromium: | | runs | failed | |---|---|---| | test on `master` (`352a0ccd`) | 20 | 2 | | this branch | 50 | 0 | - **Before:** the two failures on `master` were "old response must not change the destination page" (teardown, `/config/regions` and `nodes`), plus one 30 s timeout in an "after map replacement" case in the same runs. - **After:** none of these failures or the timeout came back in 50 runs. ## Checks - **Lint:** ESLint passes with the repo config and with Node globals. SonarQube rules on the file report nothing on the added lines; its four existing minor findings are on lines this PR doesn't touch. - **Repo rules:** one logical change, explicit `git add`, no private data, no new dependencies. This is a test-only fix, so there's no red/green pair: the "red" is the intermittent failure above, measured as 2 in 20. Related: #2030, #2048 (where the test was added). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d9342bbebd |
(feat): UserManagement, Add Support for Postal Email Delivery Provider (#2154)
This PR extends email support to add another email delivery provider called Postal for user management. It is a, open source, self hosted email delivery and reputation management service. It adds 2 new additional configuration settings to the userManagement.mail object which are "postalBaseUrl" and "postalApiKey". It also adds "postal" as a supported value for the "provider" setting. All existing functionality including email delivery notification is supported by Postal. Included test cases to support the new functionality and all pipelines pass. --------- Co-authored-by: CoderNemesis <rfedor@comchan.net> |
||
|
|
a4ee3a7328 |
feat(scope-audit): observer region filter for both views (#2171)
## Summary Builds on the transport view from #2167 and #2169 (both merged); rebased onto `master` (`352a0ccd`), so the diff is this PR's six commits only. The Scope Audit was the one analysis page without the region selector. This adds the shared region filter to it, for both the declared and the transport view. `?region=SFO,SJC` means what it means on every other endpoint: count only forwarding **heard by observers in those regions**, not repeaters located there. - **API** (`GET /api/scope-audit`, both modes): - The region codes are resolved to their observers first (a handful of rows), then the audit's hop scan gets `AND o.observer_idx IN (…)` on v3, or `o.observer_id IN (…)` on v2. The 3.5M-row hop scan never joins `observers`. - `All` means no filter (shared `normalizeRegionCodes`). Codes with no observer match nothing, instead of silently widening to every observer. - Responses echo the normalised codes as `region`. The declared and transport caches and their singleflight keys now include the region, so each region set is cached separately with the existing TTLs. - Declared-region verification (`regionEvidence`) is deliberately left unfiltered: it corroborates what a repeater declares from its own traffic, which doesn't depend on where a hop was heard. - **Page:** the shared `RegionFilter` sits with the view and window controls; a change reloads the view, and the selection persists like every other page. The transport view's text box is relabelled **Scope** (it already sends `?scope=` since the rename in #2167), so the page no longer has two controls called Region. - **Docs:** `docs/api-spec.md` (parameter, both response shapes) and the OpenAPI parameters for the route (`mode`, `scope` and `region` were all missing). Partial fix for #2142 (its region filter for rollout tracking), together with #2167 and #2169. ## Performance The unfiltered path must not get slower: it's the same query with an empty clause. `BenchmarkComputeScopeTransport` (from #2167, 1,200 repeaters), the PR it stacks on vs this branch, `-count 8`: | | before | after | | |---|---|---|---| | sec/op | 220.3 ms | 218.0 ms | ~ (p=0.065) | | B/op | 43.77 MiB | 43.70 MiB | ~ | | allocs/op | 833.5k | 833.5k | ~ | **Filtered requests, measured on staging** (a copy of a production database): the first version drove `o.observer_idx IN (…)` from `idx_observations_observer_idx`, reading every observation the region's observers ever made. Fixed in `80414242` with a unary `+` (the same treatment as #2166), so the scan starts from the `first_seen` window and filters per row. Red test `67e063b1` asserts the query plan against a fixture with the real indexes. | scan, one region over 24h (13 observers) | plan | time | hop rows | |---|---|---|---| | `o.observer_idx IN (…)` | observer index | 13.44 s | 102,114 | | `+o.observer_idx IN (…)` | `first_seen` window | **0.25 s** | 102,114 | | unfiltered | `first_seen` window | 0.35 s | 225,983 | Cold end-to-end requests on staging after the fix: 24h declared, unfiltered 0.84 s · one region 0.48 s (was 16.7 s) · two regions 0.22 s · transport, one region 0.42 s · transport, six regions 0.75 s · 7d, one region 3.46 s. 24h transport, live data: 383 repeaters unfiltered, 204 heard by one country's observers. ## Tests - Red `629e4f36` (Go): extends the audit fixture with an `observers` table and `observations.observer_idx`. Covers the declared view ignoring a scope heard only by another region's observer, the transport view listing only repeaters heard in the region, `region=All`, per-region caching, a region with no observers, and the echoed codes. Five fail on assertions; `region=All` passes because the stub ignores the filter, which is the behaviour it pins. - Green `6f1b1e1c`: the implementation, plus a v2-schema case (`observer_id`), so both branches of the clause are covered. - Red `b4933d68` / green `08ecc172` (frontend): `apiPath` carries the selection in both views and adds nothing when none is selected. The region control on the page is a net-new UI surface. - `go test` (cmd/server) passes. Frontend suites: 193 of 194. The one failure, `test-issue-1956-release-routing.js`, needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally. ## Screenshot The same transport view with the shared region filter set to **SJC**: only forwarding heard by San Jose observers counts, so 7 repeaters are listed instead of 44, with their carried scopes counted from those observers alone. Taken against `test-fixtures/e2e-fixture.db` with fictional scopes.  ## Browser validation The branch's server was run locally with 14 configured regions (dropdown layout), checked in headless Chromium at 1280, 900, 700 and 390 px (touch): | check | 1280 | 900 | 700 | 390 touch | |---|---|---|---|---| | ticking SJC re-requests with `®ion=SJC` | yes | yes | yes | yes | | menu fully on screen | yes | yes | yes | yes | | page wider than viewport | no | no | no | no | | page errors | none | none | none | none | Found and fixed this way: the filter is the right-most control on desktop, and the menu (which opens rightwards) ran 66 px off the page. It now opens leftwards above 640 px. On a phone the controls wrap, the filter starts a line, and the default applies. ## SonarLint SonarQube Community rules (the SonarLint engine) were run on the files this PR touches, and only issues on lines it adds are listed. - **S3776 on `ScopeAuditForwardingHeardIn`:** complexity 55. The function it generalises, `ScopeAuditForwarding`, is 54 on `master`; the region clause adds one point. - **S3776 on `computeScopeAudit`:** complexity 65, unchanged from `master`. - Both pre-date this PR. Splitting them is better as a refactor PR of its own than mixed into a feature. - The page code's findings were fixed in the transport-view PR it stacks on. ## Checked against the repo rules - Rebased onto `master` (`673442cc`). Each red commit was re-run and still fails on its assertions; each green commit passes (TDD.md). - `make test`: all 16 modules pass. Frontend suites: all pass except `test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally (#2160 addresses it). - No private data: test fixtures and examples use the codes the existing tests use (SJC/SFO/LAX, `#be`-style scopes). - No new `map[string]interface{}`, no hard-coded colours. `git add` was explicit, and there is one logical change per commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
55dd28164d |
test(e2e): assert the configured-scope row after the theme-refresh re-render (#2180)
## What `test-issue-1648-m2-icons-e2e.js` now asserts the configured-scope row only after the page's `theme-refresh` re-render has fired and the detail has rendered again. ## Why On 2026-10-10 this assertion failed CI on four PRs (#2169, #2171, #2154, #2164), each time at the first live-map case: `confirmed scope row must be visible, including an empty scope`. A re-run of the failed jobs passed every time. The code path: - `customize-v2.js:814` dispatches `theme-changed` once `/api/config/theme` resolves, on every page load. - `app.js:1309-1315` turns that into `theme-refresh` 300 ms later. - `live.js:4713` re-runs `showNodeDetail(activeNodeDetailKey)` on `theme-refresh`, and `showNodeDetail` first sets `#nodeDetailContent` to "Loading…" (`live.js:2598`). This is the only caller besides the marker click (`live.js:3090`). - The test waited for `#nodeDetailContent table` and then called `row.isVisible()`, which does not wait. A refresh landing between the two fails the check. The same race could let the null-scope case pass on the "Loading…" panel, because `row.count() === 0` holds there too. The fix uses the same `addInitScript` flag pattern as `test-e2e-playwright.js:83`. ## Testing - Locally against a server on the E2E fixture: 5 of 5 runs pass, 27 of 27 checks each. - **Not reproduced locally.** The unchanged test also passed every local run: 5 plain, 3 with `/api/config/theme` delayed by 1 s, 5 with 8x CPU throttling. So the reasoning above comes from the code path and the four CI failures, not from a local red-then-green. - Test-only change, no product code touched. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4e91c5cf0b |
perf(analytics): opt-in pauseWhenIdle skips recomputes nobody reads (#2164)
## Summary The analytics recomputers (#1240) recompute eleven snapshots (topology, RF, distance, channels, hash collisions, hash sizes, roles, two clock-skew views, retransmissions, direct-heard) on a fixed interval, 300 s by default, whether or not anyone reads them. That is the right trade for a busy instance: reads always hit a warm cache. On a quiet one it means most of the server's allocation goes on results nobody looks at. Measured on one production instance (2 vCPU / 2 GB): - topology, RF and hash-sizes were each **read 23 times in 45 hours** while being recomputed every 5 minutes; - `analyticsRecomputer.runOnce` accounted for **30% of all bytes allocated** (121 GB of 391 GB in 45 h); - garbage collection was 87% of the process's CPU (30 s CPU profile: `gcDrain` 39.9 s of 45.9 s sampled), with an 805 MB live heap against a 1 GiB `GOMEMLIMIT`, so allocation rate translates directly into collector time. This PR adds an **opt-in** `analytics.pauseWhenIdle`: - a recomputer's tick is skipped when nothing has read its snapshot since the previous compute, and the recomputer is marked paused; - the next read still returns immediately with the snapshot it holds (`Load` stays non-blocking, as #1240 requires), and makes a non-blocking send that wakes the loop to recompute; reads after that get the fresh snapshot and the ticker resumes from there. **Default is off**, so behaviour is unchanged unless an operator turns it on. The trade-off for operators who do: the first read after a quiet spell can show a snapshot older than the interval (refreshed seconds later). It is documented in `config.example.json`. No existing issue covers this. ## Config example Off by default. To turn it on, add one key to the existing `analytics` block in `config.json`: ```json "analytics": { "defaultIntervalSeconds": 300, "pauseWhenIdle": true } ``` Nothing else changes: per-endpoint `recomputeIntervalSeconds` still apply while an endpoint is being read. There is no UI change, so no screenshot. ## Performance Staging A/B: the same 30 minutes of real production API traffic (5,640 requests from a day's nginx log, replayed at 2x against a copy of the production database with the live MQTT feed), on `master` and on this branch with `pauseWhenIdle: true`, each after a restart and warm-up. Allocation is the delta of `/debug/pprof/allocs` across the replay: | during the replay | master | this PR | |---|---|---| | allocated by `analyticsRecomputer.runOnce` | 459.7 MB | **53.4 MB (-88%)** | | total allocated by the process | 3,704 MB | 3,372 MB (-9%) | | GC cycles | 52 | 41 | | requests: p50 / p95 / errors | 9.9 ms / 57.0 ms / 0 | 10.4 ms / 60.1 ms / 0 | The replayed traffic included real analytics reads, so the paused recomputers were woken by them; the remaining 53 MB is that. Over 15 minutes each recomputer only ticks about three times, so on a longer quiet period the saving grows with the idle time. GC count and CPU on this shared host vary between runs on their own, so the allocation figures are the claim, not the GC count. There is no Go benchmark for this change: the behaviour is "how many computes happen in a period of no reads", which the tests below assert directly (16 computes on master vs at most 2 here over the same idle period). ## Tests - Red commit `24056c57`: config field, accessor stub and an unused recomputer flag, plus tests. Three fail on the stub: - `TestAnalyticsRecomputer_PauseWhenIdleSkipsUnreadTicks`: an unread recomputer with a 20 ms interval computes at most twice in 300 ms (16 times on the stub); - `TestConfig_AnalyticsPauseWhenIdle`: `pauseWhenIdle: true` enables it, absent or nil config leaves it off; - `TestStartAnalyticsRecomputers_AppliesPauseWhenIdle`: all eleven recomputers receive the flag. - Also added, passing on both commits on purpose: a read on a paused recomputer returns the existing snapshot and triggers a refresh; a recomputer that is being read keeps ticking; the default (off) keeps ticking unread. - Green commit `19de3636`: the behaviour, `config.example.json` entry with `_comment_pauseWhenIdle`, and the `Load` doc comment. - `go test -race` on the recomputer, warm-up (#1659) and config tests, three runs: pass. `make test`: 16 of 16 modules pass. The frontend tests that read `config.example.json` pass. ## Review notes - `roles` reads the nodes-clock-skew snapshot inside its own compute, which counts as a read, so that dependency keeps its source fresh while roles is in use. - The #1659 warm-up gate is untouched: a skipped tick never marks a first pass done. - Config documented per WORKFLOW.md section 7. Customizer (AGENTS.md rule 8): it is an operator setting, not a display value; no customizer entry proposed. ## SonarLint SonarQube Community rules (the SonarLint engine) were run on the files this PR touches, and only issues on lines it adds are listed. - No issues on the added lines. ## Checked against the repo rules - Rebased onto `master` (`673442cc`). Each red commit was re-run and still fails on its assertions; each green commit passes (TDD.md). - `make test`: all 16 modules pass. Frontend suites: all pass except `test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally (#2160 addresses it). - No private data: test fixtures and examples use the codes the existing tests use (SJC/SFO/LAX, `#be`-style scopes). - No new `map[string]interface{}`, no hard-coded colours. `git add` was explicit, and there is one logical change per commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
352a0ccd19 |
feat(region-filter): configurable region quick picks (#2170)
## Summary **Independent of every other open PR.** Choosing a pick saves the selection, redraws the control and notifies the page's listeners itself, exactly as a click in the control does, so it re-queries straight away whether or not #2168 (`setSelected` notifies) is merged; with both in, nothing fires twice. Region quick picks are named groups of region (IATA) codes, offered as one-tap choices inside the region filter, e.g. "Northern California" for a dozen airport codes instead of ticking each box. One deployment has been injecting these into the page through an nginx `sub_filter` and a script that wraps `window.RegionFilter`; this makes them a configured feature. ```jsonc "regionQuickPicks": [ { "name": "Northern California", "description": "Bay Area and Sacramento observers.", "regions": ["SJC", "SFO", "OAK", "SMF"] } ] ``` - **Server:** `GET /api/config/region-quick-picks` returns the groups cleaned up: names and codes trimmed, codes upper-cased and de-duplicated in their configured order, groups without a name or codes dropped, at most 20 groups of 200 codes, always a list. `/api/config/regions` is unchanged. - **Region filter:** loads the groups with the regions and offers every group with at least one observer today, inside the region control: - dropdown layout: rows of the same kind as **All** (checkbox and bold name), directly under All with a divider before the single regions; the row is ticked while that pick is the selection, ticking it selects the pick and closes the menu, unticking goes back to all; - pill layout: the picks lead the same bar, set off from the single regions by a thin divider. - A tap selects the group's codes that have an observer and re-queries straight away. - The selector then reads the group's name ("South Bay ▾") instead of "2 Regions", and the button shows as pressed. Selecting the same codes by hand does the same. - Tapping the active group again goes back to all regions. - A failed fetch, or no configured groups, leaves the filter exactly as before. - **Mobile:** the rows take the menu's 44 px row height under `(pointer: coarse)`; the pill-bar picks reuse `.region-pill` and its touch target. Inside `.filter-bar` (Packets) the existing touch rule for inputs made every region checkbox a 44 px square; the checkbox is now 20 px and the 44 px row is the tap target. - **Semantics:** like every region filter, a group means "heard by observers in these regions", not "located there"; `config.example.json` says so. No existing issue covers this. ## Screenshot Nodes page with two picks configured (example config below), after tapping **South Bay**: the pick and its codes (SJC, MRY) are selected, and the list re-queried for them. Taken against `test-fixtures/e2e-fixture.db`.  ## Config example ```json "regionQuickPicks": [ { "name": "South Bay", "description": "San Jose and Monterey observers", "regions": ["SJC", "MRY"] }, { "name": "East and North Bay", "description": "Oakland and San Francisco observers", "regions": ["OAK", "SFO"] } ] ``` Each pick needs a `name` and at least one code; `description` is optional (shown as the tooltip). Codes are trimmed, upper-cased and de-duplicated; a pick with no code that has an observer today is not shown. At most 20 picks of 200 codes. ## Browser validation The branch's server was run locally against a migrated copy of `test-fixtures/e2e-fixture.db` (observers in MRY, OAK, SFO, SJC) with three groups configured: "South Bay" (SJC, MRY, SCZ), "East and North Bay" (OAK, SFO, sts) and "Nowhere yet" (XXX). Checked in headless Chromium: | check | desktop 1280 px | phone 390 px (touch) | |---|---|---| | groups shown | South Bay, East and North Bay ("Nowhere yet" hidden) | same | | tap South Bay selects | SJC, MRY (SCZ has no observer) | same | | next `/api/packets` request | `region=SJC,MRY` | `region=SJC,MRY` | | selector label | "South Bay ▾" | "South Bay ▾" | | second tap | back to all regions | same | | button height | 24 px | 44 px | | page wider than viewport | no | no | | page errors | none | none | Checked on Packets (dropdown layout) and Nodes (pill layout). Three layout problems found this way on the phone were fixed before opening: a sideways-scrolling row clipped at the screen edge, the selector squeezed into a narrow column (the filter group is a non-wrapping flex row), and the two wrapped rows spread apart on Nodes (`align-content`). **Revision 1:** the first version put the picks in a separate row beside the selector; they moved inside the dropdown. Re-checked with a 14-region list and three picks on Packets: desktop 1280 px dark and phone 390 px light both keep the menu on screen (menu 340 px wide, page not wider than the viewport), the pick closes the menu and the trigger names the pick. **Revision 2:** picks are checkbox rows like **All** instead of chips. Re-checked on Packets at 1280 px dark and 390 px light: rows render bold under All, the divider separates the single regions, ticking a pick ticks its codes and names the trigger, no sideways scroll. Unrelated and not changed: on Nodes at 390 px the region row (existing pills included) starts flush with the screen edge. ## Tests - Red commit `81ea67d6`: the config type, a stub normaliser and endpoint returning nothing, and tests. - Go: cleaning (trim, upper-case, de-duplicate, drop incomplete groups), caps, nil config, and the endpoint's JSON. - Frontend, `tests/e2e/test-region-quick-picks.js` (moved from `tests/unit/` in `fced69a7`; it needs jsdom, so it runs in the E2E job like `test-table-sort.js`): the real `region-filter.js` in **jsdom** against faked config endpoints. Nine cases: rendering and hiding groups with no observer, selecting and re-querying, the selector label, toggling back, a hand-made selection marked active, the pill layout, no groups, a failed fetch, HTML escaping. Seven fail on the stub, all on assertions. - Green commit `0e33210e`: the implementation, styles, `config.example.json` (`regionQuickPicks` + `_comment_regionQuickPicks`), `docs/api-spec.md`, and the OpenAPI route list (`TestOpenAPICompleteness` requires it). The jsdom test also had a cross-realm comparison bug fixed in this commit (`Array.from` on arrays coming out of the jsdom window). - `make test`: 16 of 16 modules pass. Frontend suites: 195 of 196; the one failure, `test-issue-1956-release-routing.js`, needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally. - Red commit `f991060d` (revision): picks live inside the dropdown menu before the checkboxes, choosing one closes the menu, and in the pill layout they share the region bar; two fail on assertions against the old layout. Green commit `1f720e35`. Frontend 195 of 196 (same environment-only failure). - Red `134f5256` / green `da0adb50` (second revision): picks are checkbox rows between All and the single regions with a divider, no chips in the menu; ticking and unticking a pick row. The green commit also repairs `style.css`: `1f720e35` had prepended its block to the top of the file (its edit matched an earlier `.region-pill {`) and left the previous rules in place. The file now differs from `master` by 13 lines. ## Review notes - Groups are offered only when at least one of their codes appears in `/api/config/regions`, and only those codes are selected. The stored selection is pruned to known codes on load anyway, so selecting absent codes would not survive a reload. - Customizer (AGENTS.md rule 8): groups are operator configuration; a customizer editor could come later. ## SonarLint SonarQube Community rules (the SonarLint engine) were run on the files this PR touches, and only issues on lines it adds are listed. - **Fixed** in `b84ace4e`: - `normalizeQuickPickCodes` split out of the config normaliser (S3776, 17 > 15); - a comment on why a failed quick-pick fetch is ignored (S2486); - `for…of` in `activePick` (S4138); - in the tests: `node:` specifiers, `includes()`, and an explicit string comparator for sorted selections (S7772, S7765, S2871). - **Kept on purpose:** - S2486 is still reported on that `catch`. Swallowing the error is the intended behaviour: the filter works as before, just without picks. - S1523 on the `vm` test loader, as above. ## Checked against the repo rules - Rebased onto `master` (`673442cc`). Each red commit was re-run and still fails on its assertions; each green commit passes (TDD.md). - `make test`: all 16 modules pass. Frontend suites: all pass except `test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally (#2160 addresses it). - No private data: test fixtures and examples use the codes the existing tests use (SJC/SFO/LAX, `#be`-style scopes). - No new `map[string]interface{}`, no hard-coded colours. `git add` was explicit, and there is one logical change per commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
99efaed33d |
fix(region-filter): setSelected notifies change listeners on a real change (#2168)
## Summary `RegionFilter.setSelected()` saved the selection and redrew the control but never ran the `onChange` listeners; only a click (`toggleRegion`) did. A programmatic selection therefore updated the dropdown while the page kept showing data for the old selection until the next click. Affected today: the Packets page applies a `?region=` URL parameter through `setSelected`. It registers its listener afterwards, so its first load is fine, but any other page's listener that is still registered sees nothing. And anything that offers one-click region groups has to wrap `setSelected` to make it re-query (an nginx-injected overlay on one deployment does exactly that). This PR stands alone; #2170 (configurable quick picks) does not need it. `setSelected` now compares the new selection with the current one (order-insensitive; empty and null both mean "all") and, only when they differ, runs the listeners after saving and re-rendering, exactly as a click does. Re-applying the current selection stays silent, so a caller restoring state does not trigger a reload. No existing issue covers this. ## Tests - Red commit `75569b78`: `tests/unit/test-region-filter-setselected.js` loads the real `public/region-filter.js` in a `vm` sandbox. Two cases fail on `master` (a programmatic selection notifies once with the new codes; a clear notifies once with `null`). Two pass on both commits on purpose: re-applying the current selection in another order, and clearing an empty selection, must not notify. - Green commit `8a7ddfe1`: the fix. - Listed in `test-all.sh` in sorted order; `test-test-inventory.js` passes. `test-1108-region-hide-nodes.js` (the other region-filter suite) passes. Frontend suites: 194 of 195; the one failure, `test-issue-1956-release-routing.js`, needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally. ## Review notes - `packets.js` "clear all filters" calls `setSelected([])` and then reloads itself. When a region was selected, that path now also triggers the page's region listener, so the packets list is requested twice. It is a cosmetic double fetch on an explicit user action; left as is to keep this PR to the component. - No performance claim; this is a correctness fix. ## SonarLint SonarQube Community rules (the SonarLint engine) were run on the files this PR touches, and only issues on lines it adds are listed. - **Fixed** in `9e732706`: `node:` specifiers (S7772). - **Kept on purpose:** S1523 on `vm.runInContext`, which loads the repo's own `public/region-filter.js` as the other frontend unit tests do. ## Checked against the repo rules - Rebased onto `master` (`673442cc`). Each red commit was re-run and still fails on its assertions; each green commit passes (TDD.md). - `make test`: all 16 modules pass. Frontend suites: all pass except `test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally (#2160 addresses it). - No private data: test fixtures and examples use the codes the existing tests use (SJC/SFO/LAX, `#be`-style scopes). - No new `map[string]interface{}`, no hard-coded colours. `git add` was explicit, and there is one logical change per commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
deb55dc82e |
feat(scope-audit): transport view on the Scope Audit page (#2142, page) (#2169)
> **Requires #2167 to be merged first.** This page calls the transport view API that #2167 adds (`/api/scope-audit?mode=transport`), so it cannot be split from it; until #2167 merges this PR also shows its commits. ## Summary Page half of #2142. **Stacked on #2167** (the API); review that first. #2171 adds the observer region filter on top. Partial fix for #2142 until all three are in. Adds a **Declared / Transport** toggle to `#/scope-audit`. The transport view calls `?mode=transport` and lists every repeater seen forwarding in the window: - **Seen carrying:** one chip per region scope with its packet count (first/last seen in the tooltip); - **Declared:** the repeater's declared regions coloured by whether they were seen carried (the declared view's own chips), or *not asked* when no declared-regions answer exists. Never asked is worded differently from declaring nothing; - **Other traffic:** unscoped, unmatched and ambiguous-hop counts. A **Region** box (suggestions from the scopes seen) adds a *Carries X* column, lists the repeaters not yet carrying it first, and summarises carriers against active non-carriers: the rollout report the issue asks for. Window, view and region are deep-linked (`#/scope-audit?window=7d&mode=transport&scope=be`) and survive a reload. The page says plainly that *transported* means seen carrying, not configured, and that over a short window a quiet region looks the same as a missing one. The intro text changes with the view. ## Screenshot The transport view with scope `be` typed in: every repeater seen forwarding in the last 24 h, whether it carries `be` (non-carriers first), the scopes it was seen carrying with counts, and its declared regions or *not asked*. Taken against `test-fixtures/e2e-fixture.db` with fictional `be` / `be-van` / `nl` scopes.  ## Browser validation (staging, real data) Headless Chromium against a staging copy of a production database with its live feed: - `#/scope-audit?window=24h`: the declared view, 16 rows, as before. - Clicking **Transport**: 385 rows; the hash gains `mode=transport`. - Typing a scope as `#XX-YY` in capitals: normalised to `xx-yy`; the hash gains it (now `scope=xx-yy`); summary "385 repeaters seen forwarding in the last 24h · 16 with a declared-regions answer · 51 carried xx-yy · 334 active but not seen carrying it"; the first rows are the non-carriers. - Reloading that hash restores view and region. Switching back to Declared hides the Region box and restores the declared intro. No page errors. - Checked at 1280 px and 390 px wide. Two layout bugs found this way were fixed before opening: the region box inherited `width: 100%` from `.nodes-search`, and `display: flex` overrode the `hidden` attribute on the region bar. ## Tests `tests/unit/test-frontend-helpers.js` gains 10 cases on rendered markup and state, using the page's exported internals like the existing scope-audit tests: hash and API paths (and that a region never leaks into the declared view's link), region normalisation (`#`, capitals, empty), *not asked* vs declared chips (with the `*` wildcard), carried-scope chips with counts and the filtered region marked, the carries column only with a region, HTML escaping of names, the summary counts, region suggestions, and the per-view intro. 768 passing in that file. Net-new UI surface, so per TDD.md the tests land in this PR rather than as a preceding red commit. Frontend suites on this branch: 193 of 194; the one failure, `test-issue-1956-release-routing.js`, needs CI's `GITHUB_REF_NAME` and fails identically on `master` locally (#2149 and #2160 address that). ## Review notes - Colours only through existing CSS variables (no hex literals); the filtered region is marked with the link colour, not a new status colour. - The window note's wording is in the same spirit as the declared view's `windowHonestyNote`. - No API calls per row: one request per view/window/region, cached client-side for 30 s like the declared view. ## SonarLint SonarQube Community rules (the SonarLint engine) were run on the files this PR touches, and only issues on lines it adds are listed. - **Fixed** in `df53e770`: - one `modeQuery` helper for the page hash and the API path (S3358; the two copies were identical); - the carries-scope cell built before the row (S3358); - `localeCompare` for the region suggestions (S2871), `for…of` and `.dataset` (S4138, S7761), optional chaining (S6582); - object spread in the helper tests (S6661). - Optional chaining and spread are already used throughout `public/`. No behaviour change. ## Checked against the repo rules - Rebased onto `master` (`673442cc`). Each red commit was re-run and still fails on its assertions; each green commit passes (TDD.md). - `make test`: all 16 modules pass. Frontend suites: all pass except `test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally (#2160 addresses it). - No private data: test fixtures and examples use the codes the existing tests use (SJC/SFO/LAX, `#be`-style scopes). - No new `map[string]interface{}`, no hard-coded colours. `git add` was explicit, and there is one logical change per commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
696906315a |
feat(scope-audit): transport view of every repeater seen forwarding (#2142, API) (#2167)
## Summary Backend half of #2142 (the page is the stacked PR #2169). `GET /api/scope-audit` lists only repeaters that have answered a declared-regions request: 17 of almost 200 nodes on the instance the request came from. This adds a transport view, `GET /api/scope-audit?mode=transport&window=…[&scope=X]`, with one row per repeater **seen forwarding** in the window: - `transported`: the named region scopes it was seen carrying, each with packets and first/last seen (most packets first), plus unscoped, unmatched and ambiguous-hop counts; - `asked`, `declaredRegions`, `notObserved`, `configState`, `declaredAt` where a declared answer exists; `asked: false` with null declared fields where it does not ("not asked" is not "declares nothing"); - with `scope=X` (spelled `be`, `#be` or `BE`): `carriesScope` on every row and `carrying` / `notCarrying` counts, which is the rollout report the issue asks for. `mode=declared` (or no `mode`) is unchanged. An unknown `mode` is a 400. Partial fix for #2142: this PR provides the API; the page is #2169 and the observer region filter #2171. The issue should only be considered done once all three are in. ## How it follows the issue's notes - **The scan does not change.** It is the same single `ScopeAuditForwarding` pass, with every repeater and room in `nodes` (plus any declared target) as a target instead of only the declared ones. Same attribution rules: FLOOD-family route types only, `minForwarderHopHexLen` (no 1-byte hops), and a hop whose prefix matches more than one repeater is credited to none of them. - **Identity lookup with 1,000+ pubkeys:** `scopeAuditNodeIdentities` runs one `IN` query; exercised at 1,200 keys in the benchmark below. - **Row count and response size:** measured below. - **Cache:** the transport view is cached per window with the declared view's TTLs (30 s; 5 min for 7d), under the same singleflight group with a `transport|` key. The scope filter is applied to a copy of the cached result, so one scan serves every scope. (`scope=`, not `region=`: across the API `region=` is the observer IATA filter.) - **Wording:** "transported" is documented as *seen carrying*, not configured for; the declared side stays separate. ## Performance `BenchmarkComputeScopeTransport`: 1,200 repeaters (#1975 quotes an instance with 1,179), 30k flood transmissions in the last 24h on 4-hop paths of 3-byte prefixes, a third region-scoped: ``` BenchmarkComputeScopeTransport-12 5 219253727 ns/op 425.4 response-KB 1200 rows ``` 219 ms per compute, served from cache for 30 s (24h) or 5 min (7d); a 425 KB uncompressed response for 1,200 rows. This is new code with no "before"; the declared view's numbers are untouched (same scan, same cache). ### On real data (a staging copy of a production database and its live feed) | request | rows | result | time | |---|---|---|---| | declared view, 24h | 16 | (unchanged) | | | `mode=transport`, 24h | **385** repeaters seen forwarding, 16 of them with a declared answer | | 1.5 s cold | | `mode=transport&scope=<scope A>`, 7d | 664 | **51 carrying it, 613 not** | 14.4 s cold | | `mode=transport&scope=%23<SCOPE B>` (`#` and capitals), 7d, same window now cached | 664 | 237 carrying, 427 not | 0.35 s | The 7d cold compute is the same full-window hop scan the declared view already pays for 7d (its own TTL comment measures 16.7 s on a live-shaped database); like the declared view it is then cached for 5 minutes, and every scope filter is served from that one scan. - Commit `ceaeb56b` renamed the filter from `region=` to `scope=` after opening; the real-data figures above were taken before the rename and are otherwise the same code. ## Tests - Red commit `2a9b1286`: response types and a `mode=transport` stub that returns no rows, plus tests. Four fail on the stub: - repeaters that never answered are listed with `asked: false` and null declared fields (and the answered one with its declared list and `notObserved`); - `scope=fr` / `%23fr` / `FR` marks carriers and non-carriers and counts them; no scope means no `carriesScope` or counts; - an unknown mode is a 400; - the transport and declared views are cached separately (requesting one does not serve the other). - Also added, passing on the stub by construction: a repeater with no traffic in the window is not listed; the node blacklist applies. - Green commit `60c669f4`: the implementation, `docs/api-spec.md` (new `mode` and `scope` parameters, a Transport view section with the response shape), and the benchmark. - `make test`: 16 of 16 modules pass, including every existing scope-audit test. ## Review notes - Hidden-name and blacklist filtering use the same rules as the declared view. - `newTestStoreWithDB` now takes `testing.TB` so the benchmark can use it (callers unchanged). - No new `map[string]interface{}`; the response is a named struct. - No config added. ## SonarLint SonarQube Community rules (the SonarLint engine) were run on the files this PR touches, and only issues on lines it adds are listed. - **Fixed** in `060668b6`: S3776 (cognitive complexity 41 > 15) in the new `computeScopeTransport`. It is split into target, visibility, row and sort helpers; the response is the same and the transport tests are unchanged. ## Checked against the repo rules - Rebased onto `master` (`673442cc`). Each red commit was re-run and still fails on its assertions; each green commit passes (TDD.md). - `make test`: all 16 modules pass. Frontend suites: all pass except `test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally (#2160 addresses it). - No private data: test fixtures and examples use the codes the existing tests use (SJC/SFO/LAX, `#be`-style scopes). - No new `map[string]interface{}`, no hard-coded colours. `git add` was explicit, and there is one logical change per commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8a3b923e81 |
perf(scope-stats): keep the per-region count on the first_seen index (#2166)
## Summary `/api/scope-stats` averaged **2.6 s on one production instance (max 17.7 s)**, whatever the window. Four of its five queries take 10-100 ms on that database. The fifth, the per-region count, took **2.5-2.9 s for every window, 1h included**: SQLite chose `idx_tx_scope_name` (`scope_name > ''`) and walked every scoped transmission ever stored (45 days of them), filtering by `first_seen` afterwards. ``` SEARCH transmissions USING INDEX idx_tx_scope_name (scope_name>?) | USE TEMP B-TREE FOR ORDER BY ``` A unary `+` on every `scope_name` reference in that query (`WHERE … +scope_name …`, `GROUP BY +scope_name`) stops the planner using that index. The query then searches the window on `idx_transmissions_first_seen` and groups in a temp b-tree, the same idiom the `advertsByRole` query in the same function already uses for `payload_type`. Putting `+` in the `WHERE` alone is not enough: the planner then walks `idx_tx_scope_name` to avoid sorting for the `GROUP BY`, which is just as slow; the `GROUP BY +scope_name` is what fixes it. ``` SEARCH transmissions USING INDEX idx_transmissions_first_seen (first_seen>?) | USE TEMP B-TREE FOR GROUP BY | USE TEMP B-TREE FOR ORDER BY ``` Related to #2146, which lists `/api/scope-stats` (2.3 s average on one instance) and records the 24 h summary as 7 ms warm there. On the instance measured here the per-region query planned onto `idx_tx_scope_name` and took 2.5-2.9 s for every window; this PR fixes that plan. Not marked as a fix: #2146 is about pool and lock waits. ## Results are identical On a copy of the production database, the old and new query run inside **one read transaction** (same snapshot) return exactly the same rows for 1h, 24h, 7d and 45d (5, 10, 12 and 18 regions; 73, 2,024, 13,822 and 76,083 transmissions). `TestScopeStatsByRegion_CountsMatchWindow` also checks the per-region counts against a direct count of the window. ## Performance ### The query alone, on a copy of the production database 311k transmissions, 76k of them region-scoped over 45 days; best of three: | window | before | after | |---|---|---| | 1h | 2,530 ms | 0.1 ms | | 24h | 2,570 ms | 11.6 ms | | 7d | 2,876 ms | 124 ms | ### The endpoint, on staging Cold `GET /api/scope-stats` (the response cache is 30 s, so each sample is a full compute) on a staging host with a copy of a production database and its live feed; two samples each, 32 s apart: | window | master | this PR | |---|---|---| | 1h | 3.08 s, 2.83 s | **16 ms, 12 ms** | | 24h | 2.69 s, 2.79 s | **131 ms, 101 ms** | | 7d | 3.35 s, 4.53 s | **626 ms, 663 ms** | ### Go benchmark `BenchmarkGetScopeStats24h`: all five scope-stats queries for 24h, on 300k transmissions spread over 45 days (a quarter region-scoped), with production's tables and both competing indexes and `ANALYZE` run as production does (#2072). 10 runs: ``` cpu: AMD Ryzen 5 3600XT 6-Core Processor │ master │ this PR │ GetScopeStats24h-12 33.186m ± 3% 8.058m ± 6% -75.72% (p=0.000 n=10) ``` The fixture is smaller and better cached than production, which is why the absolute times are lower than above; the plan change is the same. ## Tests - Red commit `a7dcf98f`: moves the query into `scopeStatsByRegionQuery` (unchanged), adds the fixture, the benchmark, `TestScopeStatsByRegion_UsesFirstSeenIndex` (`EXPLAIN QUERY PLAN` must use `idx_transmissions_first_seen` and not `idx_tx_scope_name`; fails on `master` with exactly the production plan shown above) and `TestScopeStatsByRegion_CountsMatchWindow` (passes on both commits; guards correctness). - Green commit `f9ba986b`: the `+` hints and a comment explaining them. - All existing scope-stats tests pass; `make test`: 16 of 16 modules pass. ## Review notes - Only the per-region query changes; the summary, non-transport, time-series and adverts-by-role queries already search by `first_seen`. - If `idx_transmissions_first_seen` were ever missing, the query would still run correctly (full scan); `+` only removes `idx_tx_scope_name` from consideration, unlike `INDEXED BY`, which would make the query fail. - `/api/scope-stats` still has no singleflight around its 30 s cache (unlike stats, channels and the scope audit). With the query now well under a second it matters much less; left for a separate change. ## SonarLint SonarQube Community rules (the SonarLint engine) were run on the files this PR touches, and only issues on lines it adds are listed. - No issues on the added lines. ## Checked against the repo rules - Rebased onto `master` (`673442cc`). Each red commit was re-run and still fails on its assertions; each green commit passes (TDD.md). - `make test`: all 16 modules pass. Frontend suites: all pass except `test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally (#2160 addresses it). - No private data: test fixtures and examples use the codes the existing tests use (SJC/SFO/LAX, `#be`-style scopes). - No new `map[string]interface{}`, no hard-coded colours. `git add` was explicit, and there is one logical change per commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
95672ac2be |
perf(server): share /api/nodes responses for identical queries for 15 s (#2165)
## Summary
`/api/nodes` scans every node row into a map, enriches it (hash size,
multi-byte capability, relay info, usefulness scores, declared scope)
and encodes the page on **every** request. On one production instance (2
vCPU / 2 GB) that was **95 GB of allocation in 45 hours, about 6.7 MB
per request and a quarter of all bytes allocated**, on a process where
garbage collection was 87% of CPU.
The queries repeat heavily. In that instance's nginx log for one day,
three pages of one query (`limit=500&offset=0|500|1000&lastHeard=30d`)
were 10,000 of the 14,053 `/api/nodes` requests.
This PR keeps the encoded response for 15 s per distinct query:
- `handleNodes` serves the cached bytes when it can; otherwise it builds
through `buildNodesResponse` (the old handler body, unchanged apart from
returning instead of writing) inside a singleflight, so concurrent
identical misses share one build.
- **Cache key:** blacklist generation | hidden-prefix generation |
sorted query string. Parameter order does not split the cache, and any
change to either filter list misses it at once. This is the pattern
`/api/nodes/{pubkey}/reach` already uses (#1629).
- `setGeoFilter` drops the cache, so a geo-filter change through the
admin endpoint shows immediately.
- **Bounded twice:** by `maxCacheEntries` (256) like the other keyed
caches, and by bytes, since a full-list response is several MB. Expired
entries are dropped on every insert, the total is capped at 32 MB, and a
single response over 8 MB is not cached.
- **15 s** matches the relay/usefulness caches the response is built
from (#1257); the client already keeps its own node list for 90 s.
Related to #2146: `/api/nodes` is in its endpoint table (144 s max on
one instance). This PR reduces how often the handler runs and allocates,
not the SQL wait the issue is about, so it is not a fix for it.
## Performance
### Go benchmarks
1,100 nodes, half repeaters, 10 runs each:
```
cpu: AMD Ryzen 5 3600XT 6-Core Processor
│ master │ this PR │
│ sec/op │ sec/op vs base │
HandleNodesRepeated-12 5991.14µ ± 3% 41.21µ ± 7% -99.31% (p=0.000 n=10)
HandleNodesUnique-12 6.010m ± 2% 5.967m ± 0% ~ (p=0.393 n=10)
│ B/op │ B/op vs base │
HandleNodesRepeated-12 1611.0Ki ± 1% 240.9Ki ± 0% -85.05% (p=0.000 n=10)
HandleNodesUnique-12 1.566Mi ± 1% 1.753Mi ± 1% +11.96% (p=0.000 n=10)
```
`Repeated` is one query asked over and over (the production pattern).
`Unique` makes every request a different query, so nothing is served
from cache: the time is unchanged, and it allocates 12% more because the
body is now built in memory so it can be kept.
### Expected hit rate
Replaying that day's 14,053 `/api/nodes` requests through a cache of
each lifetime:
| TTL | served from cache |
|---|---|
| 5 s | 39% |
| 10 s | 47% |
| **15 s** | **52%** |
| 30 s | 58% |
| 60 s | 69% |
### Staging A/B
The same 30 minutes of real production API traffic (5,640 requests, 279
of them `/api/nodes`), replayed at 2x on a staging host against a copy
of a production database, on `master` and on this branch:
| `/api/nodes` | master | this PR |
|---|---|---|
| p50 | 47.1 ms | **31.0 ms** |
| p95 | 165.4 ms | **114.4 ms** |
| max | 759.9 ms | 321.7 ms |
| allocated by `handleNodes` during the replay | 1,000 MB | **594 MB
(-41%)** |
| total allocated by the process | 3,704 MB | 3,349 MB (-10%) |
The -41% matches the expectation from the hit rate and the miss-path
cost: about 0.48 × 1.12 + 0.52 × 0.15 ≈ 0.61 of the old allocation.
## Tests
- Red commit `ab33bc29`: a test-only build hook, the tests and both
benchmarks. Three fail on `master`: identical queries are built once,
reordered parameters share an entry, ten concurrent misses share one
build. Three more guard against over-caching and pass on both commits:
different queries are built separately, an expired entry is rebuilt, a
geo-filter change drops entries.
- Green commit `ea0d1cf2`: the cache, plus
`TestHandleNodes_FilterListChangesMissCache` (hidden-prefix and
blacklist changes miss the cache) and `TestNodesCache_BoundedByBytes`
(stays under 32 MB; an oversized response is not cached).
- One existing test changed: `TestHandleNodesRegionUsesStore` already
clears the store's own 30 s region cache by hand before its second
request; it now clears this cache on the same line.
`TestHiddenNamePrefix_1181_NodesList`, which changes the hidden-prefix
list between requests, passes unchanged because the generation is in the
key.
- `go test -race` on the new tests passes. `make test`: 16 of 16 modules
pass.
## Review notes
- The response depends only on the query string and server state, not on
who is asking (no auth or header-dependent output in the handler), so a
shared cache is safe.
- `writeJSONBytes` writes the cached bytes with the same `Content-Type`
and status as `writeJSON`; the body has the trailing newline
`json.Encoder` adds, so output is byte-identical.
- No new `map[string]interface{}` added (the existing node maps are
unchanged; converting them to a struct is a separate, larger job).
- The TTL and byte budget are constants. They could be operator settings
later (AGENTS.md rule 8); not added here.
## SonarLint
SonarQube Community rules (the SonarLint engine) were run on the files
this PR touches, and only issues on lines it adds are listed.
- **S3776 on `buildNodesResponse`:** complexity 91. That is upstream's
existing handler body moved unchanged (it is also 91 on `master`); this
PR makes the handler itself simpler.
- **Fixed** in `25d40950`: the test hook moved to the cache-miss path,
so the builder stays identical to the old body rather than one point
above it. Splitting that function is a refactor of its own.
## Checked against the repo rules
- Rebased onto `master` (`673442cc`). Each red commit was re-run and
still fails on its assertions; each green commit passes (TDD.md).
- `make test`: all 16 modules pass. Frontend suites: all pass except
`test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME`
and fails the same way on `master` locally (#2160 addresses it).
- No private data: test fixtures and examples use the codes the existing
tests use (SJC/SFO/LAX, `#be`-style scopes).
- No new `map[string]interface{}`, no hard-coded colours. `git add` was
explicit, and there is one logical change per commit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
323b8040d4 |
perf(frontend): throttle nav stats to one refresh per 15 s, none in hidden tabs (#2162)
## Summary The nav bar's packet / node / observer counts were refreshed three ways at once: a 15 s timer, every batch of live WebSocket packets (`debouncedOnWS`, 250 ms), and the `/stats` client cache being cleared every 5 s while packets arrive. None of them checked whether the tab was visible. So every open tab, including hidden ones, asked `/api/stats` every few seconds for as long as it stayed open. On one production instance's nginx log for a single day, `/api/stats` was **135,654 of 187,411** CoreScope API requests (72%), about 20,000 a day for each browser that kept a tab open. Each response is cheap thanks to the 10 s server-side cache (#1910), so this is about request volume, bandwidth and log noise, not server CPU. This PR adds `createVisibleThrottle()` to `app.js` and routes all three triggers through one throttled refresh: - at most one `/api/stats` request per 15 s from a visible tab (the old timer's own period); - none from a hidden tab; - one immediate refresh when a hidden tab becomes visible again. No existing issue covers this. ## Performance Measured with headless Chromium, counting `/api/stats` requests from a real tab: 60 s visible, then 60 s with the page hidden (`document.hidden` and a `visibilitychange` event), then back to visible. | `/api/stats` requests | production (current release) | staging, `master` | staging, this PR | |---|---|---|---| | visible tab, per minute | 9 | 4 | 4 (at +10, +25, +40, +55 s) | | hidden tab, per minute | 6 | 5 | **0** | | on return to visible | n/a | n/a | 1, immediately | The same check confirmed the nav bar still renders its counts (`… pkts · … nodes · … obs`). ## Tests - Red commit `2c81a3e4`: `tests/unit/test-nav-stats-throttle.js` loads the real `public/app.js` in a `vm` context. `createVisibleThrottle` lands as a pass-through stub, so the test fails on its first assertion (40 refreshes where 1 is expected). - Green commit `f0ba92ab`: the throttle, and the nav stats timer, WS hook and a new `visibilitychange` listener calling one throttled trigger. All 6 cases pass: one refresh per burst, again after the interval, at most 241 an hour, none while hidden, one on return, and a source check that `updateNavStats` is wrapped and not called by the timer directly. - Added to `test-all.sh` in sorted order; `test-test-inventory.js` passes. - Frontend suites: 194 of 195 pass. The one failure, `test-issue-1956-release-routing.js`, needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally. ## Review notes - `home.js` keeps its own one-shot `/stats` load on the home page; only the nav bar's polling changed. - A tab opened in the background loads its counts when it is first shown. - The 15 s interval is a constant at the call site. Could be exposed in the customizer later (AGENTS.md rule 8); not added here. - No Playwright E2E added: the change is timing behaviour, covered by the unit test against the real file and by the browser measurement above. Happy to add an E2E if wanted. ## SonarLint SonarQube Community rules (the SonarLint engine) were run on the files this PR touches, and only issues on lines it adds are listed. - **Fixed** in `9eb94ea1`: `node:` specifiers (S7772). - **Kept on purpose:** - S7773 wants `Number.isFinite`/`Number.parseInt` for the `isFinite` and `parseInt` handed to the `vm` sandbox, but `app.js` calls the global functions, so those are the ones it needs. - S1523 flags `vm.runInContext`, which is how the frontend unit tests load the repo's own `public/app.js`. ## Checked against the repo rules - Rebased onto `master` (`673442cc`). Each red commit was re-run and still fails on its assertions; each green commit passes (TDD.md). - `make test`: all 16 modules pass. Frontend suites: all pass except `test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally (#2160 addresses it). - No private data: test fixtures and examples use the codes the existing tests use (SJC/SFO/LAX, `#be`-style scopes). - No new `map[string]interface{}`, no hard-coded colours. `git add` was explicit, and there is one logical change per commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
be6aeffe45 |
perf(store): drop the duplicate subpath count map (#2163)
## Summary
The subpath index keeps every contiguous run of 2-8 hops from every
stored transmission's path, in two maps:
- `spIndex`: key → count
- `spTxIndex`: key → transmissions containing that subpath, one entry
per occurrence
Both were always updated together (the only production callers were
`addTxToSubpathIndexFull` / `removeTxFromSubpathIndexFull`, which touch
both), so every count equalled `len(spTxIndex[key])`. The existing
`TestSubpathTxIndexPopulated` asserted exactly that. The index therefore
held its 1M+ keys twice.
This PR removes `spIndex`. The two analytics consumers read `len(txs)`,
and the add/remove helpers take only the transaction map. No API
response changes.
No existing upstream issue covers this; it came out of profiling a
production instance where garbage collection was 87% of CPU (30 s CPU
profile: `gcDrain` 39.9 s of 45.9 s sampled) with an 805 MB live heap
against a 1 GiB `GOMEMLIMIT`. That heap profile put 186 MB in this
index; on that instance's 7-day window there are 1.2M distinct subpath
keys (79% seen once) and 3.07M entries.
## Performance
### Go benchmark
`BenchmarkBuildSubpathIndex` builds the index for a synthetic,
production-shaped window: 41k transmissions with path lengths drawn from
the production hop-count histogram, and hops from random walks over a
400-node graph with 3-byte prefixes. That gives 1.48M keys, 85% seen
once (production: 1.2M, 79%). No real data is used. 10 runs each:
```
cpu: AMD Ryzen 5 3600XT 6-Core Processor
│ master │ this PR │
│ sec/op │ sec/op vs base │
BuildSubpathIndex-12 2.157 ± 1% 1.610 ± 5% -25.33% (p=0.000 n=10)
│ retained-B │ retained-B vs base │
BuildSubpathIndex-12 238.5Mi ± 0% 187.7Mi ± 0% -21.31% (p=0.000 n=10)
│ B/op │ B/op vs base │
BuildSubpathIndex-12 572.0Mi ± 0% 465.6Mi ± 0% -18.61% (p=0.000 n=10)
```
(`retained-B` is the live heap the index keeps after a forced GC: 250.1
MB → 196.8 MB.)
### Staging heap
Live heap after a forced GC (`/debug/pprof/heap?gc=1`), on a staging
host running a copy of a production database and its live MQTT feed,
attributed to the subpath index builder:
| | master | this PR |
|---|---|---|
| subpath index (`buildSubpathIndex`, cum) | 172.4 MB | 116.1 MB |
| packets in memory at the time | 27,521 | 24,086 |
| per packet | 6.27 KB | 4.82 KB (**-23%**) |
The two snapshots were taken minutes apart with different numbers of
packets loaded, hence the per-packet figure; it agrees with the
benchmark's -21%.
The same A/B replay used for the other PRs (30 minutes of real
production API traffic) showed no latency change (p50 10.3 ms vs 10.7
ms, p95 59.5 ms vs 52.9 ms, 0 errors). GC count and CPU were lower in
this run (44 → 28 collections, 13.0% → 9.6% CPU), but runs on this
shared host vary by that much on their own, so no claim is made from
them.
## Tests
- Red commit `ded359ff`: the fixture, `TestSubpathFixtureShape` (keeps
the fixture production-like: 60-95% singleton keys),
`BenchmarkBuildSubpathIndex`, and `TestSubpathIndexRetainedHeap`, which
allows at most 225 MB for the 41k fixture. It fails on `master` with
250.1 MB.
- Green commit `e399c561`: the change. The heap test passes at about 197
MB.
- `make test`: 16 of 16 modules pass; `go test -race` over the subpath,
eviction, path-hop and resolved-index tests passes.
Test changes in the green commit are mechanical, as required for a
structural change:
- struct literals stop setting `spIndex`; where a literal did not set
`spTxIndex` it now does, because appending to a nil map panics where the
old code skipped the transaction map when passed `nil`;
- count assertions read `len(spTxIndex[key])`;
- the old "spIndex and spTxIndex agree" check becomes "no key is kept
with an empty list", which is what keeps `len()` a correct count.
## Review notes
- Removal still takes out one occurrence per subpath occurrence and
deletes a key whose list becomes empty, so `len()` stays equal to the
old count through eviction.
- The store's memory estimate (`perSubpathEntryBytes`) is unchanged
here; accounting accuracy is a separate item.
- No new `map[string]interface{}`.
## SonarLint
SonarQube Community rules (the SonarLint engine) were run on the files
this PR touches, and only issues on lines it adds are listed.
- **Fixed** in `8af9582f`: S3776 (cognitive complexity 16 > 15) in the
test fixture. Path generation is split into graph and hop-count helpers;
the random draws stay in the same order, so the fixture is unchanged.
## Checked against the repo rules
- Rebased onto `master` (`673442cc`). Each red commit was re-run and
still fails on its assertions; each green commit passes (TDD.md).
- `make test`: all 16 modules pass. Frontend suites: all pass except
`test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME`
and fails the same way on `master` locally (#2160 addresses it).
- No private data: test fixtures and examples use the codes the existing
tests use (SJC/SFO/LAX, `#be`-style scopes).
- No new `map[string]interface{}`, no hard-coded colours. `git add` was
explicit, and there is one logical change per commit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
7f2c999266 |
fix(ingestor): record cancelled background migrations separately (#2161)
Refs #1739. Depends on the small local-validation fix in #2160; merge that first. This branch contains its prerequisite commit plus one migration-status commit. An interrupted background migration currently records `failed`, making an operator shutdown look like a migration fault. Errors matching `context.Canceled`, including wrapped errors, now record `cancelled` and retain the error and completion time without logging `FAILED`. Ordinary errors, deadline expiry and panics remain failures even when the context is cancelled; a successful callback still records `done`. Cancelled work follows the existing retry path on the next run. Regression tests cover classification and clearing stale completion/error fields before retrying, then successful completion. The migration runbook documents the distinction. This covers the existing ingestor bookkeeping portion of #1739. The issue's server status mapper and banner depend on closed, unmerged #1735 and are absent from current master; this PR does not add that API/UI and leaves #1739 open for the remaining scope. No new configuration, customizer setting or schema migration is required. Validation: direct and wrapped cancellation failed the new regression before the change; focused tests and the race check pass afterward. Native full server/ingestor suites and all 194 standalone frontend suites pass with #2160. The browser smoke suite passed 148 checks, with three fixture-dependent skips and two pre-existing disabled cases. Windows-only filesystem capability skips are documented in #2160; Linux CI retains the security assertions. Performance impact: one error-chain check when a background migration finishes, using the existing single status update. No packet-ingestion loop, batching, polling or retention behavior changes, and no speedup is claimed. |
||
|
|
a5b60ca594 |
test: make local validation independent of runner environment (#2160)
Fixes #2159. Related to #1995. Standalone release tests failed when `GITHUB_REF_NAME` was absent and could use an unrelated runner tag when it was present. Supply the simulated tag explicitly; exercise the real release-notes step with both missing and unrelated host values, including present and absent notes files. Two filesystem fixtures now report unsupported Windows capabilities explicitly: POSIX permission bits, and symlink creation denied with error 1314. Unix mode checks and all symlink safety assertions remain intact; other symlink setup errors still fail. Production code and workflows are unchanged. This is 29 added lines across three test files. Validation: reproduced all three failures before fixing them; focused regressions, the full standalone frontend suite, and native Go server/ingestor suites pass. The POSIX permission and unprivileged Windows symlink fixtures are explicitly skipped on this host and remain enabled in Linux CI. The browser smoke suite passed 148 checks, with three fixture-dependent skips and two pre-existing disabled cases. No configuration or customizer changes. |
||
|
|
0737f10a5d |
feat(notify): optional external node alerts feed and the node.external event (#2177) (#2178)
Closes #2177. ## What Node notifications can mail alerts computed outside CoreScope. With `userManagement.notifications.externalAlerts` configured, users can choose a new event, `node.external`, which is off by default and only offered when configured: ```json "notifications": { "enabled": true, "externalAlerts": { "url": "http://coverage-job/alerts.json", "maxAgeHours": 48, "label": "Coverage" } } ``` The feed: ```json { "generatedAt": "2026-10-11T01:35:12Z", "alerts": [ { "pubkey": "<64 hex>", "key": "SE", "text": "never heard to the south-east", "url": "https://example.org/coverage" } ] } ``` Mail lines: `<name>: Coverage: never heard to the south-east, <time>` when an alert appears, `<name>: Coverage resolved (SE), <time>` when it is gone from a fresh feed. ## How - `cmd/server/notify_external.go`: feed types, `parseExternalFeed` (RFC 3339 `generatedAt`, age check, per-alert validation), `fetchExternalFeed` (200 only, 1 MB cap, 10 s timeout). - `notify_eval.go`: `evalExternal`. Subjects are `<pubkey>/<key>`; a `<pubkey>/*` row is the per-node baseline, so the first fresh feed after watching stores the current alerts without a mail (like `foreign.new`), and later alerts mail at once. `External == nil` (not configured, nobody chose it, or stale) leaves every `node.external` state as it is. - `notify_service.go`: the feed is fetched only when configured and at least one user chose the event. Each new reason the feed is unusable is logged once, and so is the recovery. Mail text and link: the alert's `url`, otherwise the node page. - `notify_handlers.go`: `availableEvents` and the new `externalLabel` include the event only when configured; PUT answers 400 for `node.external` otherwise. - `internal/users/notify.go`: event constant, `OptionalNodeNotifyEvents` (canonical order: node, optional, admin), `RemoveWatch` also deletes the node's `<pubkey>/...` rows. - Account page label, OpenAPI descriptions, `config.example.json` comment, `docs/user-guide/accounts.md` section. Decisions on the three open questions in #2177: the generic name `node.external`; the resolved mail names the key instead of repeating the text, so there is **no `users.db` schema change** (a bump would also make older binaries refuse the file); one feed. ## Invariants - Read/write separation (#1283): the server only reads the feed over HTTP. State rows go to `users.db` through `internal/users`, like the other events. - A dead, garbled or old feed never mails "resolved": `TestEvaluateExternalStaleFeedChangesNothing` and `TestNotifierMailsExternalAlertsAndSkipsAStaleFeed` (HTTP 502 and a 72 h old feed in a row, then a fresh empty feed resolves). ## Performance The evaluator adds one pass over the stored states (already read every tick) plus, per user with the event on and per watched node, the node's current alerts. Bounded by `maxWatchesPerUser` (50) times the alerts per node. The feed is fetched once per tick (default 5 minutes) only when someone chose the event, capped at 1 MB. No packet-store lock is taken for it. Nothing changes for instances without `externalAlerts`. ## Tests - `cmd/server`: `go test` green locally (Windows; `TestSaveGeoFilterPreservesFileMode` skipped as it needs Unix mode bits). New: `notify_external_test.go` (feed parsing and limits, HTTP fetch 200/404/oversized, config resolution, evaluator baseline/new/resolved/returning/stale/not chosen/notifications off, notifier end to end with a fake feed, account routes). - `internal/users`: green, 2 new tests (optional event and canonical order, `RemoveWatch` deletes external subjects only for that node). - Frontend: `test-all.sh` green, 1 new test in `test-notifications-ui.js` (label from `externalLabel`, escaped). - `scripts/check-xss-sinks.sh --file public/notifications.js`: clean. ## Not done - No E2E test: the account page change is one label, covered by the unit test. - Not run against a live instance yet. The first feed in use is the coverage job of on8ar.eu (`alerts.json`, 29 alerts at the moment). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c2a590b741 |
feat(companions): linked-only ingest, account Companions and My coverage (#2176)
Part 3 of 3 for #2173 (companion linking): the optional linked-only ingest filter, the account page and the "My coverage" toggle, plus the docs. Builds on #2174 and #2175 (both merged); `upstream/master` is merged in, so the diff shows only this part. ## Changes in this PR **Ingestor: linked-only ingest** (`cmd/ingestor/linked_companions.go`) - With `clientRxCoverage.requireLinkedCompanion` on (default off) and user management enabled, every `meshcore/client/<pubkey>/...` message whose pubkey is not in `companion_links` is dropped before any client handler runs, and counted (`client_unlinked_dropped` in the stats file). - The ingestor opens `users.db` read-only (`mode=ro`, raw SQL, no `internal/users` import), like the approved-channels reader, so the #1283 write invariant holds. `TestLinkedCompanionSetIsReadOnly` pins it. - The set refreshes every 60 s. A miss re-reads at most once per 5 s, so a companion linked a moment ago is accepted within seconds while a flood of unknown pubkeys costs one read per 5 s. - It passes everything until its first successful read, and when `users.db` has no `companion_links` table yet (a server older than this). After the first read it keeps the last set on a read error. It is a filter, not a security boundary: anyone with the shared broker credential can still publish under a linked pubkey. **Frontend** - Account page: device tokens show under Devices as "CoreDrive RX" with their label; a new Companions section lists linked companions (name, short key, linked since, last seen) with an Unlink button. - Coverage page (`#/rx-coverage`): a "My coverage" toggle for logged-in users (`mine=1`). Only the newest coverage reply is drawn, and a refused `mine=1` turns the toggle off. Under "My coverage" the page does not ask for the "nothing received" cells from #2172, and their legend entry is hidden. **Docs**: user guide (accounts: devices, companions), `docs/client-rx-coverage.md` (linking, linked-only ingest, the CORS exception), `config.example.json`, and the spec status. ## Performance The filter runs on every client message. Benchmarks with 1000 linked companions (12-core dev box, `go test -bench`, 3 runs each): | Path | Time | Allocations | |---|---|---| | linked pubkey (lock-free map lookup) | 54 ns/op | 0 | | linked pubkey, parallel | 11-14 ns/op | 0 | | unlinked pubkey inside the 5 s miss gap | 85-95 ns/op | 0 | | one `users.db` re-read | 0.38 ms | 3030 | The unlinked benchmark asserts that no extra `users.db` read happens. A re-read happens at most once per 5 s on a miss and once a minute from the refresh loop. ## Verification - `cmd/ingestor`: 15 new tests (filter off changes nothing, linked passes, unlinked dropped before every handler, miss refresh accepts a new link, the 5 s cap holds under a flood, unlink honoured after the periodic refresh, missing table and missing file, fail-closed only after the first read, read-only open, startup, stats counter) and 4 benchmarks. Full ingestor suite green. - Frontend unit tests for the account page (device tokens, companions list escaping, empty state, Unlink confirm and cancel, load errors) and `tests/unit/test-rx-coverage-mine.js`. - E2E `tests/e2e/test-user-management-e2e.js` gains a companion step (device token under Devices, a companion linked with a real Ed25519 signature under Companions, Unlink). Run locally against the e2etest build: 28/28. The coverage suites (`test-rx-coverage-gaps-e2e.js`, `-noise-`, `-viewport-`) still pass. - Frontend unit suite (`test-all.sh`) green. ## Not in this PR - Narrowing the "nothing received" cells to the caller's companions under "My coverage" (the track query takes one companion today). - Per-user broker credentials or ACLs, which would turn the filter into an access control (non-goal in the spec). --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|
|
b2cda21a27 |
feat(server): companion linking routes, device tokens and bearer auth (#2175)
Part 2 of 3 for #2173 (companion linking): the server routes, bearer auth and the link handshake. Builds on #2174 (merged); `upstream/master` is merged in, so the diff shows only this part. Everything here is behind `userManagement.enabled` and answers 404 with it off. ## Changes in this PR **Device tokens and bearer auth** - `POST /api/auth/device-token`: email + password returns a bearer token, stored as a `kind = 'device'` session with a label. Same failure answers and the same rate limit as login. - Server-enforced scope for a bearer token: `/api/auth/me`, `/api/auth/logout`, `/api/account/companions*`, `/api/account/settings`. Everything else answers 403. - A bearer header only counts when there is no session cookie, so an auth proxy that adds its own `Authorization` header does not hide a logged-in browser. Cookies never accept a device token. - The sessions list on the account API carries `kind` and `label`; logout with a bearer token revokes that device. **Linking a companion** - `POST /api/account/companions/challenge` `{pubkey}` returns `{challenge, expiresAt, host}`: 32 random bytes, single use, 5 minutes, bound to the caller and the pubkey. - `POST /api/account/companions` `{pubkey, challenge, signature, name}`: the signature is the companion's Ed25519 signature over `corescope-link:<host>:<challenge>` (`internal/sigvalidate.VerifyMessage`). The challenge is consumed in every case. - A companion linked to another account moves to the caller (the newest proof wins). Both users get a `companion.transfer` audit row and the previous owner a mail. That mail is a security notice: it is sent whatever the node notification settings are. - `GET /api/account/companions` (with `lastSeenAt` from `client_receptions`) and `DELETE /api/account/companions/{pubkey}`. - Linking does not touch the synced `meshcore-my-nodes`: a companion is not a node to monitor. - Admin user detail lists linked companions; the account data export includes companions and device sessions. **Coverage and config** - `GET /api/rx-coverage?mine=1`: only coverage of the caller's linked companions (401 when logged out, 403 for a device token). With `gaps=1` (#2172) the reply carries no `gaps` member under `mine=1`, because the track query takes one companion and cannot narrow to a set yet (`TestRxCoverageMineOmitsGaps`). - `clientRxCoverage.requireLinkedCompanion` (default off) is read and reported in `/api/config/client`, which also announces `userManagement.companionLinking`, so CoreDrive RX shows its login only where linking exists. The ingestor side is part 3. - CORS: for origins in `corsAllowedOrigins`, the device-token routes alone also allow POST/PUT/DELETE and the `Authorization` and `Content-Type` headers. No credentialed CORS. Documented in `config.example.json`. - OpenAPI and `docs/api-spec.md` cover the new routes. ## Verification - 26 new tests in `cmd/server` and `internal/sigvalidate`, among them: `TestCompanionLinkFlow`, `TestCompanionLinkRejections` (signature by another key, malformed signature, challenge bound to another pubkey, unknown challenge; reuse and expiry are covered by the store tests in #2174), `TestCompanionTransfer`, `TestCompanionTransferMailIgnoresNotificationSettings`, `TestBearerScopedRoutes`, `TestBearerIgnoredWithCookie`, `TestDeviceTokenSharesLoginRateLimit`, `TestCORSBearerRoutes`, `TestCompanionRoutesAbsentWhenOff`, `TestRxCoverageMine`, `TestRxCoverageMineOmitsGaps` (fails without the `mine == nil` guard), `TestVerifyMessage`. - `cmd/server`, `internal/sigvalidate` and `internal/users`: `go vet` and `go test ./...` green, `gofmt` clean. - The linking flow runs on analyzer.on8ar.eu since 9 October 2026 with CoreDrive RX v1.19.0 (a real companion linked through the signed challenge). ## Not in this PR - The ingestor's linked-only filter, the account page (Devices, Companions), the "My coverage" toggle and the user docs: part 3. - Per-user broker credentials and per-user API keys (non-goals in the spec). --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|
|
9897d52cf6 |
feat(users): companion linking store (schema v6, device sessions, link challenges) (#2174)
Part 1 of 3 for #2173 (companion linking). This PR adds only the `users.db` side: the schema and store methods the server (part 2) and ingestor (part 3) build on. No route, no UI and no behaviour change for a running server. ## Changes in this PR - **Design spec** `docs/specs/2026-10-08-companion-linking-design.md`, including the optional linked-only ingest. - **Schema v6** (`internal/users/schema.go`): - `sessions` gains `kind` (`'web'` or `'device'`), `label` and `scopes` (`''` = full, for web sessions). - `companion_links (pubkey PRIMARY KEY, user_id, name, linked_at)`, cascading on user delete. - `link_challenges`: single-use challenges bound to a user and a pubkey, with an expiry. - **Device sessions** (`sessions.go`): create a device session with a label and scopes, list sessions with kind and label. - **Challenges** (`challenges.go`): issue and consume a challenge exactly once; consumed, expired, or bound to another user or pubkey all fail. - **Companion links** (`companions.go`): link, list, unlink. Linking a pubkey that belongs to another user moves it and reports the previous owner (transfer detection), so the server can audit and mail. - **Janitor**: the existing janitor also prunes expired challenges (`cmd/server/auth_service.go`, 3 lines). ## Upgrade note The store migrates `users.db` to v6 on start. A binary that only knows v5 refuses to open a v6 file (`users: database schema version 6 is newer than this binary supports (5)`), so rolling back after this lands needs the pre-upgrade `users.db` backup. ## Verification - `internal/users`: new tests for the v6 migration from v5, device sessions, challenges (single use, expiry, binding) and companion links (transfer, cascade on user delete). `go vet` and `go test ./...` green. - `cmd/server` full suite green against the v6 store. ## Not in this PR - Routes, bearer auth and the link handshake: part 2. - Ingestor filter, account page and "My coverage": part 3. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|
|
f3004d977e |
feat(rx-coverage): show where a companion drove and received nothing (#2172)
The Mobile RX coverage page (`#/rx-coverage`) shows only cells where a companion received something. Where a companion drove and received nothing is just as useful for operators, and the data is already there: the RF sample track (`client_rf_samples`, #1905/#1906) and the receptions (`client_receptions`) use the same hex grid (`hexCellAt`, `zoomToHexRes`). This PR adds those cells as a hatched layer under the reception cells. ## Changes in this PR **Server** - `GET /api/rx-coverage?...&gaps=1` adds a `gaps` member to the FeatureCollection: cells with moving (non-stationary) track samples and no reception in the same `days` window and `bbox`, each with `samples`. `gaps_truncated` is set when more than 5000 cells qualify (the most-sampled are kept, same pattern as `coverageFeatureCap` and `rfNoiseFeatureCap`). - Gaps are only returned when `clientRfSamples` is on and there is no `node` filter: with `?node=` the question would be "this node was not heard", which these cells do not answer. `?rx=` limits both the track and the receptions to that companion. - Without `gaps=1` the payload is unchanged: the `gaps` member is omitted. - New code lives in `cmd/server/rx_coverage_gaps.go` (pure `aggregateGaps` plus `queryTrackPoints`); the handler change in `rx_dashboard.go` is 14 lines. - `/api/rx-coverage` is now documented in `openapi.go` and removed from `openapi_known_gaps.json` (#1670). **Frontend** (`public/rx-coverage.js`, `public/node-reach-coverage.css`) - A "Nothing received" toggle on the signal layer, on by default, only rendered with `MC_CLIENT_RF_SAMPLES`. Hidden on the noise layer. - Gap cells use a 45-degree hatch (an SVG pattern in a dedicated renderer, so they sit under the reception cells). The hatch is denser for cells driven through more often (1 / 2-3 / 4+ samples). Tooltip: sample count. Legend entry: "driven, nothing received". - Off state is deep-linkable as `gaps=0`. - New token `--nq-cov-gap` for light and dark. Plain grey (`--nq-cov-grey`) already means "received, no SNR", so the hatch keeps the two apart. ## Threshold: one sample Measured on a live network (analyzer.on8ar.eu, Belgium, September to October 2026), with cells of about 250 m: a cell with track samples and no reception in one half of a month, driven again in the other half. How often did it receive something the second time? | Minimum samples | Cells "nothing received" | Driven again | Received something later | |---|---|---|---| | 1 | 3971 | 1263 | 29% | | 2 | 1565 | 592 | 32% | | 3 | 619 | 243 | 35% | | 5 | 142 | 63 | 40% | A higher threshold hides cells without making the rest more reliable, so the threshold is 1 and the wording is "nothing received in this period", never "no coverage". Using every decoded packet as reception evidence (including 1-byte path hashes) gave 25% to 32%, so the capture rule for 1-byte hashes is not what drives this. The median cell has 2 samples. ## Verification - Go: 7 new tests in `rx_coverage_gaps_test.go` (gap/no gap, stationary excluded, single sample, cap keeps the most-sampled, endpoint, `?rx=` filter, no `gaps` member when not asked / with `?node=` / with RF samples off). The endpoint tests fail without the handler change. Full `cmd/server` suite green locally, `go vet` and `gofmt` clean. - E2E: `tests/e2e/test-rx-coverage-gaps-e2e.js` (4 steps: no toggle with RF samples off, on by default with hatched cells under the reception cells, toggle off with `gaps=0` and deep link, noise layer hides it). 2 steps fail without the frontend change. Existing `test-rx-coverage-noise-e2e.js` and `test-rx-coverage-viewport-e2e.js` still pass. Listed in `scripts/non-unit-tests.json`. - Frontend unit suite (`test-all.sh`) green. - Real data on a staging instance (7 days, bbox 50.8,4.9,51.3,5.8, z=12): 478 reception cells and 379 gap cells, no cell in both, reception features identical with and without `gaps=1`, 0.48 s to 0.52 s per request both ways. - Browser: checked on staging with real data (light theme), and light, dark and 390 px wide with mocked data. ## Not in this PR - A per-node variant on the Reach page ("this repeater transmitted while a companion was nearby, and was not heard"). That needs the transmit moments from `observations` and is a heavier query. - `deploy.yml` does not get a line for the new E2E file, like the other entries that are only in `non-unit-tests.json` (#2037). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
673442ccb9 |
perf(ingestor): refresh planner statistics 24h after the last refresh, not after every start (#2158)
Refs #2146. ## Situation The daily planner statistics refresh (#2058) runs its first `ANALYZE` 2 minutes after every ingestor start. On a cold page cache that bounded `ANALYZE` (analysis_limit 10000) takes minutes on a 10-11 GB database, and it holds the ingestor's single write connection the whole time: | Where | Logged after a restart | |---|---| | production instance (11.5 GB) | `component=analyze duration=469855.2ms`, plus `[neighbor-build] SLOW tick: 7m49.98s` behind it | | staging instance, 12 restarts logged on 2026-10-09 | 4m11s to 9m11s each; 7 of the 12 took over 7 minutes | `EnsurePlannerStats` already documents what that costs: ingest stalls and buffers for those minutes. The server's reads compete with the scan for the disk during that window. Every restart pays this for statistics that are at most a day old. ## My change in this PR - `RefreshPlannerStats` records when it succeeded, in a file next to the database (`<db>.planner-stats-refreshed`, RFC 3339). - The refresh loop waits until that time is 24 hours old: at least the existing 2-minute stagger, at most 24 hours (`nextPlannerStatsRefresh`). A restart within a day of the last refresh runs no `ANALYZE`. - No recorded time (the first start after upgrading, or a new database) refreshes after 2 minutes as before, and records it. `EnsurePlannerStats` records its build too, so a new database no longer runs a second `ANALYZE` 2 minutes after the first. - A failed refresh keeps the daily rhythm instead of retrying every 2 minutes, the same as the old ticker. ## Verification On staging (`/var/lib/docker/volumes/.../meshcore.db`, 168 h window): ``` first start: [analyze] next planner statistics refresh in 2m0s [analyze] planner statistics refreshed in 7m26.086s (analysis_limit=10000) [analyze] next planner statistics refresh in 24h0m0s meshcore.db.planner-stats-refreshed: 2026-10-09T21:08:34Z docker restart, then: [analyze] next planner statistics refresh in 24h0m0s (no ANALYZE and no db-slow-writer line in the following 6 minutes) ``` Tests: - `TestNextPlannerStatsRefresh`: never refreshed, 1 hour ago, 23h59m ago, two days ago, a recorded time in the future. - `TestRefreshPlannerStatsRecordsWhen`: a refresh records about now, and a reopened store reads it back as a wait of about 24 hours. - `TestRefreshPlannerStatsDisabledRecordsNothing`. - The existing #2058 planner-statistics tests pass unchanged. `cmd/ingestor`: `go test ./...` green. ## Not in this PR - The time lives in a file, not in the database: the schema has no metadata table, and a new table would need a migration for one timestamp. If the file is lost, the only cost is one refresh 2 minutes after the next start, as today. - The cold `ANALYZE` itself is unchanged; it now runs once a day instead of after every restart. Its time of day is wherever the last refresh landed. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a2b5a4931e |
perf(server): battery history via the primary key, cached /api/perf row counts (#2156)
Refs #2146 (items: `/api/nodes/:pubkey/battery`, `/api/perf`). ## Situation Two endpoints ran SQL that reads more than it needs, on every request. Timings are read-only on a production database (18.3M observations, 283k `observer_metrics` rows), first run / second run: | Query | Now | With this PR | |---|---|---| | battery history, `WHERE LOWER(observer_id) = ?` | 174 / 29 ms (plan: `idx_observer_metrics_timestamp`) | 5.9 / 5.5 ms (plan: primary key `(observer_id, timestamp)`) | | `/api/perf`: `SELECT COUNT(*) FROM observations` | 118 / 116 ms on every call | once per 60 s | ## My changes in this PR 1. **Battery history** (`5bdd3c7f`). `LOWER(observer_id) = ?` keeps SQLite off the primary-key index, so it walked the timestamp index over every observer's metrics in the window. The query now matches `observer_id IN (?, ?)` with the pubkey lowercased and uppercased, the two casings observer IDs are stored in. 2. **`/api/perf` row counts** (`e24c24cf`). The four `COUNT(*)` (transmissions, observations, nodes, observers) are cached for 60 s. File, WAL and freelist sizes are still read on every call. ## Tests - `TestNodeBatteryQueryUsesObserverIndex` loads the `sqlite_stat1` values of that database and checks the plan uses `sqlite_autoindex_observer_metrics_1`. With the `LOWER()` form it fails with `SEARCH observer_metrics USING INDEX idx_observer_metrics_timestamp (timestamp>?)`. - `TestGetNodeBatteryHistory_LowercaseStoredID`: a lowercase-stored observer ID is still found. - `TestGetDBSizeStatsTypedCachesRowCounts`: the cached count is served within the TTL and refreshed after it. - `cmd/server`: `go test ./...` green. ## Not in this PR Other per-request queries listed in #2146 measured too small to change on the same database: `COUNT(*) FROM dropped_packets` 0.0 ms, `SELECT DISTINCT iata FROM observers` 0.2 ms, the observers packet-count query 1.3 ms, the 7-day active node count 0.1 ms. An observer ID stored in mixed case would no longer match the battery query; in that database all 41 distinct `observer_metrics.observer_id` values are uppercase. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
be9f382526 |
perf(server): /paths reads canonical paths in batches and skips redundant collision checks (#2157)
Refs #2146 (item: `/api/nodes/:pubkey/paths`). ## Situation Per-step timing in a staging build (3 most-connected nodes, 2 calls each) showed where a `/paths` request spends its time: | Step | Time | |---|---| | build candidates under the read lock | 73-115 ms | | collision check: one `INSTR(LOWER(resolved_path), ?)` query per index hit, 13,367-16,790 per call | 1.3-2.7 s (first call after start: 13.5 s) | | canonical resolved_path per survivor (`bestResolvedPath`, one or two queries each) | 1.7-2.9 s | | aggregation under the read lock | 82-120 ms | So 3.2-4.1 s of a 3.4-5.1 s request was SQL. On a production database (busiest node, 27k transmissions, read-only): the collision check costs 1.85 s one by one and still 1.69 s batched, because SQLite reads and lowercases every stored path either way. Reading the canonical path (the longest observation's stored path) costs 0.18 s one by one and 0.09 s batched by id. ## My changes in this PR - **The collision check runs only where it decides something.** The aggregation keeps a transmission with a canonical path only if that path names the node (`if !containsTarget { continue }`). If no stored path names the node, the canonical one does not either. So for index hits with a canonical path the check changes nothing, and it now runs only for index hits without one, as before. - **Canonical paths are read in batches.** `loadCanonicalResolvedPaths` reads the stored path of each candidate's longest observation, one query per 500 transmissions. When that path is missing, empty or unparseable it calls `bestResolvedPath` for that transmission, so the result is the same as before. - The snapshot of observation IDs and `path_json` strings is taken in the existing candidate read lock (string copies, no parsing). The PR has two commits: a first version that batched both queries and did not get faster on staging, and the change described above, whose message has the measurements. Squash-merging is fine. ## Measurements Staging, after the ingestor's startup `ANALYZE`, same probe for both builds (ms): | | without this PR | with this PR | |---|---|---| | node 1 (degree 55), call 1 / call 2 | 22,237 / 3,364 | 1,793 / 1,686 | | node 2 (degree 47) | 4,427 / 3,615 | 1,828 / 1,809 | | node 3 (degree 46) | 5,171 / 4,035 | 3,050 / 2,408 | | 4 concurrent clients over 40 nodes, 120 s: calls / p50 / p95 | 73 / 6,717 / 15,160 | 125 / 2,861 / 10,630 | Both staging builds also contain #2147 and #2155. ## Tests - `TestLoadCanonicalResolvedPathsMatchesBestResolvedPath` compares the batched result with `bestResolvedPath` on seven cases: the #810 case (longest observation without a stored path), equal lengths, an empty stored path, case, an observation missing from the snapshot, longest wins, nothing stored. - `TestLoadCanonicalResolvedPathsBatchesQueries`: 1,001 transmissions take 3 batched queries and no per-transmission lookups. - `TestHandleNodePaths_CanonicalPathDecidesIndexHits`: the longest observation names A, a shorter one names B; both are index hits. A gets the transmission, B does not, with no per-transmission query. Run against master, the A/B results are the same and only the query-count check fails (1 there, 0 here). - The existing `/paths` tests pass unchanged (#810, #1278, #1352 collision and fallback cases, anchor bias). - `cmd/server`: `go test ./...` and `go test -race` green. ## Not in this PR - `nodeInResolvedPathViaIndex` still calls the per-transmission check; it has no production caller, only tests. - The concurrent p95 is still 10.6 s on staging. What is left there is the 4-connection read pool shared with other endpoints; a staging A/B with 8 connections gave no gain (`/paths` throughput dropped from 133-146 to 81-103 calls per 120 s), so the pool stays at 4. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3e21b179ec |
fix(server): drive the reach scan from the observations time window (#2155)
Refs #2146 (item: `/api/nodes/:pubkey/reach`). ## Situation The reach scan joins `observations` to `transmissions` and filters on `o.timestamp >= ?` plus one `o.path_json LIKE '%"XX"%'` per token. With the planner statistics `ANALYZE` produces on a large database, SQLite runs it as `SCAN t`: every transmission, with an index probe into `observations` (`idx_observations_tx_ts`) per row. The cost is then the size of `transmissions`, whatever node is asked for and whatever the window. ## My change in this PR `scanReachRows` uses `CROSS JOIN` for the transmissions join. In SQLite that fixes the join order: `observations` is the outer loop, read through `idx_observations_timestamp` for the window, and each row looks up its transmission by primary key. Results are the same rows; it is still an inner join. The query text moves into `reachScanSQL(nTokens)`, so a test can inspect the plan. ## Measurements Read-only on a database with 18.3M observations and 1.5M transmissions (Python `sqlite3` 3.45.1 on the host), 7-day window: | node (rows returned) | `JOIN`, first run | `JOIN`, warm | `CROSS JOIN`, both runs | |---|---|---|---| | busiest relay (54,387) | 10.29 s | 3.27 s | 1.95 s | | quartile (3,913) | 3.16 s | 3.15 s | 1.81 s | | median (1,310) | 3.17 s | 3.18 s | 1.82 s | | a repeater reported as timing out in the browser (34,209) | 14.11 s | 3.25 s | 1.93 s | On that instance `/api/nodes/:pubkey/reach` reports p95 188.6 s and max 192.6 s over 34 calls in `/api/perf`; a single uncached call from the host took 3.4 to 3.6 s, so most of that is waiting for the 2 build slots and the read pool. On a staging instance (smaller database), with unique cache keys and `days` 6 or 7, one cycle per build after the ingestor's startup `ANALYZE` had finished: | | without this PR | with this PR | |---|---|---| | sequential, 12 calls, p50 | 3402-3801 ms | 2078-2467 ms | | 2 concurrent clients (= `reachMaxConcurrentBuilds`), p50 / p95 | 3575-4087 / 4049-5291 ms | 2271-2462 / 2814-2928 ms | | calls completed in 120 s | 58-68 | 96-101 | The staging builds also contain #2147. ## Verification - `TestReachScanStartsFromObservationsTimeIndex` creates the two production indexes, loads the `sqlite_stat1` values from the 18.3M-observation database, and checks that the plan starts with `SEARCH o USING INDEX idx_observations_timestamp`. With the old `JOIN` it fails with the production plan (`SCAN t`, then `SEARCH o USING INDEX idx_observations_tx_ts`). - The existing reach tests (`TestScanReachRows_DecodesRows`, `TestScanReachRows_CapTruncates`, attribution tests) pass unchanged. - `cmd/server`: `go test ./...` green. ## Not in this PR - The scan still reads every observation in the window and applies the `LIKE` per row, so its cost grows with the window and with traffic. Removing the scan needs an index on hop tokens (for example a table the ingestor maintains). I have not measured what that would cost in storage. - The build-slot limit (2) and the 5-minute cache are unchanged. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f50312767f |
fix(server): no SQL while holding PacketStore.mu (#2147)
Refs #2146 (first item: "Stop issuing SQL while holding `store.mu`").
## Situation
Several store functions ran SQLite queries while holding
`PacketStore.mu`. The server's read pool has 4 connections
(`db.go:153`), so a store-lock holder could wait for a pool connection.
`mu` is a `sync.RWMutex`: once the ingest poller asks for the write
lock, every new reader queues behind it, including `/api/healthz`. A
test that scans the package (below) finds 16 such call sites on `master`
at
|
||
|
|
9dbc287579 |
docs: release notes for v3.14.0 (#2143)
Release notes and the CHANGELOG entry for v3.14.0. The release workflow reads `docs/release-notes/v3.14.0.md` from the tagged commit as the release body, so this lands before the tag. 23 commits since v3.13.1: 13 fix, 8 feat, 1 ci, and this docs commit. The headline is optional user accounts (#2128). "Read this before upgrading" covers the new limits on unauthenticated endpoints, the `traffic_share_score` drop (#2117), the region filter window (#2114) and the config file mode (#2126). After merge: tag this commit as `v3.14.0` once its `:edge` image is built, then dispatch the CI/CD pipeline on the tag. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>v3.14.0 |
||
|
|
57395e2b28 |
fix(ui): escape observer iata, name and id, and node names, in eight render sinks (#2118)
Anyone who can publish to an MQTT broker that CoreScope reads controls three strings: the observer `iata` (a topic segment), the observer `name` (the JSON `origin` field) and the observer `id` (a topic segment). Anyone with a radio controls the node `name` in an ADVERT. Eight places wrote these into the page with `innerHTML` without escaping, so a crafted value runs as script in every visitor's browser: | file | what | |---|---| | `packets.js` | Region badge in the group, child and single packet rows | | `observers.js` | Region badge in the observers table and in the slide-over | | `live.js` | "Heard By — Regions" header in the node detail | | `nodes.js` | observer name in the clock-skew evidence panel | | `analytics.js` | `observer_id` inside `data-observer=` and `id=` attributes | | `app.js` | node name in the favorites dropdown | | `customize-v2.js` | node name in the geofilter prune preview (admin page) | **Fix:** each value now goes through the existing `escapeHtml()` / `esc()` helper. No behaviour change for normal values. **Tests:** one test per sink added to `tests/unit/test-xss-escape-sinks.js`, following the file's existing pattern (pull the template out of the source, render it with a hostile value, check the output is escaped). Reverting any single fix makes its test fail. 41/41 pass. **Notes for reviewers:** - The ingestor uppercases `iata` before storing it. That is not a defence: tag and attribute names are case-insensitive, and script can be written with numeric character references. - An operator who has set an `observerIATAWhitelist` is protected from the `iata` sinks but not from the name/id sinks. - Follow-ups worth a separate PR: validate `iata` and observer `id` to a safe character set at ingest, cap the `origin` length, and add a `Content-Security-Policy` header. This PR is escaping only so it is easy to review and safe to ship. Running in production on our instance since 2026-10-07. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com> |
||
|
|
29095658e9 |
fix(nodes): explain packet and observation counts (#2132)
Red commit: `d8a6d42` ([CI assertion](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37695889461)). Review-regression red: `f73643b` ([CI assertion](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37703995494)). Fixes #2131. Partial fix for #1154: U2 only; other audit items remain open. Both node views explain distinct transmissions versus recorded observations. The shared help is reachable by keyboard and touch, stays readable at either scroll edge, and remains open when the pointer enters its text. Escape dismisses help while preserving node context and focus; a second Escape keeps the existing navigation behavior. Count expressions and API requests are preserved. One controller per nodes page handles one active help element and one queued animation frame. Scroll/resize work is coalesced; listeners are removed on page teardown. Work is constant relative to packet/node counts, with no dataset scans or new settings/dependencies. E2E assertion added: `tests/e2e/test-e2e-playwright.js:114`. Thirteen cases cover exact counts, accessible description, keyboard/touch, hover transfer, Escape, and viewport/scroll bounds. Existing metric assertions are unchanged. Browser verified: http://localhost:63508 using Chromium, controlled fixtures and actual API data. Desktop/mobile screenshots retained locally; additional checks exercised resizing and repeated page cleanup. Local validation: 189 standalone suites; core browser 148 passed, 3 fixture-dependent skips (existing manual skips unchanged); location/metric suite 6 passed; syntax, theme variables and lint passed (0 errors, 92 existing warnings). ## Preflight overrides - OpenClaw preflight/profile unavailable; repository checks and local Chromium used. - Windows ingestor symlink fixture lacks permission. Backend files are unchanged; full Linux Go CI is required. |
||
|
|
2456762179 |
feat: account data export and users.db backup (#2141)
A small follow-up to parts A to E of #2128: users can download all data the instance holds about them, and the server backs up `users.db`. Stacked on #2140. Review the commits after that branch. ## The situation - An instance with accounts holds personal data, but a user has no way to get a copy (GDPR articles 15 and 20). Self-delete already exists. - `users.db` is the only file the server writes, and nothing backs it up: `GET /api/backup` snapshots the analyzer database only. ## What this PR adds **Data export.** `GET /api/account/export` and a "Download my data" button on the account page give one JSON file with: - the profile, including activation and a pending email change; - sessions (times and user agent); - synced settings, own proposals, notification prefs and watches; - audit rows where the user is actor or target, with other accounts as an id only; - the mail log. Password hash, session and link tokens and the unsubscribe token are left out: they are credentials. Each export is audited as `user.export`. **users.db backup** - Daily `VACUUM INTO` snapshots through `internal/users`, on by default when user management is on. They go to `backups/` next to `users.db`, the 7 newest are kept, the directory is 0700 and the files 0600. Rotation only touches `users-<timestamp>.db` files and never the snapshot it just wrote. Config: `userManagement.backup {enabled, dir, keep}`. - `GET /api/admin/users-backup`: an admin downloads a fresh snapshot to keep off the server; audited as `user.backup`. - `docs/user-guide/accounts.md` gains a restore procedure. It covers what a restore brings back (accounts deleted after the snapshot, old passwords, revoked sessions) and how to clean that up afterwards. Spec: [`docs/specs/2026-10-08-account-export-and-users-backup-design.md`](https://github.com/efiten/CoreScope/blob/feat/account-export-backup/docs/specs/2026-10-08-account-export-and-users-backup-design.md). ## Verification - `internal/users` with `-race` and the `cmd/server` suite pass locally, 30 new Go tests. The file-mode tests skip on Windows. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 27 of 27 steps locally. - On our production instance since 8 October 2026; the first snapshot there was 143 kB. ## Not in this PR - Importing an export into another instance. - Off-site upload of the snapshots. The admin download covers that by hand. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
de364a8bb6 |
feat: mail notifications for watched nodes (user management part E) (#2140)
Part E of #2128: a logged-in user watches nodes and gets one mail when a watched node goes offline, comes back, or reports a low battery. Admins can add instance events: a new foreign node, and an observer going offline or back. This covers the per-user mail part of #775. Stacked on #2139 (part D, which builds on #2138). Review the commits after that branch. ## The situation A sysop learns that a repeater went silent, or that its battery is running down, only by opening CoreScope. #775 asks for notifications. Accounts (part A) now give a verified address to mail and a mailer with delivery status. ## What this PR adds **Events.** They use the instance thresholds, so a mail does not disagree with the node page: - `node.offline`: silent for the role's `healthThresholds` window. Last heard comes from the packet store, and repeaters and rooms count relayed traffic (#1598). - `node.battery`: advert telemetry below `batteryThresholds.lowMv`, recovered at `lowMv + 100`. - Admins only: `foreign.new` (once per node) and `observer.offline`. **Store.** Schema v5 with `notification_prefs`, `notification_watches` and `notification_state`. **Server** (only with `userManagement.notifications.enabled`) - A notifier next to the janitor, every `intervalMinutes` (5). The evaluator is a pure function of watches, prefs, stored states and node snapshots. - No burst after a restart or a new watch: a subject's first evaluation stores its state without mailing. The loop waits for the startup load. While ingest is stale (newest packet older than 30 minutes) the offline checks pause, and after recovery they wait one silent window. - One mail per user per check. Limits: 20 per user and 100 per instance per rolling 24 hours, 50 watches per user. A change over a limit is recorded and never mailed later. - Every mail has a one-click unsubscribe (`List-Unsubscribe` and `List-Unsubscribe-Post`). The GET only redirects to a confirm page, so link scanners cannot unsubscribe anyone. Node names are cleaned of control and bidi characters before they go into a mail. - Routes: `GET`/`PUT /api/account/notifications`, `PUT`/`DELETE /api/account/notifications/watches/{pubkey}`, `POST /api/account/notifications/watch-my-nodes` (copies the synced "my nodes" list), `GET`/`POST /api/notifications/unsubscribe`. `mailer.Message` gains `Headers`. **Frontend.** A "Notify me" toggle on the node pages, a Notifications section on the account page (`#/account?section=notifications`), an unsubscribe page, and the notification mail count on the admin overview. Spec: [`docs/specs/2026-10-07-node-notifications-design.md`](https://github.com/efiten/CoreScope/blob/feat/notifications/docs/specs/2026-10-07-node-notifications-design.md). ## Performance Each check reads watches, prefs and states from `users.db` (one query each), the watched nodes from the analyzer DB in chunks of 500, and last-heard times from the packet store. | Measurement | Result | |---|---| | One check on our staging instance, 130,000 packets in memory, 1 watcher | 6.9 ms, of which 0.93 ms holding the store read lock | | Evaluator benchmark, 100 users with 50 watches each, 2,000 nodes | 1.75 ms | No work is added to ingest, broadcast or any request path. ## Verification - `internal/users` and `internal/mailer` with `-race`; the `cmd/server` suite plus `-race` on the notifier tests; 74 new Go tests. The same Windows-only failure as noted in #2138 applies. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 26 of 26 steps locally. With notifications off the new steps fail, so they do test the flag. - On our staging and production instance since 7 October 2026. ## Not in this PR - Other channels (Discord, Telegram, webhooks). Detection is separate from delivery, so one can be added. - The topology, RF and anomaly alerts of #775. - If the whole server starts after a feed outage that already ended, the grace window is unknown and a watcher can get one wrong offline mail. The user guide says so. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
abbe6846ff |
feat: propose and approve hashtag channels (user management part D) (#2139)
Part D of #2128: logged-in users propose a hashtag channel, an admin approves, rejects or later revokes it, and the ingestor decrypts approved channels without a restart. It is the first consumer of a generic proposal table, and answers the request in #2092. Stacked on #2138 (part C). Review the commits after that branch; GitHub shows C's commits here until it is merged. ## The situation - An instance decrypts only the hashtag channels listed in `hashChannels`. Users who want a city or club channel shown have to ask the operator, who edits `config.json` and restarts the ingestor. - #2092 asked for a suggest-and-approve flow; a downstream fork (dborup/CoreScope#99) runs one with anonymous suggestions and a file queue. ## What this PR adds **Name rules (`internal/channel`).** `ValidateHashtagName`, mirrored in the browser: - at most 31 UTF-8 bytes including `#` (firmware `ChannelDetails.name[32]`), case preserved, `Public` refused in any case; - control, bidi, separator and format characters refused except ZWJ, plus invisible fillers (`Other_Default_Ignorable_Code_Point`, U+2800, non-ASCII spaces). Go and JS give the same verdict for every code point. **Store.** Schema v4, one generic `proposals` table (kind, subject, status pending/approved/rejected/revoked, proposer, reviewer, note). One row per (kind, subject); limits are checked in the same transaction as the change. **Server** (only with `userManagement.channelProposals.enabled`) - `POST /api/proposals`, `GET /api/account/proposals`, `GET /api/admin/proposals`, `POST /api/admin/proposals/{id}/approve|reject|revoke`. - 400 for an invalid name. 409 for a duplicate, a rejected name, or a name already in `hashChannels`/`channelKeys` ("this channel is already decrypted on this instance"). 429 over a limit (`maxPending` 100, `maxApproved` 128, `perUserPerDay` 5). - `GET /api/channels` gains `approvedChannels`, so approved channels are listed before they have traffic. With the feature off the response is byte-identical. **Ingestor.** Opens `users.db` with `mode=ro` every 60 seconds, re-validates the names, and swaps its key map through an `atomic.Pointer`. Configured channels always win, and a failed read keeps the last good set. The ingestor never writes `users.db`; AGENTS.md now says so. **Frontend.** "Propose for everyone" in the add-channel dialog, an "approved" marker in the channel list, "My proposals" on the account page, and an admin "Proposals" tab with a warning on approve. The packet detail pane now escapes the channel name: until now only the operator chose those names. Spec: [`docs/specs/2026-10-07-channel-proposals-design.md`](https://github.com/efiten/CoreScope/blob/feat/channel-proposals/docs/specs/2026-10-07-channel-proposals-design.md). ## Performance Every key is tried on each GRP_TXT that no key opens (`decodeGrpTxt`). Benchmark of that worst case: | Keys | Time per packet | |---|---| | 320 configured | 204 to 216 µs | | 320 configured + 128 approved | 272 to 320 µs | That is about +33%, linear in the key count and capped by `maxApproved`. With the feature off the decoder gets the identical map and no ticker runs. ## Verification - `internal/channel`, `internal/users` (with `-race`), `cmd/server` and `cmd/ingestor` pass locally, 53 new Go tests. The ingestor's `TestWriteStatsAtomic_SymlinkAtDestIsReplaced` needs symlink rights on Windows and fails on `master` too. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 21 of 21 steps locally (propose, approve, the channel appears, revoke removes it). - On our staging and production instance since 7 October 2026. ## Not in this PR - Private (PSK) channels: their keys stay in the browser (#725). - Anonymous proposals. - Names that need ZWNJ (some Persian and Urdu spellings) and subdivision flags are refused, because ZWNJ and tag characters are format characters. Allowing them later only widens the rule, so no approved name gets stranded. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
18a634c115 |
feat: admin dashboard with overview and audit log (user management part C) (#2138)
Part C of #2128: an admin area with an overview, the user table and a global audit log. Off by default like the rest of user management: with `userManagement` off nothing changes, and non-admins get no new UI. This is the first of 4 stacked PRs (C, D, E, then a small export/backup follow-up). Each later one contains this branch; review them in order. ## The situation - An admin could manage users one by one in `#/admin/users`, but nothing showed whether the instance needs attention: accounts stuck in activation, bouncing mail, someone guessing a password, an MQTT source that dropped. - The audit log existed (`internal/users/audit.go`) but could only be read per user, and logins were not recorded. ## What this PR adds **Store (`internal/users`)** - Schema v3: an index on `audit_log(at)`. - `AuditList` with filters (action or group prefix like `user.login.*`, user as actor or target, period) and keyset pagination; `PruneAudit`; `Stats` for the user figures. **Server** - `GET /api/admin/stats` (typed struct): accounts by status, admins, registrations and active users over 7 and 30 days, logins and failed logins in 24 hours, mail by final status, and the attention items computed server-side. - `GET /api/admin/audit`: filtered, newest first, `next` cursor. - `GET /api/admin/users` gains `bouncing=1`. - Logins are audited as `user.login` and `user.login.failed` (reason `wrong_password`, `pending` or `disabled`). An unknown address writes no row. The writes are asynchronous, so the login response does not wait on `users.db`. Login rows are pruned after 90 days. **Frontend** - `#/admin?tab=overview|users|audit`: `admin.js` (tab shell), `admin-overview.js` ("Needs attention", Users card, System card from the existing health, MQTT and observer endpoints), `admin-audit.js` (filters in the URL, "Load more"). `admin-users.js` becomes the Users tab; the old `#/admin/users` link rewrites to it. - `/api/healthz` is read on open and on Refresh only, not on the 60-second timer, because it walks every packet under a read lock. Spec: [`docs/specs/2026-10-07-admin-dashboard-design.md`](https://github.com/efiten/CoreScope/blob/feat/admin-dashboard/docs/specs/2026-10-07-admin-dashboard-design.md). ## Verification - `internal/users` and `cmd/server`: `go vet` and `go test` pass locally, 26 new Go tests. One upstream test, `TestSaveGeoFilterPreservesFileMode`, also fails on Windows on plain `master` (file modes) and is unrelated. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E with the `e2etest` build: 13 of 13 steps locally. - Running on our staging and production instance since 7 October 2026. ## Not in this PR - Restricting existing pages (perf, MQTT status) to logged-in users or admins. The admin area adds no access mechanism of its own, so that stays a later router rule plus endpoint check. - Server-enforced customizer tab restrictions (#1508). - The 24-hour, 5-attempt and 10-minute attention thresholds are constants for now (AGENTS.md rule 8: customizer later). --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a4445b4985 |
ci: run E2E, Go tests and the image build side by side (#2135)
Closes #2134. ## What changes The test jobs and the image build now run side by side. Only the GHCR push waits for all of them. ``` changes ─┬─ go-test ─────────────────────────┐ ├─ race-test (ingestor changes only) │ ├─ e2e-shard ×3 ── e2e-test (gate) ───┼─ build-and-publish → deploy, publish └─ image-check (two-arch build) ──────┘ ``` - **`e2e-shard` (new, matrix of 3).** It needs only `changes`. The suite list stays in the workflow: each line now reads `suite <shard> <file> [VAR=value ...]`, with the same environment variables as before. - Shards are balanced on the measured suite times, about 6 minutes each. - A shard number outside 1..3 fails the step, so a typo cannot drop a suite silently. - `test-issue-1648-m4-icons-e2e.js` stays in the same shard as, and after, the a11y suite. Its distance-tab check only counts once the lazy distance index (#1011) has been built, and the a11y suite is what first requests it. - **`e2e-test`** is now a gate under the old check name. It runs only if all three shards passed, checks that three results arrived, and builds the same `e2e-badges` artifact as before. - **`image-check` (new).** It runs the two-arch build (no push) and the arm64 QEMU smoke on every event, beside the tests. It also computes the build metadata once, which the push reuses. - **`build-and-publish`** now needs `go-test`, `e2e-test` and `image-check`. On a PR every step is skipped. On push and tag refs it only pushes, with the same build args as `image-check`, so every layer should be a cache hit. - **The unused `docker compose build` of the staging image is gone.** `docker compose config --quiet` still validates the file. - `AXE_SCREENSHOT_DIR` now actually reaches the a11y suite. - `tests/unit/test-issue-1956-release-routing.js` follows the new jobs, and checks more than before: - a failure in `go-test`, `e2e-shard`, `e2e-test` or `image-check` skips `build-and-publish`; - the only non-publishing build lives in `image-check` and runs on every event; - the push takes its build args from `image-check`. ## Measurements The old numbers are from upstream; the new ones are from PR runs on my fork (a branch on top of this commit, plus one temporary commit that also triggers the workflow for PRs into a test base branch). | | Before (PR run 37766553643) | After (fork runs 37812660655 / 37814554422) | |---|---|---| | Go Build & Test | 4.9 min | 7.2 / 7.5 min, in parallel | | E2E | 18.4 min | 3 shards of 7.1–9.0 min, in parallel | | Image build | 6.9 min, after E2E | 5.9 / 5.4 min, in parallel | | **Total** | **30.5 min** | **9.6 / 10.6 min** | **Same tests:** I compared the second fork run with the E2E log of the upstream run, suite by suite. All 118 suites ran, and each one passed the same number of checks (1161 in total). **Cost:** more runner minutes, because each shard repeats about 2.5 minutes of setup. Standard runners are free for a public repository. ## Not verified here - **A master push.** On a fork the GHCR push cannot run. The expectation is that `Build and push to GHCR` gets cache hits from `image-check` and finishes in a minute or two instead of 4–4.5. The first master run after merge will show whether it does. - **Branch protection.** If it requires other job names than `🎭 Playwright E2E Tests` and `🏗️ Build & Publish Docker Image`, those may need adding. I cannot read the protection settings, so I could not check. ## Tests run locally - `sh test-all.sh` passes (on Windows with `PYTHONUTF8=1`). - `test-issue-1956-release-routing.js` passes, and fails as expected when `go-test` is removed from the `needs` of `build-and-publish`. - actionlint 1.7.7 reports only the existing self-hosted runner label. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |