Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions public/packets.js
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,10 @@
// lose their 120px cap; hops past the column edge go behind the +N pill).
let showFullNames = localStorage.getItem('meshcore-full-names') === 'true';
let _colFit = null; // fitColumnsToContent controller for #pktTable
var _pendingUrlRegion = null;
// Region/area filter listeners, removed in destroy() so a region change on
// another page doesn't reload packets and rewrite the URL to #/packets.
let _regionChangeHandler = null;
let _areaChangeHandler = null;

var DEFAULT_TIME_WINDOW = 15;

Expand Down Expand Up @@ -1337,8 +1340,6 @@
savedTimeWindowMin = _urlTimeWindow;
localStorage.setItem('meshcore-time-window', String(_urlTimeWindow));
}
var _urlRegion = _initUrlParams.get('region');
if (_urlRegion) _pendingUrlRegion = _urlRegion;
var _urlHash = _initUrlParams.get('hash');
if (_urlHash) filters.hash = _urlHash;
var _urlNode = _initUrlParams.get('node');
Expand Down Expand Up @@ -1601,6 +1602,8 @@
if (_docActionHandler) { document.removeEventListener('click', _docActionHandler); _docActionHandler = null; }
if (_docMenuCloseHandler) { document.removeEventListener('click', _docMenuCloseHandler); _docMenuCloseHandler = null; }
if (_docColMenuCloseHandler) { document.removeEventListener('click', _docColMenuCloseHandler); _docColMenuCloseHandler = null; }
if (_regionChangeHandler) { RegionFilter.offChange(_regionChangeHandler); _regionChangeHandler = null; }
if (_areaChangeHandler) { AreaFilter.offChange(_areaChangeHandler); _areaChangeHandler = null; }
removeAllByopOverlays();
packets = [];
hashIndex = new Map(); selectedId = null;
Expand Down Expand Up @@ -1886,15 +1889,11 @@
</table></div>
`;

// Init shared RegionFilter component
// Init shared RegionFilter component (it applies a ?region= URL param itself)
RegionFilter.init(document.getElementById('packetsRegionFilter'), { dropdown: true });
AreaFilter.init(document.getElementById('packetsAreaFilter'));
if (_pendingUrlRegion) {
RegionFilter.setSelected(_pendingUrlRegion.split(',').filter(Boolean));
_pendingUrlRegion = null;
}
RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });
AreaFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });
_regionChangeHandler = RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });
_areaChangeHandler = AreaFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });

// --- Packet Filter Language ---
(function() {
Expand Down
41 changes: 40 additions & 1 deletion public/region-filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
}

_selected = loadFromStorage();
// A ?region= link applies before any page reads the selection, so even a
// page that fetches before calling init() uses it on a fresh load.
applyUrlRegion();

/** Fetch the configured quick picks. A failure only means no quick picks. */
async function fetchQuickPicks() {
Expand Down Expand Up @@ -295,13 +298,49 @@
_listeners = _listeners.filter(function (f) { return f !== fn; });
}

/** Region codes from a ?region= hash param ("#/live?region=SJC,SFO"), or
* null when the URL doesn't set one. */
function regionsFromUrl() {
if (typeof location === 'undefined' || !location.hash) return null;
var raw = new URLSearchParams(location.hash.split('?')[1] || '').get('region');
if (!raw) return null;
var codes = raw.split(',').map(function (c) { return c.trim(); }).filter(Boolean);
return codes.length > 0 ? codes : null;
}

/** Apply a ?region= URL param as the selection, saved like a click in the
* control. Returns true when that changed the selection. */
function applyUrlRegion() {
var codes = regionsFromUrl();
if (!codes) return false;
// Once regions have loaded, fetchRegions() no longer cleans the selection,
// so drop unknown codes here; with none left, that's all regions, the same
// as the cleanup does on a fresh load.
if (_loaded) codes = codes.filter(function (c) { return Object.prototype.hasOwnProperty.call(_regions, c); });
var next = codes.length > 0 ? new Set(codes) : null;
var changed = !sameSelection(_selected, next);
_selected = next;
saveToStorage();
return changed;
}

/** Initialize filter in a container, fetch regions, render, return promise.
* Options: { dropdown: true } to force dropdown mode regardless of region count */
* Options: { dropdown: true } to force dropdown mode regardless of region count
*
* A ?region= param in the page's URL overrides the saved selection, so any
* page with a region filter can be deep-linked to a region. On a fresh load
* it was already applied when this script ran. On in-app navigation it's
* applied here, before the await, so a page that loads after init() uses
* it; if it changed the selection, listeners are told once regions load,
* which reloads a page that fetched before calling init(). Unknown codes
* are dropped either way. */
async function initFilter(container, opts) {
_container = container;
if (opts && opts.dropdown) container._forceDropdown = true;
var urlChanged = applyUrlRegion();
await fetchRegions();
render(container);
if (urlChanged) _listeners.forEach(function (fn) { fn(getSelected()); });
}

/** True when two selections (Set or null = all) hold the same codes. */
Expand Down
1 change: 1 addition & 0 deletions test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ node tests/unit/test-preflight-xss-gate.js
node tests/unit/test-pull-to-reconnect-1091.js
node tests/unit/test-pull-to-reconnect.js
node tests/unit/test-region-filter-setselected.js
node tests/unit/test-region-filter-url-param.js
node tests/unit/test-repeater-metric-scatter.js
node tests/unit/test-rx-coverage-config-race.js
node tests/unit/test-rx-coverage-mine.js
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test-issue-2012-clear-filters-selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ test('#2091: list query builder never inherits detail observation', () => {
test('#2091: init restores obs from the hash after router strips the query', () => {
const readInitialObservation = new Function('location', 'routeParam',
'let directObsId = "stale", directPacketId = null, directPacketHash = null; ' +
'let savedTimeWindowMin = 15, _pendingUrlRegion = null; const filters = {}, window = {}; ' +
'let savedTimeWindowMin = 15; const filters = {}, window = {}; ' +
'let showFullNames = false; const localStorage = { getItem: () => null, setItem() {} }; ' +
hashParamsSrc + '\n' + initParamsSrc + '\nreturn directObsId;');
for (const route of ['aabbccddeeff0011', 'id/42']) {
Expand Down
185 changes: 185 additions & 0 deletions tests/unit/test-region-filter-url-param.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* RegionFilter reads ?region= from the page's hash URL.
*
* Only the packets page honored ?region= (with its own one-off code); every
* other page with a region filter (live, nodes, observers, channels,
* analytics, scope audit) ignored it, so a link like #/live?region=SJC opened
* with whatever region the visitor last picked. RegionFilter now applies the
* param itself: it overrides the saved selection and is saved like a click in
* the control. On a fresh load it's in place when the script runs, before any
* page reads it (live fetches nodes before calling init). On in-app
* navigation init() applies it and, if that changed the selection, notifies
* listeners so a page that already fetched reloads. Unknown codes are dropped
* once regions load.
*/
'use strict';
const REPO_ROOT = require('node:path').resolve(__dirname, '..', '..');
const vm = require('node:vm');
const fs = require('node:fs');
const assert = require('node:assert');

let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); passed++; console.log(` ✓ ${name}`); }
catch (e) { failed++; console.error(` ✗ ${name}: ${e.message}`); }
}

const REGIONS = { SJC: 'San Jose', SFO: 'San Francisco', OAK: 'Oakland' };

function buildCtx(hash, initialStorage) {
const storage = Object.assign(Object.create(null), initialStorage || {});
const ctx = {
window: {},
location: { hash: hash || '' },
document: { addEventListener() {}, removeEventListener() {} },
localStorage: {
getItem: (k) => (k in storage ? storage[k] : null),
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; },
},
fetch: async (url) => ({ json: async () => (url === '/api/config/regions' ? REGIONS : {}) }),
URLSearchParams, console, setTimeout, clearTimeout,
};
ctx.window = ctx;
vm.createContext(ctx);
vm.runInContext(fs.readFileSync(REPO_ROOT + '/public/region-filter.js', 'utf8'), ctx);
return { rf: ctx.window.RegionFilter, storage, location: ctx.location };
}

function container() {
return { innerHTML: '', style: {}, querySelector: () => null, contains: () => false };
}

(async () => {
console.log('RegionFilter applies a ?region= URL param');

await test('URL region overrides the saved selection', async () => {
const { rf } = buildCtx('#/live?region=SJC', { 'meshcore-region-filter': '["SFO"]' });
await rf.init(container());
assert.deepStrictEqual(Array.from(rf.getSelected()), ['SJC']);
});

await test('comma-separated codes are all selected, whitespace trimmed', async () => {
const { rf } = buildCtx('#/nodes?region=SJC,%20SFO');
await rf.init(container());
assert.deepStrictEqual(Array.from(rf.getSelected()), ['SJC', 'SFO']);
});

await test('fresh load: selection is in place before init is even called', () => {
const { rf } = buildCtx('#/live?region=SFO', { 'meshcore-region-filter': '["SJC"]' });
assert.deepStrictEqual(Array.from(rf.getSelected()), ['SFO']);
assert.strictEqual(rf.regionQueryString(), '&region=SFO');
});

await test('fresh load: init does not notify (nothing changed since the script ran)', async () => {
const { rf } = buildCtx('#/live?region=SFO');
const calls = [];
const p = rf.init(container());
rf.onChange((sel) => calls.push(sel && Array.from(sel)));
await p;
assert.deepStrictEqual(calls, []);
});

await test('in-app navigation: a changed region notifies listeners once regions load', async () => {
const { rf, location } = buildCtx('#/packets', { 'meshcore-region-filter': '["SJC"]' });
location.hash = '#/live?region=OAK';
const calls = [];
// live registers its listener right after calling init(), without awaiting
const p = rf.init(container());
rf.onChange((sel) => calls.push(sel && Array.from(sel)));
assert.deepStrictEqual(Array.from(rf.getSelected()), ['OAK']);
await p;
assert.deepStrictEqual(calls, [['OAK']]);
});

await test('in-app navigation: the same region as already selected does not notify', async () => {
const { rf, location } = buildCtx('#/packets', { 'meshcore-region-filter': '["OAK"]' });
location.hash = '#/live?region=OAK';
const calls = [];
const p = rf.init(container());
rf.onChange((sel) => calls.push(sel && Array.from(sel)));
await p;
assert.deepStrictEqual(calls, []);
});

await test('selection is in place before init resolves (callers do not await it)', () => {
const { rf } = buildCtx('#/nodes?region=OAK');
rf.init(container());
assert.deepStrictEqual(Array.from(rf.getSelected()), ['OAK']);
assert.strictEqual(rf.regionQueryString(), '&region=OAK');
});

await test('URL selection is saved like a click in the control', async () => {
const { rf, storage } = buildCtx('#/live?region=SFO');
await rf.init(container());
assert.strictEqual(storage['meshcore-region-filter'], '["SFO"]');
});

await test('unknown codes are dropped once regions load', async () => {
const { rf } = buildCtx('#/live?region=SJC,XXX');
await rf.init(container());
assert.deepStrictEqual(Array.from(rf.getSelected()), ['SJC']);
});

// fetchRegions() only cleans the selection on its first call, so on in-app
// navigation applyUrlRegion() must drop unknown codes itself.
await test('in-app navigation: unknown codes are dropped', async () => {
const { rf, location, storage } = buildCtx('#/packets');
await rf.init(container());
location.hash = '#/nodes?region=SJC,XXX';
await rf.init(container());
assert.deepStrictEqual(Array.from(rf.getSelected()), ['SJC']);
assert.strictEqual(rf.regionQueryString(), '&region=SJC');
assert.strictEqual(storage['meshcore-region-filter'], '["SJC"]');
});

await test('in-app navigation: only unknown codes means all regions', async () => {
const { rf, location, storage } = buildCtx('#/packets', { 'meshcore-region-filter': '["SFO"]' });
await rf.init(container());
location.hash = '#/nodes?region=XXX';
await rf.init(container());
assert.strictEqual(rf.getSelected(), null);
assert.strictEqual(rf.regionQueryString(), '');
assert.ok(!('meshcore-region-filter' in storage));
});

await test('no region param keeps the saved selection', async () => {
const { rf } = buildCtx('#/live?node=abc', { 'meshcore-region-filter': '["SFO"]' });
await rf.init(container());
assert.deepStrictEqual(Array.from(rf.getSelected()), ['SFO']);
});

await test('an empty region param keeps the saved selection', async () => {
const { rf } = buildCtx('#/live?region=', { 'meshcore-region-filter': '["SFO"]' });
await rf.init(container());
assert.deepStrictEqual(Array.from(rf.getSelected()), ['SFO']);
});

await test('no hash at all keeps "all regions"', async () => {
const { rf } = buildCtx('');
await rf.init(container());
assert.strictEqual(rf.getSelected(), null);
});

// packets.js registered its region/area listeners without keeping a handle,
// so they outlived the page: after visiting packets, a region change on any
// other page reloaded packets and replaceState'd the URL to #/packets. The
// in-app notify above makes that fire on every region link, so packets must
// remove both listeners in destroy().
console.log('packets.js removes its region/area listeners on destroy');
const pktSrc = fs.readFileSync(REPO_ROOT + '/public/packets.js', 'utf8');
const destroySrc = pktSrc.slice(pktSrc.indexOf(' function destroy() {'), pktSrc.indexOf('\n }\n', pktSrc.indexOf(' function destroy() {')));

await test('listeners are registered with a kept handle', () => {
assert.match(pktSrc, /_regionChangeHandler = RegionFilter\.onChange\(/);
assert.match(pktSrc, /_areaChangeHandler = AreaFilter\.onChange\(/);
});

await test('destroy() removes both listeners', () => {
assert.match(destroySrc, /RegionFilter\.offChange\(_regionChangeHandler\)/);
assert.match(destroySrc, /AreaFilter\.offChange\(_areaChangeHandler\)/);
});

console.log(`\n${passed} passed, ${failed} failed`);
if (failed) process.exit(1);
})();
Loading