Compare commits
2 Commits
c913be9808
...
86f84a62d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f84a62d3 | ||
|
|
c05e4af6f0 |
@ -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();
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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 ? ` · ${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');
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user