diff --git a/cmd/server/issue199_node_not_found_test.go b/cmd/server/issue199_node_not_found_test.go index 086dbdbaf..b3c83329b 100644 --- a/cmd/server/issue199_node_not_found_test.go +++ b/cmd/server/issue199_node_not_found_test.go @@ -1,10 +1,14 @@ package main import ( + "bytes" + "context" "encoding/json" + "log" "net/http/httptest" "strings" "testing" + "time" "github.com/gorilla/mux" ) @@ -153,3 +157,82 @@ func TestNodeDetail404HiddenIdentityStaysBare(t *testing.T) { }) } } + +// #208 item 1: a failed lookup still answers the bare 404, but is logged -- +// once at first, then at most once per missingNodeLogEvery -- so a broken +// lookup (schema drift on inactive_nodes) does not pass for a plain miss. +func TestNodeDetail404LookupErrorIsLoggedOnce(t *testing.T) { + srv, router := setupTestServer(t) + // Schema drift: an inactive_nodes without the columns the lookup reads. + if _, err := srv.db.conn.Exec(`CREATE TABLE inactive_nodes (public_key TEXT PRIMARY KEY, name TEXT)`); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(prev) + + for i := 0; i < 3; i++ { + code, body := issue199Get(t, router, issue199Unknown) + if code != 404 || len(body) != 1 || body["error"] == nil { + t.Fatalf("request %d: status=%d body=%v, want the bare 404", i, code, body) + } + } + out := buf.String() + if n := strings.Count(out, "missing-node lookup failed"); n != 1 { + t.Fatalf("logged %d lookup failures for 3 requests, want 1; log:\n%s", n, out) + } + if !strings.Contains(out, "no such column") { + t.Errorf("log line does not carry the error: %s", out) + } + if strings.Contains(out, issue199Unknown) { + t.Errorf("log line carries the requested key: %s", out) + } +} + +// A request cancelled by its client is not a broken lookup: nothing logged. +func TestNodeDetail404CancelledLookupIsNotLogged(t *testing.T) { + srv, _ := setupTestServer(t) + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(prev) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + req := httptest.NewRequest("GET", "/api/nodes/"+issue199Unknown, nil).WithContext(ctx) + w := httptest.NewRecorder() + srv.writeNodeNotFound(w, req, issue199Unknown) + if w.Code != 404 { + t.Fatalf("status=%d, want 404", w.Code) + } + if strings.Contains(buf.String(), "missing-node lookup failed") { + t.Errorf("cancelled request logged as a lookup failure: %s", buf.String()) + } +} + +// The throttle logs the first failure, suppresses the rest inside the +// interval and reports how many it suppressed with the next logged one. +func TestMissingNodeLookupLogThrottle(t *testing.T) { + var l missingNodeLookupLog + t0 := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) + steps := []struct { + at time.Duration + log bool + wantSuppressed int + }{ + {0, true, 0}, + {time.Second, false, 0}, + {missingNodeLogEvery - time.Second, false, 0}, + {missingNodeLogEvery, true, 2}, + {missingNodeLogEvery + time.Minute, false, 0}, + {3 * missingNodeLogEvery, true, 1}, + {5 * missingNodeLogEvery, true, 0}, + } + for i, s := range steps { + logIt, suppressed := l.note(t0.Add(s.at)) + if logIt != s.log || suppressed != s.wantSuppressed { + t.Errorf("step %d (+%v): note()=(%v, %d), want (%v, %d)", i, s.at, logIt, suppressed, s.log, s.wantSuppressed) + } + } +} diff --git a/cmd/server/node_not_found.go b/cmd/server/node_not_found.go index 22b3657c9..e9ba5a17e 100644 --- a/cmd/server/node_not_found.go +++ b/cmd/server/node_not_found.go @@ -8,6 +8,8 @@ import ( "log" "net/http" "strings" + "sync" + "time" ) // nodeNotFoundResponse is the 404 body of GET /api/nodes/{pubkey} (#199). @@ -93,12 +95,47 @@ func (s *Server) lookupMissingNode(ctx context.Context, pubkey string) (nodeNotF return resp, nil } +// missingNodeLogEvery bounds the log of failed missing-node lookups (#208): +// the first failure is logged at once, later ones at most once per interval. +const missingNodeLogEvery = 10 * time.Minute + +// missingNodeLookupLog throttles that log. The zero value is ready. +type missingNodeLookupLog struct { + mu sync.Mutex + last time.Time + suppressed int +} + +// note reports whether a failure at now is logged and, if so, how many +// failures were suppressed since the previous logged one. +func (l *missingNodeLookupLog) note(now time.Time) (bool, int) { + l.mu.Lock() + defer l.mu.Unlock() + if !l.last.IsZero() && now.Sub(l.last) < missingNodeLogEvery { + l.suppressed++ + return false, 0 + } + n := l.suppressed + l.last, l.suppressed = now, 0 + return true, n +} + // writeNodeNotFound answers a node-detail miss. A failed lookup falls back to // the bare 404: it only enriches the error and must not turn it into a 500. +// The failure is logged, throttled, so a broken lookup (e.g. schema drift on +// inactive_nodes) does not pass for a plain miss (#208). A request its client +// cancelled is not a broken lookup and is not logged. The requested key is +// left out of the line: it is client input and adds nothing to the error. func (s *Server) writeNodeNotFound(w http.ResponseWriter, r *http.Request, pubkey string) { resp, err := s.lookupMissingNode(r.Context(), pubkey) if err != nil { resp = nodeNotFoundResponse{Error: "Not found"} + if r.Context().Err() == nil { + if logIt, suppressed := s.missingNodeLog.note(time.Now()); logIt { + log.Printf("[routes] missing-node lookup failed, answering a bare 404: %v (%d more suppressed since the last report; next report in %v at the earliest)", + err, suppressed, missingNodeLogEvery) + } + } } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) diff --git a/cmd/server/routes.go b/cmd/server/routes.go index e2ce37ea0..f349355f4 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -58,6 +58,9 @@ type Server struct { // miss moves through handleStats. Nil in production. statsHook func(stage string) + // Throttles the log of failed GET /api/nodes/{pubkey} 404 lookups (#208). + missingNodeLog missingNodeLookupLog + // Shared channel proposals; built lazily from cfg/db (tests may preset). proposals *channelProposalService proposalsOnce sync.Once diff --git a/public/analytics.js b/public/analytics.js index fce9f4bc2..405e9c26e 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -348,11 +348,19 @@ var SCOPES_SUBTAB = { param: 'sub', storageKey: 'scopes_subtab', allowed: ['overview', 'hopdepth', 'regions', 'hygiene'], dflt: 'overview' }; var SCOPES_WINDOW = { param: 'swin', storageKey: 'scopes_window', allowed: ['1h', '24h', '7d'], dflt: '24h' }; var WARDRIVING_WINDOW = { param: 'wdwin', storageKey: 'wardriving_window', allowed: ['1h', '24h', '7d'], dflt: '24h' }; + // #208 — Hash Stats' multi-byte adopters filter. URL only (no storageKey): + // it had no stored state before, so a plain visit still opens on All. + var HASHSTATS_MB_FILTER = { param: 'mbf', allowed: ['all', 'confirmed', 'suspected', 'unknown'], dflt: 'all' }; // The hash keys each tab owns; _updateAnalyticsUrl drops them when - // another tab is selected. + // another tab is selected. Hash Issues' bytes= is deliberately not listed: + // it has no stored fallback, so it stays in the URL across a tab switch + // and a return to Hash Issues keeps the chosen byte size (#1914, #208). + // Its section= is a one-shot scroll anchor and is dropped. var TAB_URL_PARAMS = { 'rf-health': ['range', 'observer', 'from', 'to'], + collisions: ['section'], + hashsizes: [HASHSTATS_MB_FILTER.param], scopes: [SCOPES_SUBTAB.param, SCOPES_WINDOW.param], wardriving: [WARDRIVING_WINDOW.param], }; @@ -370,20 +378,58 @@ try { return typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(key) : null; } catch (e) { return null; } } + // #208 — the view each history entry showed is recorded in its + // history.state, so Back/Forward to an entry whose view was a default (its + // key left out of the URL) restores that default, not the value a later + // entry stored. A new entry (a link, location.hash = …) has no record and + // still gets the stored value. Only specs with a storageKey are recorded: + // for the others a missing key already means the default. A tab switch + // writes a null state (_updateAnalyticsUrl), so a tab clicked back within + // the same entry also gets the stored value, as before. + var ENTRY_VIEW_KEY = 'analyticsView'; + + // The current entry's record, as { param: value } with string values only. + function _entryView() { + var out = {}; + try { + var st = typeof history !== 'undefined' ? history.state : null; + var v = st && typeof st === 'object' ? st[ENTRY_VIEW_KEY] : null; + if (!v || typeof v !== 'object') return null; + Object.keys(v).forEach(function (k) { if (typeof v[k] === 'string') out[k] = v[k]; }); + } catch (e) { return null; } + return out; + } + + // history.state with the record replaced; other keys are kept. + function _stateWithEntryView(view) { + var out = {}; + var st = typeof history !== 'undefined' ? history.state : null; + if (st && typeof st === 'object') Object.keys(st).forEach(function (k) { out[k] = st[k]; }); + out[ENTRY_VIEW_KEY] = view; + return out; + } + // Stores the values and writes them to the hash in one go. A default is // left out, so a tab in its default view keeps the URL it had before #205. + // A spec without a storageKey lives in the URL only. function _writeViewParams(specs, values) { var updates = {}; + var view = _entryView() || {}; + var viewChanged = false; specs.forEach(function (spec, i) { - try { if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(spec.storageKey, values[i]); } catch (e) { /* storage blocked */ } + if (spec.storageKey) { + try { if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(spec.storageKey, values[i]); } catch (e) { /* storage blocked */ } + if (view[spec.param] !== values[i]) { view[spec.param] = values[i]; viewChanged = true; } + } updates[spec.param] = values[i] === spec.dflt ? '' : values[i]; }); if (!window.URLState) return; // replaceState can throw (Safari throttles it); the view has already // changed by then, so a failed URL sync must not break the tab (#1914). + // It only runs when the URL or the entry's record changes. try { var newHash = URLState.updateHashParams(updates, location.hash); - if (newHash !== location.hash) history.replaceState(null, '', newHash); + if (newHash !== location.hash || viewChanged) history.replaceState(_stateWithEntryView(view), '', newHash); } catch (e) { /* URL sync is best effort */ } } @@ -392,11 +438,16 @@ // Read on render: resolve every value of the tab from the same hash first, // then store them and write them back. Writing one value rebuilds the // hash, which drops an empty key ("?sub=") the next read would still see. + // Without a URL value, the entry's own record (#208) comes before the + // stored value. function restoreViewParams(specs) { var hash = typeof location !== 'undefined' ? String(location.hash || '') : ''; var params = new URLSearchParams(hash.split('?')[1] || ''); + var entry = _entryView(); var values = specs.map(function (spec) { - return resolveViewParam(params.get(spec.param), _sessionGet(spec.storageKey), spec.allowed, spec.dflt); + var fallback = null; + if (spec.storageKey) fallback = entry && Object.prototype.hasOwnProperty.call(entry, spec.param) ? entry[spec.param] : _sessionGet(spec.storageKey); + return resolveViewParam(params.get(spec.param), fallback, spec.allowed, spec.dflt); }); _writeViewParams(specs, values); return values; @@ -1489,6 +1540,7 @@ // ===================== HASH SIZES (original) ===================== function renderHashSizes(el, data) { + const mbFilter = restoreViewParams([HASHSTATS_MB_FILTER])[0]; // ?mbf= (#208) const d = data.distribution; const total = data.total; const pct = (n) => total ? (n / total * 100).toFixed(1) : '0'; @@ -1538,7 +1590,7 @@ - ${renderMultiByteAdopters(data.multiByteNodes, data.multiByteCapability || [])} + ${renderMultiByteAdopters(data.multiByteNodes, data.multiByteCapability || [], mbFilter)}
@@ -1562,7 +1614,10 @@ `; } - function renderMultiByteAdopters(nodes, caps) { + // filter: the initially selected filter (All when missing or unknown). + function renderMultiByteAdopters(nodes, caps, filter) { + var initialFilter = HASHSTATS_MB_FILTER.allowed.indexOf(filter) >= 0 ? filter : HASHSTATS_MB_FILTER.dflt; + var mbBtnClass = function (f) { return f === initialFilter ? 'tab-btn active' : 'tab-btn'; }; // Merge capability status into adopter nodes var capByPubkey = {}; (caps || []).forEach(function(c) { capByPubkey[c.pubkey] = c; }); @@ -1627,20 +1682,20 @@ 'Unknown = no multi-byte evidence yet.

' + '
' + '
' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + '
' + '
' + - '
' + buildTableContent(rows, 'all') + '
' + + '
' + buildTableContent(rows, initialFilter) + '
' + ''; // Use setTimeout for event delegation on the stable section container setTimeout(function() { var section = document.getElementById('mbAdoptersSection'); if (!section) return; - var currentFilter = 'all'; + var currentFilter = initialFilter; section.addEventListener('click', function handler(e) { var btn = e.target.closest('[data-mb-filter]'); @@ -1652,6 +1707,7 @@ // Replace only the table content, not the whole section var wrap = section.querySelector('#mbAdoptersTableWrap'); if (wrap) wrap.innerHTML = buildTableContent(rows, currentFilter); + setViewParam(HASHSTATS_MB_FILTER, currentFilter); return; } var th = e.target.closest('[data-sort]'); diff --git a/public/nodes.js b/public/nodes.js index 2e1aa4c4e..516220da8 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -713,7 +713,9 @@ let headline, explanation; if (inactive) { headline = 'Inactive node'; - explanation = 'No advert heard since ' + when(inactive.last_seen) + '; this device is inactive. ' + + // The record, not the device (#208): an observer can be uploading now + // while its node row is still in inactive_nodes. + explanation = 'No advert heard since ' + when(inactive.last_seen) + '; this node is listed as inactive. ' + 'Nodes without an advert inside the retention window are moved off the node list until they advertise again.'; rows.push(['Name', escapeHtml(inactive.name || '—')], ['Role', escapeHtml(inactive.role || '—')], ['Last advert', when(inactive.last_seen)]); } else { diff --git a/test-analytics-subtab-deeplinks-205.js b/test-analytics-subtab-deeplinks-205.js index 15cd65fc4..7b29eb67e 100644 --- a/test-analytics-subtab-deeplinks-205.js +++ b/test-analytics-subtab-deeplinks-205.js @@ -169,7 +169,8 @@ function pageEnv(opts) { localStorage: storage(), sessionStorage: storage(opts.session), location: { hash: '#/analytics' }, - history: { replaceState(_s, _t, url) { hashLog.push(url); ctx.location.hash = url; } }, + // state is kept as a structured clone, like the real history.state. + history: { state: null, replaceState(st, _t, url) { ctx.history.state = st == null ? null : JSON.parse(JSON.stringify(st)); hashLog.push(url); ctx.location.hash = url; } }, CustomEvent: class CustomEvent {}, Map, Set, Promise, URLSearchParams, getComputedStyle: () => ({ getPropertyValue: () => '' }), timeAgo: () => 'x ago', initTabBar() {}, makeColumnsResizable() {}, @@ -209,6 +210,21 @@ function pageEnv(opts) { await flush(); }, initError: () => initError, + // History entries (#208): the current one as { hash, state }; a new + // entry (a link, location.hash = …) has no state; Back/Forward brings + // an entry back with the state it had when it was left. + entry: () => ({ hash: ctx.location.hash, state: ctx.history.state == null ? null : JSON.parse(JSON.stringify(ctx.history.state)) }), + async visit(hash) { + page.destroy(); + ctx.history.state = null; + await this.mount(hash); + }, + async traverse(entry) { + page.destroy(); + ctx.history.state = entry.state == null ? null : JSON.parse(JSON.stringify(entry.state)); + await this.mount(entry.hash); + }, + historyState: () => ctx.history.state, destroy: () => page.destroy(), hash: () => ctx.location.hash, params: () => Object.fromEntries(new URLSearchParams(ctx.location.hash.split('?')[1] || '')), @@ -228,6 +244,7 @@ function pageEnv(opts) { }, activeScopesWindows: () => activeOf('data-win'), activeWardrivingWindows: () => activeOf('data-wdwin'), + activeMbFilters: () => activeOf('data-mb-filter'), clickSubtab: async (key) => { const bar = content().querySelector('#scopesSubtabs'); assert.ok(bar, 'no #scopesSubtabs'); @@ -254,6 +271,7 @@ function pageEnv(opts) { globalWindow: () => el('analyticsTimeWindow').value, content: () => content().innerHTML, resolveViewParam: ctx._analyticsResolveViewParam, + renderMultiByteAdopters: ctx._analyticsRenderMultiByteAdopters, }; } @@ -475,6 +493,26 @@ function pageEnv(opts) { assert.strictEqual(env.hash(), '#/analytics?tab=scopes'); }); + // #208 item 7: Hash Issues' section= is a one-shot scroll anchor and is + // dropped when leaving the tab. bytes= is its remembered byte size: it has + // no stored fallback, and #1914 pins that a tab round-trip keeps it. + await test('switching from Hash Issues to another tab drops section=, keeps bytes= and window=', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=collisions&bytes=2§ion=hashMatrixSection&window=24h'); + assert.strictEqual(env.initError(), null, 'init() threw'); + assert.strictEqual(env.params().bytes, '2', 'precondition: bytes= kept on Hash Issues'); + await env.clickTab('topology'); + assert.strictEqual(env.hash(), '#/analytics?tab=topology&bytes=2&window=24h'); + }); + + await test('a Hash Issues → Hash Stats → Hash Issues round-trip keeps bytes= (#1914)', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=collisions&bytes=2'); + await env.clickTab('hashsizes'); + await env.clickTab('collisions'); + assert.strictEqual(env.params().bytes, '2', 'bytes= lost: ' + env.hash()); + }); + console.log('\n=== #205: Wardriving window (wdwin=) ==='); for (const w of ['1h', '24h', '7d']) { @@ -530,6 +568,169 @@ function pageEnv(opts) { assert.strictEqual(env.hash(), '#/analytics?tab=wardriving'); }); + // #208 item 5: a default view leaves its key out of the URL, and a missing + // key falls back to sessionStorage. Back/Forward to an entry whose view + // was the default must restore that default, not the value a later entry + // stored; a new entry without the key still gets the stored value. + console.log('\n=== #208: Back/Forward to an entry in its default view ==='); + + await test('Scopes: Back to a default-view entry shows the default, Forward the later view', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=scopes'); + const e1 = env.entry(); + await env.visit('#/analytics?tab=scopes&sub=regions&swin=7d'); + assert.deepStrictEqual(env.activeSubtabs(), ['regions'], 'precondition'); + const e2 = env.entry(); + await env.traverse(e1); + assert.deepStrictEqual(env.activeSubtabs(), ['overview'], 'Back: sub-tab'); + assert.deepStrictEqual(env.visiblePanels(), ['overview'], 'Back: panel'); + assert.deepStrictEqual(env.activeScopesWindows(), ['24h', '24h'], 'Back: window'); + assert.strictEqual(env.hash(), '#/analytics?tab=scopes', 'Back: URL'); + assert.strictEqual(env.session.scopes_subtab, 'overview', 'Back: stored sub-tab'); + await env.traverse(e2); + assert.deepStrictEqual(env.activeSubtabs(), ['regions'], 'Forward: sub-tab'); + assert.deepStrictEqual(env.activeScopesWindows(), ['7d', '7d'], 'Forward: window'); + }); + + await test('Scopes: an entry clicked back to its default is restored as the default', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=scopes'); + await env.clickSubtab('hygiene'); + await env.clickSubtab('overview'); + const e1 = env.entry(); + await env.visit('#/analytics?tab=scopes&sub=regions'); + await env.traverse(e1); + assert.deepStrictEqual(env.activeSubtabs(), ['overview']); + assert.strictEqual(env.hash(), '#/analytics?tab=scopes'); + }); + + await test('Scopes: a new entry without sub= still opens the stored sub-tab', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=scopes'); + await env.visit('#/analytics?tab=scopes&sub=regions'); + await env.visit('#/analytics?tab=scopes'); + assert.deepStrictEqual(env.activeSubtabs(), ['regions']); + assert.strictEqual(env.hash(), '#/analytics?tab=scopes&sub=regions'); + }); + + await test('Wardriving: Back to a default-view entry shows 24h', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=wardriving'); + const e1 = env.entry(); + await env.visit('#/analytics?tab=wardriving&wdwin=1h'); + assert.deepStrictEqual(env.activeWardrivingWindows(), ['1h'], 'precondition'); + await env.traverse(e1); + assert.deepStrictEqual(env.activeWardrivingWindows(), ['24h']); + assert.strictEqual(env.hash(), '#/analytics?tab=wardriving'); + }); + + await test('an entry from another page (Back from Nodes) keeps its own view', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=scopes'); + const e1 = env.entry(); + await env.visit('#/analytics?tab=scopes&sub=hopdepth'); + await env.visit('#/nodes'); + await env.traverse(e1); + assert.deepStrictEqual(env.activeSubtabs(), ['overview']); + }); + + await test('a tab switch inside an entry still brings back the stored view', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=scopes&sub=hopdepth'); + await env.clickTab('topology'); + await env.clickTab('scopes'); + assert.deepStrictEqual(env.activeSubtabs(), ['hopdepth']); + }); + + for (const st of [{ analyticsView: { sub: 'x"]', swin: '__proto__' } }, { analyticsView: 'hopdepth' }, { analyticsView: { sub: 7 } }, 'junk', 42]) { + await test('a foreign or garbled entry state ' + JSON.stringify(st) + ' never reaches the view, no exception', async () => { + const env = pageEnv({ session: { scopes_subtab: 'regions' } }); + await env.traverse({ hash: '#/analytics?tab=scopes', state: st }); + assert.strictEqual(env.initError(), null, 'init() threw'); + const sub = env.activeSubtabs(); + assert.ok(sub.length === 1 && ['overview', 'regions'].includes(sub[0]), 'sub-tab ' + JSON.stringify(sub)); + }); + } + + await test('other keys in history.state are kept', async () => { + const env = pageEnv(); + await env.traverse({ hash: '#/analytics?tab=scopes&sub=regions', state: { other: 'kept' } }); + assert.strictEqual(env.historyState() && env.historyState().other, 'kept', JSON.stringify(env.historyState())); + }); + + // #208 item 6: the Hash Stats multi-byte adopters filter (All / Confirmed + // / Suspected / Unknown) is deep-linked as mbf=. URL only: it had no + // stored state before, so a plain visit still opens on All. + console.log('\n=== #208: Hash Stats multi-byte adopters filter (mbf=) ==='); + + // Each adopter's status as the card derives it: its capability row by + // pubkey, else unknown. + const capStatus = {}; + REAL.hashData.multiByteCapability.forEach((c) => { capStatus[c.pubkey] = c.status; }); + const ADOPTERS = REAL.hashData.multiByteNodes.map((n) => ({ name: n.name, status: capStatus[n.pubkey] || 'unknown' })); + await test('precondition: the fixture has multi-byte adopters', async () => { + assert.ok(ADOPTERS.length > 0, 'no multiByteNodes in the fixture'); + }); + + for (const f of ['all', 'confirmed', 'suspected', 'unknown']) { + await test('#/analytics?tab=hashsizes&mbf=' + f + ' selects ' + f + ' and filters the table', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=hashsizes&mbf=' + f); + assert.strictEqual(env.initError(), null, 'init() threw'); + assert.deepStrictEqual(env.activeMbFilters(), [f], 'active filter button'); + for (const a of ADOPTERS) { + const shown = f === 'all' || a.status === f; + assert.strictEqual(env.content().indexOf('' + a.name + '') >= 0, shown, a.name + ' (' + a.status + ')' + (shown ? ' missing' : ' shown')); + } + const none = !ADOPTERS.some((a) => f === 'all' || a.status === f); + assert.strictEqual(env.content().indexOf('No adopters match this filter.') >= 0, none, 'empty-filter message'); + assert.strictEqual(env.hash(), f === 'all' ? '#/analytics?tab=hashsizes' : '#/analytics?tab=hashsizes&mbf=' + f, 'URL not canonical'); + }); + } + + for (const value of ['', 'x"]', 'x"],[data-mb-filter="unknown', '__proto__', 'constructor', 'Confirmed', ' confirmed', '']) { + await test('?mbf=' + JSON.stringify(value) + ' falls back to All, no exception', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=hashsizes&mbf=' + encodeURIComponent(value)); + assert.strictEqual(env.initError(), null, 'init() threw'); + assert.deepStrictEqual(env.activeMbFilters(), ['all']); + assert.strictEqual(env.hash(), '#/analytics?tab=hashsizes'); + assert.ok(env.content().indexOf('onerror') < 0, 'URL value reached the markup'); + }); + } + + await test('#/analytics?tab=hashsizes is left as it is and opens on All', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=hashsizes'); + assert.deepStrictEqual(env.activeMbFilters(), ['all']); + assert.deepStrictEqual(env.hashLog.filter((h) => h !== '#/analytics?tab=hashsizes'), [], 'URL rewritten'); + }); + + await test('mbf= is URL only: nothing stored, a later plain visit opens on All', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=hashsizes&mbf=confirmed'); + assert.deepStrictEqual(Object.keys(env.session), [], 'sessionStorage written: ' + JSON.stringify(env.session)); + env.destroy(); + await env.mount('#/analytics?tab=hashsizes'); + assert.deepStrictEqual(env.activeMbFilters(), ['all']); + }); + + await test('switching from Hash Stats to another tab drops mbf=', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=hashsizes&mbf=confirmed&window=24h'); + await env.clickTab('topology'); + assert.strictEqual(env.hash(), '#/analytics?tab=topology&window=24h'); + }); + + await test('renderMultiByteAdopters(nodes, caps, filter) marks the filter active; an unknown one is All', async () => { + const env = pageEnv(); + const nodes = REAL.hashData.multiByteNodes, caps = REAL.hashData.multiByteCapability; + const active = (html) => (html.match(/