mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 16:32:22 +00:00
## 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>
225 lines
8.8 KiB
Go
225 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
// The Scope Audit had no region selector, unlike every other page. With
|
|
// ?region=<IATA,...> both views count only forwarding heard by observers in
|
|
// those regions: "as heard by", the same meaning region= has on /api/nodes
|
|
// and /api/packets, not "located in".
|
|
|
|
// setupScopeRegionServer is setupScopeAuditServer with observers and the v3
|
|
// observations.observer_idx column, so a hop can be attributed to the
|
|
// observer that heard it.
|
|
func setupScopeRegionServer(t *testing.T) (*Server, *mux.Router) {
|
|
t.Helper()
|
|
srv, router := setupScopeAuditServer(t)
|
|
for _, ddl := range []string{
|
|
`CREATE TABLE observers (id TEXT, name TEXT, iata TEXT)`,
|
|
`ALTER TABLE observations ADD COLUMN observer_idx INTEGER`,
|
|
`INSERT INTO observers (rowid, id, name, iata) VALUES (1, 'obs-sfo', 'San Francisco', 'SFO'), (2, 'obs-bru', 'Brussels', 'BRU')`,
|
|
} {
|
|
if _, err := srv.db.conn.Exec(ddl); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := srv.db.detectSchema(context.Background(), srv.db.conn); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !srv.db.isV3 {
|
|
t.Fatal("fixture must be v3 (observer_idx) or the test exercises nothing")
|
|
}
|
|
return srv, router
|
|
}
|
|
|
|
var heardCounter int
|
|
|
|
// seedHeard inserts one flood transmission forwarded by hop, heard by the
|
|
// observer with the given rowid.
|
|
func seedHeard(t *testing.T, srv *Server, hop string, seed scopeSeed, observerRowid int) {
|
|
t.Helper()
|
|
heardCounter++
|
|
res, err := srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, code1, code2, scope_name)
|
|
VALUES ('AA', ?, ?, ?, 1, ?, '00', ?)`, fmt.Sprintf("heard%d", heardCounter),
|
|
time.Now().UTC().Add(-time.Minute).Format(time.RFC3339), RouteFlood, seed.code1, seed.scopeName)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
path := fmt.Sprintf(`["%s"]`, strings.ToUpper(hop))
|
|
if observerRowid == 0 { // v2 fixture: the caller attributes observer_id itself
|
|
_, err = srv.db.conn.Exec(`INSERT INTO observations (transmission_id, path_json, timestamp) VALUES (?, ?, ?)`, id, path, time.Now().Unix())
|
|
} else {
|
|
_, err = srv.db.conn.Exec(`INSERT INTO observations (transmission_id, path_json, timestamp, observer_idx) VALUES (?, ?, ?, ?)`, id, path, time.Now().Unix(), observerRowid)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// A declared "be", heard carrying #be by San Francisco and #de by Brussels.
|
|
func seedRegionalForwarding(t *testing.T, srv *Server) {
|
|
t.Helper()
|
|
insertDeclared(t, srv, testFullPubkeyA, time.Now().UTC().Format(time.RFC3339), "be", 0)
|
|
addRepeater(t, srv, testFullPubkeyA, "Alpha")
|
|
addRepeater(t, srv, testFullPubkeyB, "Bravo")
|
|
seedHeard(t, srv, testFullPubkeyA[:4], scopeMatched("#be"), 1)
|
|
seedHeard(t, srv, testFullPubkeyA[:4], scopeMatched("#de"), 2)
|
|
seedHeard(t, srv, testFullPubkeyB[:4], scopeMatched("#fr"), 2)
|
|
}
|
|
|
|
func TestScopeAudit_RegionCountsOnlyHopsHeardThere(t *testing.T) {
|
|
srv, router := setupScopeRegionServer(t)
|
|
seedRegionalForwarding(t, srv)
|
|
|
|
all := findScopeAuditRow(t, getScopeAudit(t, router, "").Repeaters, testFullPubkeyA)
|
|
if len(all.UndeclaredObserved) != 1 || all.UndeclaredObserved[0].Scope != "de" {
|
|
t.Fatalf("without a region, #de heard in Brussels should be undeclared-observed: %+v", all.UndeclaredObserved)
|
|
}
|
|
sfo := getScopeAudit(t, router, "?region=SFO")
|
|
row := findScopeAuditRow(t, sfo.Repeaters, testFullPubkeyA)
|
|
if len(row.UndeclaredObserved) != 0 {
|
|
t.Errorf("region=SFO must not count #de heard only in Brussels: %+v", row.UndeclaredObserved)
|
|
}
|
|
if len(row.NotObserved) != 0 {
|
|
t.Errorf("region=SFO: #be was heard in San Francisco, notObserved = %v", row.NotObserved)
|
|
}
|
|
if sfo.Region != "SFO" {
|
|
t.Errorf("region = %q, want the normalised codes echoed", sfo.Region)
|
|
}
|
|
}
|
|
|
|
func TestScopeTransport_RegionListsOnlyRepeatersHeardThere(t *testing.T) {
|
|
srv, router := setupScopeRegionServer(t)
|
|
seedRegionalForwarding(t, srv)
|
|
got := getScopeTransport(t, router, "®ion=bru")
|
|
if got.Region != "BRU" {
|
|
t.Errorf("region = %q, want BRU", got.Region)
|
|
}
|
|
a := transportRow(t, got.Repeaters, testFullPubkeyA)
|
|
if len(a.Transported) != 1 || a.Transported[0].Scope != "de" {
|
|
t.Errorf("A as heard in Brussels: transported = %+v, want only de", a.Transported)
|
|
}
|
|
transportRow(t, got.Repeaters, testFullPubkeyB)
|
|
sfo := getScopeTransport(t, router, "®ion=SFO")
|
|
for _, r := range sfo.Repeaters {
|
|
if r.PublicKey == testFullPubkeyB {
|
|
t.Fatal("B was only heard in Brussels and must not be listed for region=SFO")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScopeAudit_RegionAllMeansNoFilter(t *testing.T) {
|
|
srv, router := setupScopeRegionServer(t)
|
|
seedRegionalForwarding(t, srv)
|
|
got := getScopeAudit(t, router, "?region=All")
|
|
row := findScopeAuditRow(t, got.Repeaters, testFullPubkeyA)
|
|
if len(row.UndeclaredObserved) != 1 || got.Region != "" {
|
|
t.Fatalf("region=All must behave like no region: undeclared=%+v region=%q", row.UndeclaredObserved, got.Region)
|
|
}
|
|
}
|
|
|
|
func TestScopeAudit_RegionsAreCachedSeparately(t *testing.T) {
|
|
srv, router := setupScopeRegionServer(t)
|
|
seedRegionalForwarding(t, srv)
|
|
getScopeAudit(t, router, "?region=SFO") // fills the SFO entry first
|
|
row := findScopeAuditRow(t, getScopeAudit(t, router, "").Repeaters, testFullPubkeyA)
|
|
if len(row.UndeclaredObserved) != 1 {
|
|
t.Fatalf("the unfiltered view must not be served from the SFO cache entry: %+v", row.UndeclaredObserved)
|
|
}
|
|
tr := getScopeTransport(t, router, "®ion=SFO")
|
|
if len(transportRow(t, tr.Repeaters, testFullPubkeyA).Transported) != 1 {
|
|
t.Fatal("transport view for SFO should count only #be")
|
|
}
|
|
}
|
|
|
|
func TestScopeAudit_RegionWithNoObserversMatchesNothing(t *testing.T) {
|
|
srv, router := setupScopeRegionServer(t)
|
|
seedRegionalForwarding(t, srv)
|
|
got := getScopeTransport(t, router, "®ion=ZZZ")
|
|
if len(got.Repeaters) != 0 {
|
|
t.Fatalf("a region with no observers must match no forwarding, got %d rows", len(got.Repeaters))
|
|
}
|
|
}
|
|
|
|
// On the v2 schema an observation names its observer by observers.id in
|
|
// observer_id rather than by rowid; the filter must follow it.
|
|
func TestScopeTransport_RegionOnV2Schema(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
for _, ddl := range []string{
|
|
`CREATE TABLE observers (id TEXT, name TEXT, iata TEXT)`,
|
|
`ALTER TABLE observations ADD COLUMN observer_id TEXT`,
|
|
`INSERT INTO observers (id, name, iata) VALUES ('obs-sfo', 'San Francisco', 'SFO'), ('obs-bru', 'Brussels', 'BRU')`,
|
|
} {
|
|
if _, err := srv.db.conn.Exec(ddl); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := srv.db.detectSchema(context.Background(), srv.db.conn); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if srv.db.isV3 {
|
|
t.Fatal("fixture must be v2")
|
|
}
|
|
addRepeater(t, srv, testFullPubkeyA, "Alpha")
|
|
addRepeater(t, srv, testFullPubkeyB, "Bravo")
|
|
seedHeard(t, srv, testFullPubkeyA[:4], scopeMatched("#be"), 0)
|
|
seedHeard(t, srv, testFullPubkeyB[:4], scopeMatched("#fr"), 0)
|
|
if _, err := srv.db.conn.Exec(`UPDATE observations SET observer_id = CASE WHEN path_json LIKE ? THEN 'obs-sfo' ELSE 'obs-bru' END`,
|
|
`%`+strings.ToUpper(testFullPubkeyA[:4])+`%`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := getScopeTransport(t, router, "®ion=SFO")
|
|
if len(got.Repeaters) != 1 || got.Repeaters[0].PublicKey != testFullPubkeyA {
|
|
t.Fatalf("v2 region=SFO should list only A, got %+v", got.Repeaters)
|
|
}
|
|
}
|
|
|
|
// With an index on observations(observer_idx), as every real database has,
|
|
// SQLite drives "o.observer_idx IN (...)" from that index: every observation
|
|
// the region's observers ever made, then the window check per row. On a
|
|
// live-shaped database that was 13.4s for one region over 24h against 0.25s when
|
|
// the scan starts from the first_seen window as the unfiltered scan does.
|
|
func TestScopeAudit_RegionScanKeepsTheTimeIndex(t *testing.T) {
|
|
srv, _ := setupScopeRegionServer(t)
|
|
for _, ddl := range []string{
|
|
`CREATE INDEX IF NOT EXISTS idx_transmissions_first_seen ON transmissions(first_seen)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_observations_observer_idx ON observations(observer_idx)`,
|
|
} {
|
|
if _, err := srv.db.conn.Exec(ddl); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
clause, args, err := srv.db.observerRegionClause(srv.db.conn, []string{"SFO"})
|
|
if err != nil || clause == "" {
|
|
t.Fatalf("clause=%q err=%v", clause, err)
|
|
}
|
|
rows, err := srv.db.conn.Query("EXPLAIN QUERY PLAN "+scopeAuditForwarderScanQuery+clause,
|
|
append([]interface{}{"2000-01-01T00:00:00Z"}, args...)...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
var plan []string
|
|
for rows.Next() {
|
|
var id, parent, notused int
|
|
var detail string
|
|
if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plan = append(plan, detail)
|
|
}
|
|
joined := strings.Join(plan, " | ")
|
|
if strings.Contains(joined, "idx_observations_observer_idx") || !strings.Contains(joined, "idx_transmissions_first_seen") {
|
|
t.Fatalf("the region scan must start from the first_seen window, not the observer index: %s", joined)
|
|
}
|
|
}
|