@@ -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);