Skip to content

feat(analytics): deep-link Scopes sub-tabs and window (#205) - #206

Merged
dborup merged 2 commits into
masterfrom
codex/issue-205-analytics-subtab-deeplinks
Oct 4, 2026
Merged

dborup merged 2 commits into
masterfrom
codex/issue-205-analytics-subtab-deeplinks

Conversation

@dborup-agent

Copy link
Copy Markdown
Collaborator

Relates to #205

Summary

The Scopes tab's sub-tab (Overview / Hop Depth / Regions / Hygiene) and its 1h/24h/7d window lived only in sessionStorage, so #/analytics?tab=scopes could not point at e.g. Hop Depth. The Wardriving tab's window had the same problem. All three are now in the hash next to ?tab=:

Key Values Default (left out of the URL)
sub= overview, hopdepth, regions, hygiene overview
swin= 1h, 24h, 7d 24h
wdwin= 1h, 24h, 7d 24h

Example: #/analytics?tab=scopes&sub=hopdepth&swin=7d

Why not reuse window=

window= is already the global analytics time picker above the tab bar. It allows different values (''/1h/24h/7d/30d), _updateAnalyticsUrl writes it on every tab switch, and it drives the shared loads (rf/topology/channels/relay-airtime). The Scopes picker is a separate control that only feeds /api/scope-stats. Sharing one key would either push the Scopes window into every other tab's fetches or let the global 30d/All-data values land on a picker that has no such buttons. So each tab window gets its own key: swin for Scopes and wdwin for Wardriving, mirroring their data-win / data-wdwin buttons.

Plan / milestones

  1. Red tests:
    • a vm-harness unit test test-analytics-subtab-deeplinks-205.js, registered in test-all.sh;
    • a Playwright E2E test-issue-205-analytics-subtab-deeplinks-e2e.js in the CI Playwright step.
  2. Fix in public/analytics.js:
    • one shared resolver plus a read/write pair, used by Scopes and Wardriving;
    • _updateAnalyticsUrl drops other tabs' keys through one map.
  3. Validation and audit:
    • browser validation against a local server running the migrated e2e-fixture.db;
    • the audit below. Follow-ups are listed, not implemented.

No config/customizer implications: nothing new is configurable.

Behaviour

  • Precedence: a URL value wins over sessionStorage and is stored, so a later plain click on the Scopes tab returns to it.
  • No URL value: the stored value is used while it is still a known one, and is written back to the URL so the address bar shows the state on screen.
  • Unknown or hostile URL value: falls back to the default, not to the stored value, never throws, and the URL is rewritten without it. Values are only compared with === against a fixed list and never reach a selector or markup (as in analytics: escape-safe ?tab= lookup, pin remaining #191 test gaps, withQuery contract #193/fix(analytics): escape-safe ?tab= lookup and withQuery contract (#193) #194).
  • Default view: the default is left out of the URL, so a default view keeps the URL it produces today, e.g. #/analytics?tab=scopes.
  • Clicks: a sub-tab or window click writes the URL with replaceState, the same as the top-level tab clicks. Switching to another tab drops sub/swin/wdwin.
  • Reload and back/forward: these remount the page from the URL. Every hash change goes through navigate(), which re-runs init().
  • One snapshot per read: all keys of a tab are resolved from the same hash snapshot before any is written. Writing one key rebuilds the hash, which drops an empty key (?sub=) that a second read would then miss. The unit test found this during development, and it is pinned there.
  • rf-health: its range/observer/from/to clearing now goes through the same TAB_URL_PARAMS map, with no behaviour change. This is pinned by a test.

Perf

  • Each tab render does one URLSearchParams parse and at most one replaceState. Clicks do the same.
  • There is no new fetch and nothing in a hot loop.

Audit of other analytics tabs' local view state

Tab State Lives in Decision
Scopes sub-tab sessionStorage URL sub= (this PR, tested)
Scopes 1h/24h/7d window sessionStorage URL swin= (this PR, tested)
Wardriving 1h/24h/7d window sessionStorage URL wdwin= (this PR, tested). It is the same pattern as Scopes.
RF Health range / observer / from / to URL already Unchanged; its clearing now goes through TAB_URL_PARAMS.
Hash Issues bytes / section URL already (Kpa-clawbot#1914) Unchanged. Follow-up: these keys are not dropped on a tab switch. Adding collisions: ['bytes', 'section'] to TAB_URL_PARAMS would do it, but it changes existing URLs, so it is left out of this PR.
Prefix Tool prefix / generate URL, read-only prefill Unchanged. Follow-up: the URL is not updated when a prefix is checked.
Scopes Hygiene/Overview filters (noScopeFilter role/q/geo, neverRelayFilter.geo, adoptFilter.mode) memory Follow-up. These are filters, so they belong in the URL. There are five values, one of them free text, so they need their own key design.
Scopes <details> open state memory Stays local. It is a disclosure state, like an expand toggle, not a view.
Repeater Metrics scatter X/Y axes localStorage Follow-up. It is a view mode, but today it deliberately persists across sessions. It needs a decision on URL vs localStorage precedence, with the keys validated against REPEATER_METRIC_AXES.
Neighbor Graph min-score slider (localStorage), role checkboxes (memory) localStorage / memory Follow-up. These are filters. They interact with the Kpa-clawbot#1925 restart path and with test-issue-1758-ng-filter-rerenders-e2e.js, so they are kept out of this PR.
Topology per-observer reach selector memory Follow-up. It is a selected item.
Clock Health severity filter + table sort memory Follow-up. It is a filter and a sort.
Areas table sort + "show all" memory Sort: follow-up, with Clock Health under one analytics-table sort pattern. "Show all": stays local (disclosure).
Wardriving sender drill-down memory Follow-up. It is a selected item.
Wardriving, Foreign Traffic "Show all N" toggles memory Stay local. It is a disclosure state.
Route Patterns "Hide likely prefix collisions" localStorage Stays local. It is a display preference that persists across sessions on purpose.
Channels table sort localStorage Stays local for now. It is a sort preference that persists across sessions on purpose. It could move to the URL with URLState.serializeSort if wanted.
Roles, Overview, RF, Nodes, My Repeaters, Distance, Hash Stats none beyond the global window/region/area — Nothing to do.

Pre-existing issue found during the audit (not changed):

  • Where: renderScopesTab is called again on the startup theme-refresh.
  • Problem: the second call's 60 s auto-refresh interval keeps the window it read at that moment. A later window click updates the first call's closure, so the next auto-refresh can reload the old window. This PR does not make it worse, since every call now reads the same URL.
  • Follow-up: have the interval read the current window.

Tests

  • test-analytics-subtab-deeplinks-205.js: 69 cases in the real app.js + url-state.js + analytics.js in a vm. It covers:

    • the resolveViewParam table;
    • every sub-tab and window from the URL;
    • URL over storage;
    • storage written back to the URL;
    • hostile sub/swin/wdwin (quotes, selector-list injection, __proto__, markup) falling back to the defaults;
    • empty keys;
    • window= staying independent;
    • unchanged default URLs;
    • clicks writing the URL;
    • tab switches dropping keys;
    • rf-health unchanged.

    On master, 57 of 66 fail; the 3 empty-key cases were added later.

  • test-issue-205-analytics-subtab-deeplinks-e2e.js, added to the Playwright step:

    • cold load of ?tab=scopes&sub=hopdepth;
    • clicks writing sub/swin;
    • reload;
    • back/forward across #/nodes;
    • in-app navigation where the URL wins over storage;
    • a tab switch;
    • a hostile sub with no page error;
    • an unchanged default URL;
    • a Wardriving wdwin cold load and click.

    Against the master frontend, 8 of 9 fail.

  • Fork guards in deploy.yml are unchanged (9 occurrences).

Browser validation

Local Go server with the migrated e2e-fixture.db (freshened, migrated and seeded as in CI). Results for #/analytics?tab=scopes&sub=hopdepth:

Step Result
Cold load Hop Depth active, its panel is the only visible one, URL unchanged
Click Hygiene URL …&sub=hygiene
Reload Hygiene
Go to #/packets, back Hygiene
Forward #/packets
Back Hygiene

No page errors.

🤖 Generated with Claude Code

dborup and others added 2 commits October 4, 2026 06:37
…the URL (#205)

Unit tests (vm harness) and a Playwright E2E for ?sub=, ?swin= and
?wdwin=: URL wins over sessionStorage, unknown values fall back to the
defaults, default URLs unchanged, clicks write the URL, reload and
back/forward restore the view. Red on master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dow (#205)

The Scopes sub-tab and window and the Wardriving window lived only in
sessionStorage. They are now in the hash as ?sub=, ?swin= and ?wdwin=
(?window= stays the global time picker). A URL value wins, an unknown
one falls back to the default, and values are only compared with ===.
Without a URL value the stored one is used and written back; defaults
are left out, so today's URLs are unchanged. All keys of a tab are read
before any is written, since rebuilding the hash drops an empty key.
_updateAnalyticsUrl drops other tabs' keys through one map (rf-health
folded in).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-pve-agent1 PR#206 #205 — head 6b6ceca

Status: Draft, all CI jobs green on 6b6ceca6. Scopes sub=/swin= and Wardriving wdwin= are deep-linked; the remaining items from the audit are listed as follow-ups.

Evidence tags:

  • [T] = tested (automated test run here or in CI);
  • [A] = observed by me (manual run, browser or log);
  • [K] = from reading the code, not executed.

Changes

Two commits: 104db110 (red tests) and 6b6ceca6 (feature).

  • public/analytics.js:
    • New URL keys:
      • sub= for the Scopes sub-tab: overview, hopdepth, regions or hygiene;
      • swin= for the Scopes window: 1h, 24h or 7d;
      • wdwin= for the Wardriving window: 1h, 24h or 7d.
    • They share one resolver (resolveViewParam) and one read/write pair (restoreViewParams / setViewParam). [T]
    • window= is not reused. It is the global picker: it has other values (All data, 30d), it is written on every tab switch and it drives the shared loads. Reusing it would mix the two controls. [K]
  • Precedence:
    • a valid URL value wins over sessionStorage and is stored [T];
    • without a URL value, the stored value is used and written back to the URL [T];
    • an unknown or hostile URL value falls back to the default (not to the stored value), is only compared with ===, and is removed from the URL [T].
  • Default URLs are unchanged: defaults are left out, so #/analytics?tab=scopes stays as it is. [T]
  • Tab switch: _updateAnalyticsUrl drops other tabs' keys through one TAB_URL_PARAMS map. rf-health's existing clearing is folded in, with behaviour pinned by a test. [T]
  • Bug found by the unit test during development:
    • Problem: writing one key rebuilds the hash and drops an empty key (?sub=) before it is read.
    • Fix: all keys of a tab are now read from one snapshot before anything is written.
    • Pinned by 3 cases. [T]
  • test-all.sh and deploy.yml: one new line each. Fork guards are still 9 occurrences. [A]

Audit

The full table is in the PR description.

  • Moved to the URL in this PR:
    • Scopes sub-tab and window [T];
    • Wardriving window [T].
  • Already in the URL, unchanged:
    • RF Health;
    • Hash Issues bytes/section;
    • Prefix Tool prefill. [K]
  • Follow-ups:
    • Scopes hygiene/adoption filters;
    • Repeater Metrics scatter axes;
    • Neighbor Graph min score and roles;
    • Topology observer selector;
    • Clock Health filter and sort, Areas sort;
    • Wardriving sender drill-down;
    • Hash Issues keys not dropped on a tab switch;
    • Prefix Tool not writing back to the URL. [K]
  • Stays local (disclosure toggles, or a display preference persisted on purpose):
    • "Show all" toggles;
    • Scopes <details>;
    • Route Patterns hide-collisions;
    • Channels sort. [K]
  • Pre-existing issue:
    • Where: the startup theme-refresh re-runs renderScopesTab.
    • Problem: the second run's 60 s interval keeps the window it read at that moment, so a later window click can be overwritten by the auto-refresh.
    • It is not worsened here and is listed as a follow-up. [K]

Tests and mutants

  • New unit test test-analytics-subtab-deeplinks-205.js: 69/69 green [T].
    • On origin/master, 57/66 fail; the 3 empty-key cases were added later. [T]
  • New E2E test-issue-205-analytics-subtab-deeplinks-e2e.js: 9/9 locally [T].
    • Against the master frontend, 8/9 fail [T].
    • It covers:
      • cold load;
      • clicks;
      • reload;
      • back/forward;
      • in-app navigation where the URL wins over storage;
      • tab switch;
      • a hostile sub with no page error;
      • an unchanged default URL;
      • Wardriving.
  • Existing suites:
    • sh test-all.sh: 212/212 files [T];
    • node test-frontend-helpers.js: 707/707 [T];
    • scripts/check-xss-sinks.sh --diff: clean [T].
  • Mutants: 7 of 7 killed by the unit test [T]:
    1. An unknown URL value falls back to the stored value.
    2. The default is not omitted from the URL.
    3. Scopes keys are not dropped on a tab switch.
    4. Keys are written one at a time (the empty-key bug).
    5. A sub-tab click does not write the URL. The E2E also kills this one, with 5 steps failing. [T]
    6. The stored value wins over the URL (the old behaviour).
    7. The Wardriving restore is removed.

Browser

Local Go server with a copy of e2e-fixture.db, freshened, migrated with corescope-migrate and seeded as in CI. It was stopped by pid. Results for #/analytics?tab=scopes&sub=hopdepth:

Step Result
Cold load Hop Depth active, its panel the only visible one, URL unchanged [A]
Click Hygiene URL …&sub=hygiene [A]
Reload Hygiene [A]
#/packets, then back Hygiene [A]
Forward #/packets [A]
Back Hygiene [A]

No page errors. Screenshot checked: Hop Depth chart and cards render. [A]

CI per job

Run on head 6b6ceca6:

Job Result
✅ Go Build & Test success, including test-all 212/212 and the XSS gate [T]
🎭 Playwright E2E Tests success; the new E2E ran and printed PASS [T]
🏗️ Build & Publish Docker Image success [T]
Release Artifacts, Deploy Staging, Publish Badges skipped (fork/PR guards, as expected) [A]

Remaining

  • The follow-ups listed under Audit.
  • The PR stays a draft. No merge or ready-for-review was done by me.

@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Review — CS-pve-agent3 PR#206 analytics-deeplinks — head 6b6ceca

Dom: APPROVE with nits

Independent, read-only review. I read issue #205, the PR description and the author's report.

  • Code was taken from git archive of the head and of the merged tree (git merge-tree --write-tree origin/master 6b6ceca6).
  • The merge base is the current origin/master (33b0dfe5), and the merged tree is byte-identical to the head tree (a6c81ced).
  • git ls-remote showed the head at 6b6ceca6 both before and after the review.

Evidence tags: [T] = test or CI run by me, [A] = my own code analysis, [K] = from the author's report, not re-run.

Findings

# Prio Where Description
1 P3 public/analytics.js:395 (restoreViewParams) Back/forward to a history entry in a default state shows the session value, not that entry's view. A default is left out of the URL, and a missing key falls back to sessionStorage. So an entry without swin=/sub= cannot pin the default.
Seen in the browser:
  1. Cold load of #/analytics?tab=scopes&sub=hopdepth.
  2. Set the hash to …&sub=regions&swin=1h, which creates a history entry.
  3. Go back.
The page shows Hop Depth (correct) with the 1h window, and rewrites that entry to …&sub=hopdepth&swin=1h. The same applies to sub: an entry with plain ?tab=scopes reopens on the last stored sub-tab. [T]
This follows from two requirements of the issue: default URLs stay unchanged, and the stored value is the fallback. Explicit URL values always round-trip, so the acceptance criterion holds. Suggest: one line under "Behaviour" in the PR text stating this limit. No code change needed.
2 P3 PR text, audit table The audit misses two items:
  • Hash Stats: the multi-byte adopters section has a filter (All / Confirmed / Suspected / Unknown, data-mb-filter, analytics.js:1630) and a column sort (analytics.js:1657), both kept in memory. The table says Hash Stats has "none beyond the global window/region/area".
  • Neighbor Graph: the confidence <select id="ngConfidence"> (analytics.js:3271, memory) is not listed. Only the min-score slider and the role checkboxes are.
Both are filters, so by the PR's own rule they would be follow-ups. Suggest: add two rows. [A]
3 P3 (pre-existing) public/analytics.js:354 (TAB_URL_PARAMS) Hash Issues keys are not cleaned on a tab switch. The comment says the map holds "the hash keys each tab owns", but Hash Issues' bytes/section are not in it. The author already lists this as a follow-up.
Seen in the browser: in a click flow from Overview through every tab, bytes=1 from Hash Stats/Hash Issues stays in the URL on Scopes and Wardriving (#/analytics?tab=scopes&bytes=1). Master behaves identically. [T]
This PR does not need to change it.

There are no P1 or P2 findings.

Verification points

1. Scopes: read on mount, written on click, URL beats storage, back/forward, key choice

  • Read on mount: renderScopesTab and renderWardrivingTab resolve their keys from one hash snapshot (restoreViewParams) before anything else renders. [A]
  • Written on click: the sub-tab, data-win and data-wdwin clicks call setViewParam, which uses replaceState, as the top-level tab clicks do. [A] [T]
  • URL wins over sessionStorage:
    • covered by the unit test and the E2E;
    • also seen when the hash is edited in place on the analytics page: ?sub=regions&swin=1h replaced Hop Depth with Regions and 1h, with no page error. [T]
  • Back/forward:
    • works across another page (E2E);
    • works within the page for explicit values;
    • see finding 1 for entries in the default state. [T]
  • swin/wdwin instead of window: the choice is justified.
    • The global window= accepts ''/30d.
    • _updateAnalyticsUrl writes it on every tab switch.
    • It drives loadAnalytics.
    • The Scopes picker only feeds /api/scope-stats and has only 1h/24h/7d.
    • The unit test pins that the two stay independent. [A] [T]

2. Escape safety (as in #194)

  • No selector or markup is built from the URL. [A]
    • URL values are only compared with allowed.indexOf() (===).
    • getElementById('scopes-panel-' + key) iterates the constant SCOPES_SUBTAB.allowed.
    • data-subtab and data-win in the markup come from constants.
  • Stored values are now also validated, where before they went into load() unchecked. This is a small hardening. [A]
  • Browser matrix with no failures (72/72) [T]:
    • Keys: sub, swin and wdwin.
    • Values: ", ], __proto__, constructor, toString, HOPDEPTH, 7D, <img src=x onerror=…>, a"],[data-subtab="hygiene, %00, the empty string and ' hopdepth'.
    • Each value was run with and without a non-default stored value.
    • In every case the view fell back to the default (not to the stored value), the key was removed from the URL, there was no pageerror and the markup did not execute.

3. Default-state URLs are unchanged

  • The same Playwright script ran against two local servers on the same migrated fixture DB: one serving the master public/, one the merged-tree public/.
  • It recorded 86 resulting URLs:
    • cold loads of all 20 tabs, each plain and with &window=7d and &region=…;
    • a fresh-session click flow through every tab;
    • a global window change on Scopes;
    • the rf-health range/observer and Hash Issues bytes/section deep links, each followed by a switch to Scopes.
  • Result: byte-identical, with 0 page errors on either side. [T]

4. Audit table

  • The localStorage/sessionStorage coverage is complete. [A]
    • Every storage key in analytics.js is either listed or is not analytics view state: the affinity debug flag, meshcore-my-nodes (an app-wide claim list) and the GPS-trail handoff to the map.
  • The reasons given for "stays local" and "follow-up" are reasonable:
    • disclosure toggles and <details> stay local;
    • preferences persisted on purpose (Route Patterns, Channels sort) stay local;
    • filters, sorts and selected items become follow-ups.
  • Two memory-only filters are missing; see finding 2. [A]
  • The pre-existing theme-refresh interval issue is plausible from the code, but I did not reproduce it. [K]

5. Tests: see below.

6. Rules

  • scripts/check-xss-sinks.sh --diff (head vs origin/master): exit 0, nothing flagged. [T]
  • Fork guards: the upstream github.repository == … guard occurs 9 times in deploy.yml on both master and head. The diff only adds one E2E line. [T]
  • No closing keywords:
  • CI is green on 6b6ceca6: Go Build & Test, Playwright E2E and Docker. Deploy/Release/Badges were skipped by the guards. [T] (read from the checks API)

Tests and mutants

  • sh test-all.sh on the merged tree: 212 passed, 0 failed (212 files), exit 0. [T]

    • It includes test-frontend-helpers.js at 707 passed, 0 failed.
    • It includes the new test-analytics-subtab-deeplinks-205.js at 69 passed, 0 failed.
    • This was the only full run.
  • New E2E test-issue-205-analytics-subtab-deeplinks-e2e.js against a local Go server (merged tree, migrated and freshened copy of e2e-fixture.db, system Chromium): 9 passed, 0 failed. [T]

    • Both servers were stopped by the pid found from their listening port.
  • Own mutants (6/6 killed), each run against the 205 unit test and test-analytics-tab-state-and-query.js. [T]

    Mutant 205 unit test tab-state test
    A: empty URL key treated as absent (params.get(k) || null), so the stored value wins on ?sub= 6 fail 39/39 pass
    B: stored value not validated against allowed 3 fail 39/39 pass
    C: rf-health entry removed from TAB_URL_PARAMS 1 fail 39/39 pass
    D: Wardriving window click writes only storage, not the URL 1 fail 39/39 pass
    E: sub-tab click writes through the wrong spec (SCOPES_WINDOW) 2 fail 39/39 pass
    F: URL value accepted by prefix match instead of === 2 fail 39/39 pass

    Mutant C is caught only by the new test. Before this PR, the rf-health clearing on a tab switch had no unit pin, so that is a coverage gain.

  • The author's 7 mutants and the "8/9 E2E fail on master" result were not re-run. [K]

Not verified

  • Browser validation on staging or prod (out of scope; local only).
  • The 60 s Scopes auto-refresh interval issue named by the author. [K]
  • Safari's replaceState throttling path (the try/catch in _writeViewParams was only checked by reading). [A]
  • Mobile layout and screenshots. There is no visual change beyond the existing buttons.
  • The CI Playwright run with instrumented public/ (taken from the CI result, not re-run).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants