feat(ui): encode view & filter state in URL hash (#749) (#1072)

## Summary

Encodes view + filter state in the URL hash so deep links restore the
exact page state (issue #749).

## Changes

New shared helper `public/url-state.js` exposing `URLState`:
- `parseSort('col:asc')` → `{column, direction}` (defaults to `desc`)
- `serializeSort('col', 'desc')` → `'col'` (omits default direction)
- `parseHash('#/nodes/abc?tab=x')` → `{route: 'nodes/abc', params:
{tab:'x'}}`
- `buildHash(route, params)` and `updateHashParams(updates,
currentHash)` for round-tripping while preserving subpaths.

Wired into:

- **packets.js** — sort column/direction now in
`#/packets?sort=col[:asc]`, restored on init (overrides localStorage).
Subpath `#/packets/<hash>` preserved.
- **nodes.js** — sort encoded as `#/nodes?sort=col[:asc]`, restored on
init. Subpath `#/nodes/<pubkey>` preserved.
- **analytics.js** — both selected tab (`tab=topology`) AND time-window
picker value (`window=7d`) now round-trip via URL. Subview keys used by
rf-health (`range/observer/from/to`) cleared when switching tabs to keep
URLs clean.

Existing deep links (`#/nodes/<pubkey>`, `#/packets/<hash>`,
`?filter=…`, `?node=…`, `?observer=…`, `?channel=…`, `?timeWindow=…`,
`?region=…`) all keep working — additive change only.

## Tests

TDD red→green:
- Red: `5e1482e` (stub throws "not implemented"; 18/18 tests fail on
assertions)
- Green: `512940e` (helper implemented; 18/18 pass)

Wired `test-url-state.js` into `test-all.sh`.

Fixes #749

---------

Co-authored-by: clawbot <clawbot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-05-05 01:17:22 -07:00
committed by GitHub
parent 83881e6b71
commit 8b924cd217
7 changed files with 308 additions and 6 deletions
+29 -3
View File
@@ -109,18 +109,38 @@
// Tab handling
const analyticsTabs = document.getElementById('analyticsTabs');
initTabBar(analyticsTabs);
// #749 — keep analytics tab + window in URL for deep-linking.
function _updateAnalyticsUrl() {
if (!window.URLState) return;
var twElNow = document.getElementById('analyticsTimeWindow');
var updates = {
tab: _currentTab && _currentTab !== 'overview' ? _currentTab : '',
window: twElNow && twElNow.value ? twElNow.value : ''
};
// Drop any subview-specific keys that don't belong to the active tab
// so switching tabs gives a clean URL. (rf-health uses 'range', 'observer', 'from', 'to')
if (_currentTab !== 'rf-health') {
var cleared = ['range', 'observer', 'from', 'to'];
for (var i = 0; i < cleared.length; i++) updates[cleared[i]] = '';
}
var newHash = URLState.updateHashParams(updates, location.hash);
if (newHash !== location.hash) history.replaceState(null, '', newHash);
}
analyticsTabs.addEventListener('click', e => {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_currentTab = btn.dataset.tab;
_updateAnalyticsUrl();
renderTab(_currentTab);
});
// Deep-link: #/analytics?tab=collisions
// Deep-link: #/analytics?tab=collisions&window=7d
const hashParams = location.hash.split('?')[1] || '';
const urlTab = new URLSearchParams(hashParams).get('tab');
const _ap = new URLSearchParams(hashParams);
const urlTab = _ap.get('tab');
if (urlTab) {
const tabBtn = analyticsTabs.querySelector(`[data-tab="${urlTab}"]`);
if (tabBtn) {
@@ -129,6 +149,12 @@
_currentTab = urlTab;
}
}
// #749 — restore time window from URL.
const urlWindow = _ap.get('window');
if (urlWindow) {
const twInit = document.getElementById('analyticsTimeWindow');
if (twInit) twInit.value = urlWindow;
}
RegionFilter.init(document.getElementById('analyticsRegionFilter'));
RegionFilter.onChange(function () { loadAnalytics(); });
@@ -136,7 +162,7 @@
// Time-window picker (#842) — refresh analytics on change.
const tw = document.getElementById('analyticsTimeWindow');
if (tw) {
tw.addEventListener('change', function () { loadAnalytics(); });
tw.addEventListener('change', function () { _updateAnalyticsUrl(); loadAnalytics(); });
}
// Delegated click/keyboard handler for clickable table rows
+1
View File
@@ -95,6 +95,7 @@
<script src="hop-resolver.js?v=__BUST__"></script>
<script src="hop-display.js?v=__BUST__"></script>
<script src="app.js?v=__BUST__"></script>
<script src="url-state.js?v=__BUST__"></script>
<script src="home.js?v=__BUST__"></script>
<script src="table-sort.js?v=__BUST__"></script>
<script src="packet-filter.js?v=__BUST__"></script>
+25 -2
View File
@@ -82,12 +82,26 @@
var parts = [];
if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab));
if (searchStr) parts.push('search=' + encodeURIComponent(searchStr));
// #749 — encode current sort state (default 'last_seen:desc' is omitted).
if (window.URLState) {
var st = _getSortState();
var isDefault = st.column === 'last_seen' && st.direction === 'desc';
if (!isDefault) {
var token = URLState.serializeSort(st.column, st.direction);
if (token) parts.push('sort=' + encodeURIComponent(token));
}
}
return parts.length ? '?' + parts.join('&') : '';
}
window.buildNodesQuery = buildNodesQuery;
function updateNodesUrl() {
history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search));
// Preserve subpath (e.g. #/nodes/<pubkey>) so this doesn't break detail deep-links.
var cur = String(location.hash || '');
var subpath = '';
var m = cur.match(/^#\/nodes(\/[^?]*)?/);
if (m && m[1]) subpath = m[1];
history.replaceState(null, '', '#/nodes' + subpath + buildNodesQuery(activeTab, search));
}
function renderNodeTimestampHtml(isoString) {
@@ -370,6 +384,15 @@
const _urlSearch = _listUrlParams.get('search');
if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab;
if (_urlSearch) search = _urlSearch;
// #749 — restore sort from URL (overrides localStorage persistence).
var _urlSort = _listUrlParams.get('sort');
if (_urlSort && window.URLState) {
var _parsedSort = URLState.parseSort(_urlSort);
if (_parsedSort && _parsedSort.column) {
try { localStorage.setItem('meshcore-nodes-sort', JSON.stringify(_parsedSort)); } catch {}
_fallbackSortState = _parsedSort;
}
}
app.innerHTML = `<div class="nodes-page">
<div class="nodes-topbar">
@@ -1091,7 +1114,7 @@
defaultColumn: 'last_seen',
defaultDirection: 'desc',
storageKey: 'meshcore-nodes-sort',
onSort: function () { renderRows(); }
onSort: function () { renderRows(); updateNodesUrl(); }
});
}
+26 -1
View File
@@ -53,12 +53,25 @@
if (filters.observer) parts.push('observer=' + encodeURIComponent(filters.observer));
if (filters.channel) parts.push('channel=' + encodeURIComponent(filters.channel));
if (filters._filterExpr) parts.push('filter=' + encodeURIComponent(filters._filterExpr));
// Sort state (#749) — encode as 'col[:asc]'; default 'time:desc' is omitted.
if (_packetSortColumn) {
var sortDefault = _packetSortColumn === 'time' && _packetSortDirection === 'desc';
if (!sortDefault && window.URLState) {
var sortToken = URLState.serializeSort(_packetSortColumn, _packetSortDirection);
if (sortToken) parts.push('sort=' + encodeURIComponent(sortToken));
}
}
return parts.length ? '?' + parts.join('&') : '';
}
window.buildPacketsQuery = buildPacketsQuery;
function updatePacketsUrl() {
history.replaceState(null, '', '#/packets' + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam()));
// Preserve any subpath after /packets (e.g. #/packets/<hash>).
var cur = String(location.hash || '');
var subpath = '';
var m = cur.match(/^#\/packets(\/[^?]*)?/);
if (m && m[1]) subpath = m[1];
history.replaceState(null, '', '#/packets' + subpath + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam()));
// Update clear-filters button visibility
var cb = document.getElementById('clearFiltersBtn');
if (cb) {
@@ -366,6 +379,17 @@
if (_urlChannel) filters.channel = _urlChannel;
var _urlFilterExpr = _initUrlParams.get('filter');
if (_urlFilterExpr) filters._filterExpr = _urlFilterExpr;
// #749 — restore sort state from URL (overrides localStorage).
var _urlSort = _initUrlParams.get('sort');
if (_urlSort && window.URLState) {
var _parsed = URLState.parseSort(_urlSort);
if (_parsed) {
_packetSortColumn = _parsed.column;
_packetSortDirection = _parsed.direction;
// Persist so TableSort init picks it up.
try { localStorage.setItem('meshcore-packets-sort', JSON.stringify({ column: _parsed.column, direction: _parsed.direction })); } catch {}
}
}
app.innerHTML = `<div class="split-layout detail-collapsed">
<div class="panel-left" id="pktLeft" aria-live="polite" aria-relevant="additions removals"></div>
@@ -1393,6 +1417,7 @@
_packetSortDirection = direction;
sortPacketsArray();
renderTableRows();
updatePacketsUrl();
}
});
// Apply initial sort state from TableSort
+123
View File
@@ -0,0 +1,123 @@
/* === CoreScope url-state.js ===
*
* Shared helpers for encoding/decoding view & filter state in the URL hash.
* Pages use these so deep links restore the exact view (issue #749).
*
* Hash format: "#/<route>?key1=val1&key2=val2"
*
* Existing deep links remain intact:
* #/nodes/<pubkey> (path segment after route)
* #/packets/<hash> (path segment after route)
* #/packets?filter=... (query after route)
*
* This module ONLY parses/serializes it never mutates location.
*/
'use strict';
(function (root) {
/**
* Parse a sort token "column[:direction]" into { column, direction }.
* Direction defaults to 'desc'. Anything other than 'asc'/'desc' falls back to 'desc'.
* Empty/null input returns null.
*/
function parseSort(s) {
if (s == null || s === '') return null;
var str = String(s);
var idx = str.indexOf(':');
var column = idx >= 0 ? str.slice(0, idx) : str;
var dir = idx >= 0 ? str.slice(idx + 1) : 'desc';
if (dir !== 'asc' && dir !== 'desc') dir = 'desc';
return { column: column, direction: dir };
}
/**
* Serialize a sort state to a token. 'desc' is the default and omitted.
* Empty/null column returns ''.
*/
function serializeSort(column, direction) {
if (!column) return '';
if (direction === 'asc') return column + ':asc';
return String(column);
}
/**
* Parse a location.hash string into { route, params }.
* - Strips leading '#' and '/'.
* - Splits on first '?'; left = route (may include subpath like 'nodes/abc'),
* right = querystring parsed via URLSearchParams.
*/
function parseHash(hash) {
var h = String(hash || '');
if (h.charAt(0) === '#') h = h.slice(1);
if (h.charAt(0) === '/') h = h.slice(1);
if (h === '') return { route: '', params: {} };
var qi = h.indexOf('?');
var route = qi >= 0 ? h.slice(0, qi) : h;
var qs = qi >= 0 ? h.slice(qi + 1) : '';
var params = {};
if (qs) {
var sp = new URLSearchParams(qs);
sp.forEach(function (v, k) { params[k] = v; });
}
return { route: route, params: params };
}
/**
* Build a hash string '#/<route>?k=v&...'. Skips keys with null/undefined/'' values.
* 'route' may be passed as '#/foo', '/foo' or 'foo'.
*/
function buildHash(route, params) {
var r = String(route || '');
if (r.charAt(0) === '#') r = r.slice(1);
if (r.charAt(0) === '/') r = r.slice(1);
var sp = new URLSearchParams();
if (params && typeof params === 'object') {
for (var k in params) {
if (!Object.prototype.hasOwnProperty.call(params, k)) continue;
var v = params[k];
if (v == null || v === '') continue;
sp.set(k, String(v));
}
}
var qs = sp.toString();
return '#/' + r + (qs ? '?' + qs : '');
}
/**
* Apply a partial-update to the params of an existing hash, preserving the route
* (including any subpath like 'nodes/<pubkey>'). Returns the new hash string
* caller decides whether to history.replaceState() it.
*
* Setting a key to '' / null / undefined removes it.
*/
function updateHashParams(updates, currentHash) {
var src = currentHash != null ? currentHash :
(typeof location !== 'undefined' ? location.hash : '');
var parsed = parseHash(src);
var merged = {};
var k;
for (k in parsed.params) {
if (Object.prototype.hasOwnProperty.call(parsed.params, k)) merged[k] = parsed.params[k];
}
if (updates && typeof updates === 'object') {
for (k in updates) {
if (!Object.prototype.hasOwnProperty.call(updates, k)) continue;
var v = updates[k];
if (v == null || v === '') delete merged[k];
else merged[k] = v;
}
}
return buildHash(parsed.route, merged);
}
var api = {
parseSort: parseSort,
serializeSort: serializeSort,
parseHash: parseHash,
buildHash: buildHash,
updateHashParams: updateHashParams,
};
if (typeof module !== 'undefined' && module.exports) module.exports = api;
root.URLState = api;
})(typeof window !== 'undefined' ? window : globalThis);
+1
View File
@@ -12,6 +12,7 @@ echo "── Unit Tests ──"
node test-packet-filter.js
node test-aging.js
node test-frontend-helpers.js
node test-url-state.js
node test-perf-go-runtime.js
node test-channel-psk-ux.js
node test-channel-sidebar-layout.js
+103
View File
@@ -0,0 +1,103 @@
/* Unit tests for URL state helpers (issue #749) */
'use strict';
const assert = require('assert');
const URLState = require('./public/url-state.js');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ✅ ' + name); }
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
}
console.log('── URL State Helpers ──');
// ------- parseSort -------
test('parseSort: column only defaults to desc', function () {
assert.deepStrictEqual(URLState.parseSort('time'), { column: 'time', direction: 'desc' });
});
test('parseSort: column:asc', function () {
assert.deepStrictEqual(URLState.parseSort('lastSeen:asc'), { column: 'lastSeen', direction: 'asc' });
});
test('parseSort: column:desc', function () {
assert.deepStrictEqual(URLState.parseSort('time:desc'), { column: 'time', direction: 'desc' });
});
test('parseSort: invalid direction → desc', function () {
assert.deepStrictEqual(URLState.parseSort('time:weird'), { column: 'time', direction: 'desc' });
});
test('parseSort: empty/null → null', function () {
assert.strictEqual(URLState.parseSort(''), null);
assert.strictEqual(URLState.parseSort(null), null);
assert.strictEqual(URLState.parseSort(undefined), null);
});
// ------- serializeSort -------
test('serializeSort: desc default omitted', function () {
assert.strictEqual(URLState.serializeSort('time', 'desc'), 'time');
});
test('serializeSort: asc included', function () {
assert.strictEqual(URLState.serializeSort('lastSeen', 'asc'), 'lastSeen:asc');
});
test('serializeSort: empty column → empty string', function () {
assert.strictEqual(URLState.serializeSort('', 'desc'), '');
assert.strictEqual(URLState.serializeSort(null, 'asc'), '');
});
// ------- parseHash -------
test('parseHash: bare route', function () {
assert.deepStrictEqual(URLState.parseHash('#/packets'), { route: 'packets', params: {} });
});
test('parseHash: route with params', function () {
var r = URLState.parseHash('#/packets?filter=type%3D%3DADVERT&sort=time');
assert.strictEqual(r.route, 'packets');
assert.strictEqual(r.params.filter, 'type==ADVERT');
assert.strictEqual(r.params.sort, 'time');
});
test('parseHash: route with subpath kept (existing deep links)', function () {
var r = URLState.parseHash('#/nodes/abc123def?tab=repeaters');
assert.strictEqual(r.route, 'nodes/abc123def');
assert.strictEqual(r.params.tab, 'repeaters');
});
test('parseHash: empty hash', function () {
assert.deepStrictEqual(URLState.parseHash(''), { route: '', params: {} });
assert.deepStrictEqual(URLState.parseHash('#/'), { route: '', params: {} });
});
// ------- buildHash -------
test('buildHash: bare route', function () {
assert.strictEqual(URLState.buildHash('packets', {}), '#/packets');
});
test('buildHash: with params, omits empty values', function () {
var h = URLState.buildHash('packets', { filter: 'type==ADVERT', sort: '', empty: null, blank: undefined });
assert.strictEqual(h, '#/packets?filter=type%3D%3DADVERT');
});
test('buildHash: encodes special chars', function () {
var h = URLState.buildHash('analytics', { tab: 'topology', window: '7d' });
// Order is preserved in object iteration
assert.ok(h === '#/analytics?tab=topology&window=7d' || h === '#/analytics?window=7d&tab=topology');
});
test('buildHash: leading "#/" is OK on route, normalized', function () {
assert.strictEqual(URLState.buildHash('#/packets', { sort: 'time' }), '#/packets?sort=time');
});
// ------- updateHashParams -------
test('updateHashParams: round-trip preserves route subpath', function () {
// Simulate location.hash environment
var fakeLocation = { hash: '#/nodes/abcdef?tab=repeaters' };
var newHash = URLState.updateHashParams({ sort: 'lastSeen:asc' }, fakeLocation.hash);
// Must keep the nodes/abcdef subpath
var r = URLState.parseHash(newHash);
assert.strictEqual(r.route, 'nodes/abcdef');
assert.strictEqual(r.params.tab, 'repeaters');
assert.strictEqual(r.params.sort, 'lastSeen:asc');
});
test('updateHashParams: setting empty/null removes key', function () {
var newHash = URLState.updateHashParams({ tab: '' }, '#/nodes?tab=repeaters&search=foo');
var r = URLState.parseHash(newHash);
assert.strictEqual(r.params.tab, undefined);
assert.strictEqual(r.params.search, 'foo');
});
console.log('');
console.log(passed + ' passed, ' + failed + ' failed');
process.exit(failed > 0 ? 1 : 0);