feat(dashboard): change pages without reloading the dashboard

nav.js (loaded on every signed-in page) follows links and GET forms to
other dashboard pages by fetching the page and swapping in its <main>,
title, page styles and page scripts; the sidebar, top bar and live
WebSocket stay. History entries, back/forward (with scroll positions,
also kept for a reload), deep links, #hash filters and breadcrumbs keep
working. What the old page set up is taken down first: its
document/window listeners, setInterval timers, Live watchers and
topics, DataTables, dialogs and what it appended to <body>. Page
scripts run again in order; DOMContentLoaded/load handlers they add run
once they have all run. A thin bar shows while loading; a failed fetch
shows an inline error with Try again / Open it normally.

Falls back to a normal load for anything that isn't a signed-in
dashboard page, when the account or permissions changed, and for pages
with module scripts or an import map (World 3D, property view and 3D,
UGC server) or data-nav="reload", and when leaving those. Unsaved-change
prompts (beforeunload) are asked before swapping.

Check: click around the sidebar and into accounts/characters: no white
flash, the footer's live indicator stays "live", back/forward return to
the same scroll position, breadcrumbs follow the path (Accounts > an
account > a character). Activity Log links with #search= still filter.
System Log's server picker works. Leaving Settings with a change asks
first. World 3D and a property's 3D view still load (normally). Pages
that poll (Server Health, Instance Load) stop polling once left
(browser network tab). Test: NavRulesJs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 01:57:06 -05:00
parent f13d15b4dd
commit f58255ab8a
10 changed files with 691 additions and 44 deletions

View File

@@ -94,4 +94,7 @@ if(NODE_EXECUTABLE)
# The sidebar's saved group state (the script at the end of templates/header.jinja2)
add_test(NAME SidebarStateJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/sidebar-state.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/templates/header.jinja2")
# Which links the dashboard follows without a reload (static/js/nav.js)
add_test(NAME NavRulesJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/nav.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/nav.js")
endif()

View File

@@ -0,0 +1,65 @@
// Which links the dashboard follows without a reload (static/js/nav.js).
// Run by ctest: node nav.test.mjs <nav.js>
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const [navPath] = process.argv.slice(2);
const window = {};
vm.runInNewContext(readFileSync(navPath, 'utf8'), { window });
const R = window.NavRules;
let failures = 0;
const same = (actual, expected, what) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
failures++;
console.error(`${what}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`);
}
};
const here = new URL('http://127.0.0.1:2006/accounts?x=1');
const kind = (href) => R.kind(new URL(href, here), here);
// Dashboard pages are swapped in
same(kind('/characters'), 'swap', 'a list page');
same(kind('/characters/1234'), 'swap', 'a detail page');
same(kind('/properties/5/3d'), 'swap', 'the 3D view (the fetched page then asks for a normal load)');
same(kind('/system_log?server=world'), 'swap', 'a page with a query');
same(kind('/activity_log#search=12'), 'swap', 'another page with a hash filter');
same(kind('/accounts'), 'swap', 'the same page without its query');
same(kind('/accounts?x=1'), 'swap', 'the page that is open, again');
same(kind('/'), 'swap', 'home');
same(kind('/status'), 'swap', 'server status');
// Only the fragment of this page: the browser does it
same(kind('/accounts?x=1#tab'), 'hash', 'fragment of this page');
same(kind('#tab'), 'hash', 'bare fragment');
// Normal loads
same(kind('https://example.com/characters'), null, 'another site');
same(kind('http://127.0.0.1:2007/characters'), null, 'another port');
same(kind('mailto:a@b.c'), null, 'mailto');
for (const path of ['/api/backups/1/download', '/css/dashboard.css', '/js/nav.js', '/ws', '/metrics', '/login', '/logout',
'/register', '/forgot_password', '/reset_password?token=1', '/verify_email', '/oauth2/callback', '/status/widget', '/ugc/model.nif', '/files/log.zip']) {
same(kind(path), null, path);
}
same(kind('/logins'), 'swap', 'a path that only starts like a signed-out page');
// Pages of one kind share what was learnt about them
same(R.pageKind('/properties/12/3d'), '/properties/*/3d', 'a 3D view');
same(R.pageKind('/characters/1152921504606846978'), '/characters/*', 'a character');
same(R.pageKind('/world3d'), '/world3d', 'no numbers');
// Scripts: classic JavaScript runs again, data and modules don't
for (const type of [null, '', 'text/javascript', 'application/javascript', 'TEXT/JavaScript ', 'application/x-javascript', 'text/ecmascript']) same(R.runnable(type), true, 'runs: ' + type);
for (const type of ['application/json', 'module', 'importmap', 'text/template']) same(R.runnable(type), false, 'inert: ' + type);
// Child lists line up only when every key does
same(R.sameKeys(['DIV#a', '#3', 'P'], ['DIV#a', '#3', 'P']), true, 'same keys');
same(R.sameKeys(['DIV#a', 'P'], ['DIV#a', '#3', 'P']), false, 'one more');
same(R.sameKeys(['DIV#a'], ['DIV#b']), false, 'other id');
same(R.sameKeys([], []), true, 'empty');
if (failures) {
console.error(`${failures} failure(s)`);
process.exit(1);
}
console.log('nav rules: all passed');