fix(webui/admin): paint page header immediately, confine spinner to content

The Admin Dashboard (OverviewPage) and Admin System (SystemPage) boards each
blanked the whole content pane to a bare headerless lpLoadingBox() on init(),
then rendered the .page-header + panel only once data arrived. So clicking
either tab flashed a full-pane spinner with no indication of which page you'd
landed on, then everything appeared at once.

Extract the page header into a _headerHtml() helper on each page and render it
up front in both the loading state and render(). Now navigating to a board
paints its header (breadcrumb + title + description) instantly and the spinner
is confined to the .admin-panel beneath it — the header stays put and only the
content swaps in when the fetch resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-07-05 20:33:21 +01:00
parent d96a9646bc
commit 6dd729d0ac
2 changed files with 42 additions and 19 deletions

View File

@ -14,7 +14,10 @@ class OverviewPage {
async init() {
const r = this.root();
if (r) r.innerHTML = '<div class="admin-page">' + lpLoadingBox() + '</div>';
// Paint the header immediately and confine the spinner to the content
// panel below it, rather than blanking the whole pane to a headerless
// loader while the board's data loads.
if (r) r.innerHTML = `<div class="admin-page">${this._headerHtml()}<div class="admin-panel">${lpLoadingBox()}</div></div>`;
this.bindEvents();
const [upd, verify, backup, ssh, disk, mem, info] = await Promise.all([
this.fetchJson('/data/system/update_status.json'),
@ -156,6 +159,22 @@ class OverviewPage {
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
}
// The page header (breadcrumb + title + description) — rendered up front on
// the loading state AND by render(), so navigating here paints the header
// immediately and the spinner is confined to the content panel below it,
// instead of the whole pane blanking to a headerless loader.
_headerHtml() {
return `
<div class="page-header config-page-header">
<div class="page-header-icon-slot"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"></rect><rect x="14" y="3" width="7" height="5"></rect><rect x="14" y="12" width="7" height="9"></rect><rect x="3" y="16" width="7" height="5"></rect></svg></div>
<div class="page-header-title">
<div class="admin-breadcrumb">Admin</div>
<h1>Dashboard</h1>
<p>System health and admin status at a glance. Manage anything from the cards below.</p>
</div>
</div>`;
}
render() {
const root = this.root();
if (!root) return;
@ -235,14 +254,7 @@ class OverviewPage {
root.innerHTML = `
<div class="admin-page">
<div class="page-header config-page-header">
<div class="page-header-icon-slot"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"></rect><rect x="14" y="3" width="7" height="5"></rect><rect x="14" y="12" width="7" height="9"></rect><rect x="3" y="16" width="7" height="5"></rect></svg></div>
<div class="page-header-title">
<div class="admin-breadcrumb">Admin</div>
<h1>Dashboard</h1>
<p>System health and admin status at a glance. Manage anything from the cards below.</p>
</div>
</div>
${this._headerHtml()}
<div class="admin-panel">
<div class="admin-card-grid">
${updCard}

View File

@ -83,8 +83,10 @@ class SystemPage {
return;
}
// Default: index view.
r.innerHTML = '<div class="admin-page">' + lpLoadingBox('Loading system stats…') + '</div>';
// Default: index view. Paint the header immediately and keep the spinner
// inside the content panel, so the page reads as "System, loading its
// stats" rather than a blank headerless loader.
r.innerHTML = `<div class="admin-page sys-page">${this._headerHtml('—')}<div class="admin-panel">${lpLoadingBox('Loading system stats…')}</div></div>`;
await this.refresh();
this.bind();
if (window.LiveSystem) {
@ -257,6 +259,22 @@ class SystemPage {
if (subEl) subEl.textContent = `Live host and per-app statistics. Updated ${new Date(s.t || Date.now()).toLocaleTimeString()}.`;
}
// The page header (breadcrumb + title + description) — rendered up front on
// the loading state AND by render(), so navigating here paints the header
// immediately and the spinner is confined to the content panel below it,
// instead of the whole pane blanking to a headerless loader.
_headerHtml(updated = '—') {
return `
<div class="page-header config-page-header">
<div class="page-header-icon-slot"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg></div>
<div class="page-header-title">
<div class="admin-breadcrumb">Admin</div>
<h1>System</h1>
<p>Live host and per-app statistics. Updated ${updated}.</p>
</div>
</div>`;
}
render() {
const root = this.root();
if (!root) return;
@ -328,14 +346,7 @@ class SystemPage {
root.innerHTML = `
<div class="admin-page sys-page">
<div class="page-header config-page-header">
<div class="page-header-icon-slot"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg></div>
<div class="page-header-title">
<div class="admin-breadcrumb">Admin</div>
<h1>System</h1>
<p>Live host and per-app statistics. Updated ${updated}.</p>
</div>
</div>
${this._headerHtml(updated)}
<div class="admin-panel">
${gauges}
${charts}