diff --git a/public/analytics.js b/public/analytics.js index 499464c8..1c627d52 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -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 diff --git a/public/index.html b/public/index.html index 989eb694..5060c278 100644 --- a/public/index.html +++ b/public/index.html @@ -95,6 +95,7 @@ + diff --git a/public/nodes.js b/public/nodes.js index 48047591..d1397df1 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -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/) 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 = `
@@ -1091,7 +1114,7 @@ defaultColumn: 'last_seen', defaultDirection: 'desc', storageKey: 'meshcore-nodes-sort', - onSort: function () { renderRows(); } + onSort: function () { renderRows(); updateNodesUrl(); } }); } diff --git a/public/packets.js b/public/packets.js index dfb67840..37f834b8 100644 --- a/public/packets.js +++ b/public/packets.js @@ -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/). + 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 = `
@@ -1393,6 +1417,7 @@ _packetSortDirection = direction; sortPacketsArray(); renderTableRows(); + updatePacketsUrl(); } }); // Apply initial sort state from TableSort diff --git a/public/url-state.js b/public/url-state.js new file mode 100644 index 00000000..31e56acb --- /dev/null +++ b/public/url-state.js @@ -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: "#/?key1=val1&key2=val2" + * + * Existing deep links remain intact: + * #/nodes/ (path segment after route) + * #/packets/ (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 '#/?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/'). 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); diff --git a/test-all.sh b/test-all.sh index 4c079541..e3ec8182 100755 --- a/test-all.sh +++ b/test-all.sh @@ -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 diff --git a/test-url-state.js b/test-url-state.js new file mode 100644 index 00000000..371b3ae9 --- /dev/null +++ b/test-url-state.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);