Repository navigation
fix(analytics): derive role breakdown from filtered node rows - #580
Conversation
Analytics -> Nodes' Role Breakdown read /api/nodes' global `counts` (cmd/server/db.go GetAllRoleCounts, an unconditional COUNT(*) over the whole nodes table), while the Network Status total above it already came from the loaded, region/area-filtered rows. Picking a region changed one number and not the other. Compute the breakdown with a single bounded pass over the rows the page already has (same role set and pluralized keys as GetAllRoleCounts, so an unfiltered load is unchanged), add a generation guard so a late response from a superseded filter change can't overwrite a newer render, and add unit + E2E coverage. Relates to #560 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rapport — CS-Macmini PR#580 #560 — head 56156aeStatus: All CI jobs green on the first run (no reruns needed; the known #271 flake was not hit in any of the 3 Playwright E2E shards). Requirement coverage
All four red-before/green-after transitions were verified locally by temporarily reverting the CI per job
Run: https://github.com/dborup/CoreScope/actions/runs/40159484258 [A] Local verification (beyond CI)
Residuals / follow-ups (not filed)
|
Review — CS-Minimax PR#580 — head 56156aeDom: REQUEST CHANGES The code change is correct and minimal. The request is about the issue's acceptance criteria: several of them have no test, and the loader-completeness requirement is not addressed. Evidence tags: [T] = I ran it, [A] = read from CI or a script output, [K] = checked by reading the code or diff. Findings
The review points
Tests and mutants
Other checks
Not verified
Suggested path to approveAdd an area-only and a combined-filter case (a unit test that records the request query is enough for the mutant, plus an E2E scenario if an area exists in the fixture). Either surface or argue away the |
Rapport — CS-Macmini PR#580 #560 — head 56156aeStatus: CI complete and fully green — all 18 jobs passed on attempt 1 of run 40159484258, no reruns, and the known #271 E2E flake did not fire in any of the three Playwright shards. Draft PR, not merged or marked ready. This post records the CI completion (the three jobs still pending at the time of the earlier report — Go Server Tests 3/3 and Playwright E2E 2/3 and 3/3 — have since finished green) and re-establishes the requirement evidence from runs I executed myself on the merged tree. Two acceptance criteria are not met; they are listed as gaps below rather than as passes. Evidence tags: [T] run or measured by me here, [A] read from CI or a script's own output, [K] established by reading the code/diff. Base: one commit on top of Requirement coverage
Area-only behaviour, verified by handBecause requirement 3 has no test, I verified the behaviour directly rather than leaving it unknown. Two servers on the same migrated fixture with an
33 and the 28/4/1 split match a direct CI per job (head
|
| Job | Result |
|---|---|
| Go Build & Test | pass (31s) |
| Go Server Tests (1/3) | pass (8m34s) |
| Go Server Tests (2/3) | pass (5m32s) |
| Go Server Tests (3/3) | pass (14m45s) |
| Go Ingestor Tests | pass (4m07s) |
| Go Libraries & CLI | pass (1m35s) |
| JavaScript Unit Tests & Lint | pass (2m38s) |
| Script & Container Unit Checks | pass (1m14s) |
| Verify Go Test Assignments | pass (1m04s) |
| Playwright E2E Tests (1/3) | pass (10m22s) |
| Playwright E2E Tests (2/3) | pass (13m37s) |
| Playwright E2E Tests (3/3) | pass (12m31s) |
| Verify E2E Shards & Coverage | pass (19s) |
| Validate Docker Image | pass (48s) |
| Build & Publish Docker Image | pass (8s) |
| Deploy Staging / Release Artifacts / Publish Badges & Summary | skipping (expected on a PR run) |
Single attempt, no rerun was needed or performed; #271 was not observed. [A]
Local verification
sh test-all.sh: 269/269 files pass (268 on master + the new unit file). [T]node test-frontend-helpers.js: 719/719 pass. [T]node test-issue-560-role-breakdown.js: 8/8 on the merged tree, 0/8 on unchanged master. [T]test-issue-560-role-breakdown-e2e.jsagainst a local Go server ontest-fixtures/e2e-fixture.dbprepared as CI does (freshen → CI seed SQL →corescope-migrate): 4/4 on the merged tree, 3/4 on a master build. [T]npx eslint public/*.jswith eslint@8 exactly as CI invokes it: 0 errors (93 pre-existingno-unused-varswarnings in other files). The two newwindow._analytics*assignments need no.eslintrc.jsonentry because they are property writes, not bare identifier reads, sono-undefdoes not apply. [T]scripts/check-xss-sinks.sh --diff origin/master: exit 0, no output. [T]- Fork guards unchanged: 9 in
deploy.yml, 1 inrelease-fast-path.yml; neither workflow file is in the diff. [T] - No new
map[string]interface{}(0 in the diff). No new hardcoded colour: the only#-match on an added line is the literal#560in a comment; the#6b7280and#ff6b6bliterals are pre-existing context lines. [T] cmd/serveris untouched, so it stays read-only by construction. I rango test ./...incmd/serveron the merged tree anyway as a regression check: pass (67.8s). [T]/[K]- No closing keyword: both the PR body and the commit message say "Relates to fix(analytics): derive role breakdown from filtered node rows #560". [T]
My own mutants
All six applied one at a time to public/analytics.js on the merged tree, each reverted before the next:
| Mutant | Unit suite | Caught |
|---|---|---|
MY1 — role breakdown reverts to nodesResp.counts || {} |
5 passed, 3 failed | yes |
MY2 — unknown role tallied as companions |
7 passed, 1 failed | yes |
| MY3 — empty row set falls back to global counts | 7 passed, 1 failed | yes |
| MY4 — remove the success-path generation guard | 7 passed, 1 failed | yes |
| MY5 — remove the catch-path generation guard | 8 passed, 0 failed | no |
MY6 — drop AreaFilter.areaQueryString() from the request |
8 passed, 0 failed | no (E2E would catch the region half only) |
Residuals
- The breakdown tallies only the four roles
GetAllRoleCountsalready tracks (repeater/room/companion/sensor→repeaters/rooms/companions/sensors). A row withrole = "observer", a missing role, or any unrecognized value is tallied into no bucket, so the breakdown sum can be lower than Total Nodes. That omission is pre-existing; this PR changes only its scope, from the whole table to the filtered rows. Unchanged and out of scope. [K] - Loader completeness is not addressed (requirement 13).
fetchAllNodesdefaults tosafetyCap = 10000and hard-slices the deduped result to it (public/app.js:416,442);analytics.jspasses no cap and surfaces no indication. BecausefetchAllNodesreturnstotal: nodes.length— the capped count, not the server'stotal— Total Nodes and the breakdown stay mutually consistent above 10 000 nodes, so the specific inconsistency fix(analytics): derive role breakdown from filtered node rows #560 was filed about is still fixed. But a truncated population is presented as complete, which is exactly what the issue asked to either confirm against or expose. ThesafetyCapbound is also absent from the PR's performance explanation (requirement 14). [K]/[T] - Requirements 3 and 4 (area-only, combined region+area) have no test. A unit case that records the outgoing request query would kill MY6; an E2E area scenario needs an
areasentry in the fixture config, which the committed fixture does not have. I verified the behaviour by hand instead (table above), so this is a coverage gap, not a known defect. [T] - The catch-path generation guard is untested (MY5 survives). One unit case that rejects the first request and resolves a newer one would close it. [T]
- "Unfiltered numbers unchanged" holds on this fixture but not unconditionally:
/api/nodesrows pass through the geo_filter, blacklist, hidden-prefix and area post-filters inhandleNodes, whereasGetAllRoleCountsis a raw per-roleCOUNT(*). On a deployment with any of those configured, the unfiltered breakdown can now read lower than before — and will agree with Network Status, which is arguably the intended behaviour, but it is a visible change the PR text does not state. Established by readinghandleNodes; not exercised. [K] - The Role Breakdown badge colour resolves via
ROLE_COLORS[role]whereroleis the pluralized key, which never matchesROLE_COLORS' singular keys, so every badge renders in the fallback grey. Pre-existing, unrelated to fix(analytics): derive role breakdown from filtered node rows #560, unchanged. [K]
Not verified
- No staging or production access; no measurement above the 10 000-node cap (residual 2 is from reading
fetchAllNodes). [A] - Behaviour with geo_filter, blacklist or hidden-prefix configured (residual 5) is from reading
handleNodesonly. [K] - Chromium only; no other browser engine. [T]
cmd/ingestorwas not re-run locally — the diff contains no Go at all; CI's Go Ingestor Tests job passed on this head. [A]
CS-Minimax PR#580 round-2 (REQUEST CHANGES): 1. Area-only and combined region+area filters had no test coverage, so mutant M7 (dropping AreaFilter.areaQueryString() from the request) survived. Added unit tests that record the outgoing fetchAllNodes query for both cases, plus an area-only and a combined region+area E2E scenario against a private, disposable server with one test-only area (the shared BASE_URL fixture configures none). 2. fetchAllNodes (public/app.js) silently sliced to its safetyCap with no way for a caller to tell. It now returns `truncated`, and renderNodesTab (analytics.js) shows a visible notice when the loaded node list may be incomplete, rather than presenting Total Nodes/Role Breakdown as whole regardless. 3. Added an E2E assertion that /api/nodes' global `counts` stays the unconditional per-role total under a region filter -- the contract this PR's Go-free diff relies on holding. 4. Added an E2E assertion that the unfiltered Role Breakdown equals /api/nodes' own `counts` directly, not just its own internal sum. Relates to #560. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rapport — CS-Macmini PR#580 runde-2 — head ca00f9eReview feedback addressed (commit 1. Area-only and combined region+area had no test coverage (mutant M7 survived) — fixed
2. Loader completeness (
|
| Finding | Mutant | Caught by |
|---|---|---|
| 1 | M7: drop AreaFilter.areaQueryString() from the request |
2 new unit tests (Section D) |
| 2a | Invert truncated's sign in fetchAllNodes |
4 truncated assertions in test-fetch-all-nodes-pagination.js |
| 2b | Force nodesTruncatedNotice to '' always |
"shows a truncation notice" unit case |
CI (head ca00f9e7, run 40166200367, attempt 1 of each job)
| Job | Result |
|---|---|
| Go Build & Test | pass (25s) |
| Go Server Tests (1/3, 2/3, 3/3) | pass (4m45s, 8m31s, 19m27s) |
| Go Ingestor Tests | pass (4m9s) |
| Go Libraries & CLI | pass (1m21s) |
| JavaScript Unit Tests & Lint | pass (2m53s) |
| Script & Container Unit Checks | pass (1m1s) |
| Playwright E2E Tests (1/3, 2/3, 3/3) | pass (11m8s, 16m52s, 9m50s) — no retry needed, #271 not observed |
| Verify E2E Shards & Coverage | pass (19s) |
| Verify Go Test Assignments | pass (1m7s) |
| Validate Docker Image | pass (53s) |
| Build & Publish Docker Image | pass (7s) |
| Deploy Staging / Release Artifacts / Publish Badges | skipping (expected on a non-tag PR run) |
No #301 flake either (that test is in the cmd/server suites, all green on attempt 1). Run: https://github.com/dborup/CoreScope/actions/runs/40166200367 [A]
Branch state
- Merged
origin/master(fe3b650c) into the branch with a merge commit — brought in sqlite: honour busy_timeout/journal_mode via modernc _pragma DSN #578 (sqlite busy_timeout DSN), sqlite: _busy_timeout/_journal_mode DSN params are ignored by modernc (busy_timeout=0 in server/ingestor) #576/fix(packets): preserve area filtering in SQL history fallback #579 (area-filter SQL history fallback), fix(node analytics): render battery status icons safely at icon size #581 (battery icon), server: wait for graceful shutdown before exit #582 (graceful shutdown wait). Only conflict was an insertion collision inscripts/e2e-shards.json(this branch's newtest-issue-560-role-breakdown-e2e.jsenv entry vs. master's newtest-issue-562-battery-badge-e2e.jsentry) — resolved by keeping both, order preserved. [K] - Re-ran the full local verification (unit,
test-all.sh,cmd/serverGo tests, the E2E suite against a rebuilt server+migrate binary) on the merged tree before pushing; all green, reported above. - Commit identity:
dborup <kontakt@meshview.dk>(repo-local config, verified). PR remains a draft; no merge, approve, or issue-closing action taken.
Not verified
- No staging or production access was used; no API key was used.
- Finding 6's geo_filter/blacklist/hidden-prefix scenario is unexercised (no such config in the fixture), consistent with the prior round's own "Not verified" note.
Review — CS-Minimax PR#580 — head ca00f9eDom: APPROVE med nits Re-review of round 2 against my review of 56156ae. All four required findings (1–4) are closed with tests that fail on unchanged code and pass with the fix. One process item (finding A) should be dealt with at merge time. Evidence tags: [T] = I ran it, [A] = read from CI or a script output, [K] = checked by reading the code or diff. Status of my round-1 findings
New findings
The review points
Tests (merged tree:
|
| Mutant | Result |
|---|---|
N1: drop AreaFilter.areaQueryString() |
caught: 2 unit failures |
N2: drop RegionFilter.regionQueryString() |
caught: 1 unit failure |
N3: invert truncated (stoppedShort instead of !stoppedShort) |
caught: 4 pagination failures |
N4: drop the seen.size > nodes.length term |
survives (finding C) |
N5: drop the !stoppedShort term |
caught: 1 pagination failure |
| N6: truncation notice always empty | caught: 1 unit failure |
| N7: notice shown on the inverted flag | caught: 2 unit failures |
| N8 (E2E): drop the area query | caught: area-only and combined steps fail |
Other checks
scripts/check-xss-sinks.sh --diffagainstorigin/master, run on a scratch git copy of the merged tree: clean, exit 0. [T]- Fork guards: 9 in
deploy.yml, 1 inrelease-fast-path.yml, same asorigin/master; neither file is in the diff. [T] - Commit author and committer are
dborup <kontakt@meshview.dk>on all three commits. [T] cmd/serveruntouched, so it stays read-only. No Go changes, so no newmap[string]interface{}. No new hex colors; the notice usesvar(--status-yellow), which exists instyle.css. [K]- Head SHA unchanged on the remote before and after my work. [T]
Not verified
- A real deployment with more than 10000 nodes; the truncation path is covered by unit tests and probes only.
- Behavior with geo_filter, blacklist or hidden-prefix configured (finding 6).
cmd/ingestorsuite, and any non-Chromium browser.- CI reruns; I read the job results of the existing run. No staging or production access was used.
Relates to #560
Problem
Analytics → Nodes rendered its "Role Breakdown" from
/api/nodes' globalcountsfield (cmd/server/db.goGetAllRoleCounts()), an unconditionalCOUNT(*)over the wholenodestable. The "Network Status" total right above it was already computed client-side from the loaded, region/area-filtered rows (public/analytics.jsrenderNodesTab). Picking a region changed the total but left every role badge showing the unfiltered mesh-wide numbers.Change
public/analytics.js: addednodeRoleBreakdownCounts(nodes)— a single boundedO(n)pass over the rowsrenderNodesTabalready has in memory, tallying the same four roles and the same pluralized keys (repeaters/rooms/companions/sensors) asGetAllRoleCounts, so an unfiltered load renders identical numbers to before. Roles outside that set (observer, missing, or anything unrecognized) are not tallied into any bucket — the same omissionGetAllRoleCountsalready has today, just now scoped to the filtered rows instead of the whole table.renderNodesTabnow builds the breakdown fromnodesinstead ofnodesResp.counts. No new request is made —fetchAllNodesand the bulk-health call are unchanged._nodesTabGen, same pattern as_channelRenderGen/_distanceGenelsewhere in this file) so a late response from a superseded filter/tab change can't overwrite a newer render./api/nodes' globalcountsfield itself is untouched —cmd/serverstays read-only, and any other consumer of that field keeps its existing global meaning.Why this shape
nodestable only ever hasrepeater/room/companionrows (nosensor/observerrows), so mirroringGetAllRoleCounts' existing 4-role tally satisfies that exactly, without guessing at a different categorization (e.g. tallying every observed role string) that would also change what the unfiltered view renders today.analytics.jsonly — no backend/schema change, no extra request, no change to the fork's existing filtered pagination, identity visibility or listener cleanup.Tests
test-issue-560-role-breakdown.js(Section A, 5 cases) — unit tests ofwindow._analyticsNodeRoleBreakdownCountsnodesResp.counts || {}fails 3/8 cases in the filecounts; no extra requesttest-issue-560-role-breakdown.js(Section B) —renderNodesTabexercised with a stubfetchAllNodesreturning rows that disagree with a synthetic globalcounts; asserts exactly 1fetchAllNodes+ 1 bulk-health calltest-issue-560-role-breakdown.js(Section B, third case) — two overlappingrenderNodesTabcalls, resolved out of ordermyGen !== _nodesTabGenguard fails this case (7/8 pass, 1 fails)test-issue-560-role-breakdown-e2e.js— Playwright against the Go server on the CI fixture (region MRY: 13 nodes, allrepeater, out of 200 total/168 repeaters unfiltered)nodesResp.countsfails the MRY assertion (3/4 pass, 1 fails: sum 200 ≠ total 13)All four red-before/green-after transitions were verified locally (temporarily reverting the
public/analytics.jsdiff and re-running each file).[T]
go test ./...incmd/server: pass (no Go changes in this PR; run as a regression check).[T]
node test-frontend-helpers.js: 719/719 pass.[T]
sh test-all.sh: 269/269 files pass (new unit file wired in).[T]
test-issue-560-role-breakdown-e2e.jswired intoscripts/e2e-shards.json(shard 2) and validated withnode scripts/run-e2e-shard.js --validate.[A]
scripts/check-xss-sinks.sh --diff origin/master: clean.[A] Fork-guard counts unchanged: 9 in
deploy.yml, 1 inrelease-fast-path.yml(no diff to either file).Scope
cmd/serveruntouched.map[string]interface{}outside tests (no Go changes at all).