mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 06:46:54 +00:00
## 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:
+29
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user