Compare commits

...

2 Commits

Author SHA1 Message Date
librelad
86f84a62d3 Merge claude/1 2026-06-12 19:47:41 +01:00
librelad
c05e4af6f0 refactor(webui): move update status from dashboard banner to a topbar pill
The dashboard carried a persistent, full-width "LibrePortal vX.Y.Z" /
up-to-date strip — passive info occupying prime real estate above the
user's actual content, and out of place next to genuinely actionable
cards. Version/update state is chrome, so it now lives in one persistent
pill in the global topbar (every page), with detail + actions behind the
existing modal.

The pill is calm by default and escalates only when warranted:
  * up to date     -> subtle "✓ Up to date"
  * local/dev      -> neutral version chip ("v0.2.0")
  * checking        -> spinning "Checking…"
  * update waiting  -> accented, pulsing-dot "⟳ Update"
Clicking opens the details modal (unchanged) for the full readout and the
Update now / Check for updates actions.

The dashboard update banner is removed. The network-notifier's banner
stays — it's attention-only (shown solely on a real, actionable network
conflict), which is exactly when a dashboard banner earns its place; its
topbar badge now anchors after the pill, and the dashboard data-loader
re-asserts that banner on mount.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-12 19:47:41 +01:00
4 changed files with 119 additions and 196 deletions

View File

@ -207,8 +207,9 @@ async function loadDashboardData() {
// Start countdown to next automatic update
startUpdateCountdown();
// Show/refresh the "out of date" banner on the dashboard.
window.updateNotifier?.renderDashboardBanner();
// Re-assert the network-attention banner now the dashboard DOM is in place
// (it leads the dashboard only when there's a real conflict to act on).
window.networkNotifier?.renderDashboardBanner();
}

View File

@ -4,7 +4,7 @@
// IP no longer falls inside the shared network's real subnet (the "network
// recreated with a different /24, app stranded" case). Two surfaces, shown ONLY
// when there's a real conflict to act on:
// * a badge in the global topbar (after the update badge), and
// * a badge in the global topbar (after the update pill), and
// * a banner on the dashboard.
// Both are driven by /data/system/network_status.json, written host-side by
// webuiSystemNetworkCheck (scripts/webui/data/generators/system/).
@ -108,9 +108,9 @@ class NetworkNotifier {
badge.className = 'network-badge';
badge.type = 'button';
badge.addEventListener('click', () => this.openPanel());
// Sit just after the update badge (if any) so the two coexist in a stable
// order — update first, network second — rather than racing for firstChild.
const anchor = document.getElementById('update-badge');
// Sit just after the update pill so the two coexist in a stable order —
// update first, network second — rather than racing for firstChild.
const anchor = document.getElementById('update-pill');
if (anchor && anchor.parentNode === controls) controls.insertBefore(badge, anchor.nextSibling);
else controls.insertBefore(badge, controls.firstChild);
}
@ -145,10 +145,8 @@ class NetworkNotifier {
if (!banner) {
banner = document.createElement('div');
banner.id = 'network-banner';
// After the update banner if present; else top of the dashboard.
const anchor = document.getElementById('update-banner');
if (anchor && anchor.parentNode === main) main.insertBefore(banner, anchor.nextSibling);
else main.insertBefore(banner, main.firstChild);
// Attention-only, so it leads the dashboard when a conflict is present.
main.insertBefore(banner, main.firstChild);
}
const s = this.status;

View File

@ -2,97 +2,78 @@
Driven by js/update-notifier.js. Colours come from the active
theme tokens (with safe fallbacks) so it tracks every palette. */
/* ---- Topbar badge -------------------------------------------------------- */
/* ---- Topbar pill (persistent update status) ------------------------------ */
.update-badge {
.update-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--status-warning, #e0a106);
border-radius: 6px;
background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.14);
color: var(--status-warning, #e0a106);
padding: 6px 11px;
border: 1px solid transparent;
border-radius: 8px;
font-weight: 600;
font-size: 0.85rem;
font-size: 0.82rem;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
white-space: nowrap;
transition: background 0.18s, border-color 0.18s, color 0.18s, transform 0.1s;
}
.update-badge:hover {
.update-pill svg { flex: 0 0 auto; }
.update-pill:active { transform: scale(0.97); }
/* Calm states up to date, local install, mid-check. Low emphasis: the
version/status sits quietly alongside the other topbar controls. */
.update-pill.is-ok,
.update-pill.is-local,
.update-pill.is-checking {
color: var(--text-secondary, #b8c0cc);
background: var(--surface-bg, rgba(255, 255, 255, 0.04));
border-color: var(--border-color, rgba(255, 255, 255, 0.1));
}
.update-pill.is-ok:hover,
.update-pill.is-local:hover,
.update-pill.is-checking:hover {
background: var(--surface-hover, rgba(255, 255, 255, 0.08));
color: var(--text-primary, #fff);
}
.update-pill.is-ok svg { color: var(--status-success, #2ea043); }
.update-pill.is-local svg,
.update-pill.is-checking svg { color: var(--text-muted, #8a93a3); }
.update-pill.is-checking svg { animation: update-pill-spin 0.9s linear infinite; }
/* Update available — the one loud state: accent border, tint, pulsing dot. */
.update-pill.is-update {
color: var(--status-warning, #e0a106);
background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.14);
border-color: var(--status-warning, #e0a106);
}
.update-pill.is-update:hover {
background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.24);
}
.update-badge:active { transform: scale(0.97); }
.update-badge-dot {
width: 8px;
height: 8px;
.update-pill-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--status-warning, #e0a106);
box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0.6);
animation: update-badge-pulse 2s infinite;
animation: update-pill-pulse 2s infinite;
}
@keyframes update-badge-pulse {
@keyframes update-pill-pulse {
0% { box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0.6); }
70% { box-shadow: 0 0 0 7px rgba(var(--status-warning-rgb, 224, 161, 6), 0); }
100% { box-shadow: 0 0 0 0 rgba(var(--status-warning-rgb, 224, 161, 6), 0); }
}
@keyframes update-pill-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.update-badge-dot { animation: none; }
}
/* ---- Dashboard banner ---------------------------------------------------- */
.update-banner {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 16px 20px;
border: 1px solid var(--status-warning, #e0a106);
border-left-width: 4px;
border-radius: 10px;
background: rgba(var(--status-warning-rgb, 224, 161, 6), 0.1);
color: var(--text-primary, #fff);
}
/* Subtle "up to date" / local-install variant of the banner. */
.update-banner.update-banner-ok {
border-color: var(--border-color, rgba(255, 255, 255, 0.12));
border-left-color: var(--status-success, #2ea043);
background: var(--surface-bg, rgba(255, 255, 255, 0.03));
}
.update-banner.update-banner-ok .update-banner-icon { color: var(--status-success, #2ea043); }
.update-banner.update-banner-ok .update-banner-title { font-weight: 600; }
.update-banner-icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--status-warning, #e0a106);
}
.update-banner-text { flex: 1 1 auto; min-width: 0; }
.update-banner-title { font-weight: 700; font-size: 1rem; }
.update-banner-sub {
margin-top: 2px;
font-size: 0.85rem;
color: var(--text-muted, #9aa);
}
.update-banner-actions {
display: flex;
gap: 8px;
flex: 0 0 auto;
.update-pill-dot { animation: none; }
.update-pill.is-checking svg { animation: none; }
}
/* ---- Shared action buttons ----------------------------------------------- */
@ -229,9 +210,3 @@
gap: 8px;
padding: 18px 20px 20px;
}
@media (max-width: 600px) {
.update-banner { flex-wrap: wrap; }
.update-banner-actions { width: 100%; }
.update-banner-actions button { flex: 1 1 auto; }
}

View File

@ -1,13 +1,16 @@
// Update Notifier
// -----------------------------------------------------------------------------
// Surfaces LibrePortal's "out of date" state across the WebUI:
// * a persistent badge in the global topbar (visible on every page), and
// * a banner on the dashboard,
// both driven by /data/system/update_status.json (written host-side by
// Surfaces LibrePortal's version + "out of date" state in one persistent pill
// in the global topbar (visible on every page). The pill stays calm by default —
// the installed version, or "Up to date" — and only escalates to an accented,
// pulsing "Update" when one is actually waiting. Clicking it opens a details
// modal carrying the full version readout and the update / check actions.
//
// Driven by /data/system/update_status.json (written host-side by
// webuiSystemUpdateCheck — see scripts/webui/data/generators/system/).
//
// The two actions both go through the normal task pipeline so the user can
// watch them stream on the Tasks page, exactly like an app install:
// Both actions go through the normal task pipeline so the user can watch them
// stream on the Tasks page, exactly like an app install:
// * "Update now" -> task `libreportal update apply`
// * "Check for updates" -> task `libreportal update check`
//
@ -21,6 +24,7 @@ class UpdateNotifier {
this.pollMs = 5 * 60 * 1000; // re-read the status file every 5 min
this.pollTimer = null;
this.started = false;
this._checking = false; // a manual "check for updates" is in flight
}
// ---- data ----------------------------------------------------------------
@ -47,8 +51,7 @@ class UpdateNotifier {
async refresh() {
await this.fetchStatus();
this.renderTopbarBadge();
this.renderDashboardBanner();
this.renderTopbarPill();
}
// ---- lifecycle -----------------------------------------------------------
@ -65,7 +68,7 @@ class UpdateNotifier {
// first load regardless of which won the race.
let tries = 0;
const ensure = setInterval(() => {
if (document.querySelector('.topbar-controls')) { this.renderTopbarBadge(); clearInterval(ensure); }
if (document.querySelector('.topbar-controls')) { this.renderTopbarPill(); clearInterval(ensure); }
else if (++tries > 30) clearInterval(ensure); // ~15s ceiling
}, 500);
@ -77,7 +80,7 @@ class UpdateNotifier {
// with the task-refresh coordinator (single source of truth); the debounce
// gives the host a beat to finish writing update_status.json.
window.taskRefresh?.register({
id: 'update-badge',
id: 'update-pill',
match: (d) => d.action === 'update' || d.action === 'system_update'
|| /^libreportal update\b/.test((d.task && d.task.command) || d.command || ''),
run: () => this.refresh(),
@ -87,121 +90,62 @@ class UpdateNotifier {
// Called by TopbarComponent.init() once the topbar DOM exists.
onTopbarReady() {
this.renderTopbarBadge();
this.renderTopbarPill();
this.refresh();
}
// ---- topbar badge --------------------------------------------------------
renderTopbarBadge() {
// ---- topbar pill ---------------------------------------------------------
// One persistent status control in the global topbar, on every page. Calm by
// default (the installed version, or "Up to date"); escalates to an accented,
// pulsing "Update" only when one is actually waiting, and shows a spinner
// while a manual check runs. Clicking it opens the details modal.
renderTopbarPill() {
const controls = document.querySelector('.topbar-controls');
if (!controls) return;
let badge = document.getElementById('update-badge');
const show = this.status && this.status.update_available === true;
if (!show) {
if (badge) badge.remove();
return;
}
if (!badge) {
badge = document.createElement('button');
badge.id = 'update-badge';
badge.className = 'update-badge';
badge.type = 'button';
badge.addEventListener('click', () => this.openPanel());
controls.insertBefore(badge, controls.firstChild);
}
const to = this._versionLabel();
badge.title = `Update available${to ? ' — ' + to : ''}`;
badge.setAttribute('aria-label', badge.title);
badge.innerHTML = `
<span class="update-badge-dot" aria-hidden="true"></span>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
<span class="update-badge-text">Update</span>`;
}
// ---- dashboard banner ----------------------------------------------------
renderDashboardBanner() {
const main = document.querySelector('.dashboard-main');
if (!main) return; // not on the dashboard
let banner = document.getElementById('update-banner');
const s = this.status;
let pill = document.getElementById('update-pill');
// No status file yet (fresh install before the first check) — show nothing.
if (!s) {
if (banner) banner.remove();
return;
// Nothing known yet (fresh install before the first check) — show nothing.
if (!s) { if (pill) pill.remove(); return; }
if (!pill) {
pill = document.createElement('button');
pill.id = 'update-pill';
pill.type = 'button';
pill.addEventListener('click', () => this.openPanel());
controls.insertBefore(pill, controls.firstChild);
}
if (!banner) {
banner = document.createElement('div');
banner.id = 'update-banner';
main.insertBefore(banner, main.firstChild);
}
const refreshIcon = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 2v6h-6"></path><path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path><path d="M3 22v-6h6"></path><path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path></svg>`;
const checkIcon = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`;
const boxIcon = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>`;
const refreshIcon = `
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>`;
const checkIcon = `
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>`;
const ver = (s.current_version && s.current_version !== 'unknown') ? `v${s.current_version}` : '';
const local = s.source === 'local' || s.install_mode === 'local';
if (s.update_available) {
// Prominent: an update is waiting.
const versionLine = this._versionLabel() || `${s.behind} update${s.behind === 1 ? '' : 's'} behind`;
const behindLine = s.behind > 0 ? `${s.behind} commit${s.behind === 1 ? '' : 's'} behind` : '';
banner.className = 'update-banner';
banner.innerHTML = `
<div class="update-banner-icon" aria-hidden="true">${refreshIcon}</div>
<div class="update-banner-text">
<div class="update-banner-title">A LibrePortal update is available</div>
<div class="update-banner-sub">${this._escape(versionLine)}${behindLine ? ` &middot; ${this._escape(behindLine)}` : ''}</div>
</div>
<div class="update-banner-actions">
<button type="button" class="update-btn-secondary" id="update-banner-details">Details</button>
${s.can_update ? '<button type="button" class="update-btn-primary" id="update-banner-update">Update now</button>' : ''}
</div>`;
let state, icon, text, title, dot = '';
if (this._checking) {
state = 'is-checking'; icon = refreshIcon; text = 'Checking…';
title = 'Checking for updates…';
} else if (s.update_available) {
state = 'is-update'; icon = refreshIcon; text = 'Update';
dot = '<span class="update-pill-dot" aria-hidden="true"></span>';
const to = this._versionLabel();
title = `Update available${to ? ' — ' + to : ''}`;
} else if (local) {
// Local/dev install: no remote to check against — show the version plainly.
state = 'is-local'; icon = boxIcon; text = ver || 'Local';
title = `Local installation — updates are managed manually${ver ? ' · ' + ver : ''}`;
} else {
// Subtle: up to date (or a local install). Still gives a version readout
// and a manual "Check for updates" entry point.
const local = s.source === 'local' || s.install_mode === 'local';
const verText = (s.current_version && s.current_version !== 'unknown') ? `LibrePortal v${s.current_version}` : 'LibrePortal';
const subText = local ? 'Local installation — updates are managed manually' : 'Up to date';
banner.className = 'update-banner update-banner-ok';
banner.innerHTML = `
<div class="update-banner-icon" aria-hidden="true">${checkIcon}</div>
<div class="update-banner-text">
<div class="update-banner-title">${this._escape(verText)}</div>
<div class="update-banner-sub">${this._escape(subText)}</div>
</div>
<div class="update-banner-actions">
<button type="button" class="update-btn-secondary" id="update-banner-details">Details</button>
${local ? '' : '<button type="button" class="update-btn-secondary" id="update-banner-check">Check for updates</button>'}
</div>`;
state = 'is-ok'; icon = checkIcon; text = 'Up to date';
title = `LibrePortal${ver ? ' ' + ver : ''} · up to date`;
}
const details = banner.querySelector('#update-banner-details');
if (details) details.addEventListener('click', () => this.openPanel());
const updateBtn = banner.querySelector('#update-banner-update');
if (updateBtn) updateBtn.addEventListener('click', () => this.runUpdate());
const checkBtn = banner.querySelector('#update-banner-check');
if (checkBtn) checkBtn.addEventListener('click', () => this.checkNow());
pill.className = `update-pill ${state}`;
pill.title = title;
pill.setAttribute('aria-label', title);
pill.innerHTML = `${dot}${icon}<span class="update-pill-text">${this._escape(text)}</span>`;
}
// ---- details panel (self-contained modal) --------------------------------
@ -283,12 +227,17 @@ class UpdateNotifier {
}
async checkNow() {
this._checking = true;
this.renderTopbarPill();
try {
await this._createTask('libreportal update check');
this._toast('Checking for updates…', 'info');
// The check task rewrites update_status.json; refresh shortly after.
setTimeout(() => this.refresh(), 4000);
// The check task rewrites update_status.json; clear the spinner and
// re-read shortly after so the pill settles on the fresh state.
setTimeout(() => { this._checking = false; this.refresh(); }, 4000);
} catch (e) {
this._checking = false;
this.renderTopbarPill();
this._toast('Could not check for updates: ' + e.message, 'error');
}
}