Post-task UI refresh was scattered: every page added its own taskCompleted listener and hard-coded which actions it cared about, so it was easy to add a task and forget the refresh (stale UI), with no single place to see the wiring. Adds TaskRefreshCoordinator (window.taskRefresh): one listener, with dedupe (the SSE bus + synthetic fallbacks double-fire) and opt-in debounce (bursts coalesce; per-task handlers run every time). Components now register a refresh entry; window.taskRefresh.table() is the introspectable "what reloads when" map. Migrated onto it: apps (install/uninstall/tool/config_update lifecycle + restore/update/rebuild state), backups (backup/restore/delete), the update badge, and the admin overview integrity badge. Gaps closed: restore/update/ rebuild now repaint app+service data. (start/stop/restart intentionally omitted — no live status surface to refresh today; revisit if a running/stopped badge is added. Storage reclaim/image-rm keep their own in-page refresh.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
368 lines
15 KiB
JavaScript
368 lines
15 KiB
JavaScript
// 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
|
||
// 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:
|
||
// * "Update now" -> task `libreportal update apply`
|
||
// * "Check for updates" -> task `libreportal update check`
|
||
//
|
||
// This file owns no detection logic of its own. When get.libreportal.org is
|
||
// wired up, only the host-side generator changes; this stays as-is.
|
||
|
||
class UpdateNotifier {
|
||
constructor() {
|
||
this.status = null;
|
||
this.fetching = null; // de-dupe concurrent fetches
|
||
this.pollMs = 5 * 60 * 1000; // re-read the status file every 5 min
|
||
this.pollTimer = null;
|
||
this.started = false;
|
||
}
|
||
|
||
// ---- data ----------------------------------------------------------------
|
||
|
||
async fetchStatus() {
|
||
if (this.fetching) return this.fetching;
|
||
this.fetching = (async () => {
|
||
try {
|
||
const [s, v] = await Promise.all([
|
||
fetch('/data/system/update_status.json', { cache: 'no-store' }).then(r => r.ok ? r.json() : null).catch(() => null),
|
||
fetch('/data/system/verify_status.json', { cache: 'no-store' }).then(r => r.ok ? r.json() : null).catch(() => null),
|
||
]);
|
||
if (s !== null) this.status = s; // keep last-good on a failed fetch
|
||
if (v !== null) this.verify = v;
|
||
return this.status;
|
||
} catch {
|
||
return null;
|
||
} finally {
|
||
this.fetching = null;
|
||
}
|
||
})();
|
||
return this.fetching;
|
||
}
|
||
|
||
async refresh() {
|
||
await this.fetchStatus();
|
||
this.renderTopbarBadge();
|
||
this.renderDashboardBanner();
|
||
}
|
||
|
||
// ---- lifecycle -----------------------------------------------------------
|
||
|
||
start() {
|
||
if (this.started) return;
|
||
this.started = true;
|
||
|
||
this.refresh();
|
||
|
||
// The topbar HTML and this script load independently; if the topbar
|
||
// mounted first, the authoritative onTopbarReady() call already no-op'd.
|
||
// Briefly retry until .topbar-controls exists so the badge appears on
|
||
// first load regardless of which won the race.
|
||
let tries = 0;
|
||
const ensure = setInterval(() => {
|
||
if (document.querySelector('.topbar-controls')) { this.renderTopbarBadge(); clearInterval(ensure); }
|
||
else if (++tries > 30) clearInterval(ensure); // ~15s ceiling
|
||
}, 500);
|
||
|
||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||
this.pollTimer = setInterval(() => this.refresh(), this.pollMs);
|
||
|
||
// Re-read the status as soon as an update task finishes so the badge clears
|
||
// (or the version updates) without waiting for the next poll. Registered
|
||
// 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',
|
||
match: (d) => d.action === 'update' || d.action === 'system_update'
|
||
|| /^libreportal update\b/.test((d.task && d.task.command) || d.command || ''),
|
||
run: () => this.refresh(),
|
||
debounceMs: 1500,
|
||
});
|
||
}
|
||
|
||
// Called by TopbarComponent.init() once the topbar DOM exists.
|
||
onTopbarReady() {
|
||
this.renderTopbarBadge();
|
||
this.refresh();
|
||
}
|
||
|
||
// ---- topbar badge --------------------------------------------------------
|
||
|
||
renderTopbarBadge() {
|
||
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;
|
||
|
||
// No status file yet (fresh install before the first check) — show nothing.
|
||
if (!s) {
|
||
if (banner) banner.remove();
|
||
return;
|
||
}
|
||
|
||
if (!banner) {
|
||
banner = document.createElement('div');
|
||
banner.id = 'update-banner';
|
||
main.insertBefore(banner, main.firstChild);
|
||
}
|
||
|
||
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>`;
|
||
|
||
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>`;
|
||
} 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>`;
|
||
}
|
||
|
||
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());
|
||
}
|
||
|
||
// ---- details panel (self-contained modal) --------------------------------
|
||
|
||
openPanel() {
|
||
this.closePanel();
|
||
const s = this.status || {};
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'update-panel-overlay';
|
||
overlay.className = 'update-panel-overlay';
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.closePanel(); });
|
||
|
||
const local = s.source === 'local' || s.install_mode === 'local';
|
||
const rows = [
|
||
['Installed version', s.current_version || 'unknown'],
|
||
['Latest version', s.latest_version || 'unknown'],
|
||
['Commits behind', (s.behind ?? 0).toString()],
|
||
['Branch', s.branch || '—'],
|
||
['Last checked', this._formatTime(s.checked_at)],
|
||
];
|
||
const integrity = this._integrityLabel();
|
||
if (integrity) rows.splice(2, 0, ['Integrity', integrity]);
|
||
|
||
const statusLine = local
|
||
? 'This is a local installation — updates are managed manually on the host.'
|
||
: (s.update_available
|
||
? 'An update is available.'
|
||
: 'LibrePortal is up to date.');
|
||
|
||
overlay.innerHTML = `
|
||
<div class="update-panel" role="dialog" aria-modal="true" aria-label="LibrePortal updates">
|
||
<div class="update-panel-header">
|
||
<h3>LibrePortal Updates</h3>
|
||
<button type="button" class="update-panel-close" id="update-panel-close" aria-label="Close">×</button>
|
||
</div>
|
||
<div class="update-panel-status ${s.update_available ? 'is-outdated' : ''}">${this._escape(statusLine)}</div>
|
||
${s.error ? `<div class="update-panel-error">${this._escape(s.error)}</div>` : ''}
|
||
<dl class="update-panel-rows">
|
||
${rows.map(([k, v]) => `<div class="update-panel-row"><dt>${this._escape(k)}</dt><dd>${this._escape(v)}</dd></div>`).join('')}
|
||
</dl>
|
||
${s.can_update && s.update_available ? '<p class="update-panel-note">Updating backs up your configuration, pulls the latest version, and restarts LibrePortal. Progress streams on the Tasks page.</p>' : ''}
|
||
<div class="update-panel-actions">
|
||
<button type="button" class="update-btn-secondary" id="update-panel-check">Check for updates</button>
|
||
${s.can_update && s.update_available ? '<button type="button" class="update-btn-primary" id="update-panel-update">Update now</button>' : ''}
|
||
</div>
|
||
</div>`;
|
||
|
||
document.body.appendChild(overlay);
|
||
overlay.querySelector('#update-panel-close').addEventListener('click', () => this.closePanel());
|
||
overlay.querySelector('#update-panel-check').addEventListener('click', () => this.checkNow());
|
||
const upd = overlay.querySelector('#update-panel-update');
|
||
if (upd) upd.addEventListener('click', () => this.runUpdate());
|
||
|
||
this._escHandler = (e) => { if (e.key === 'Escape') this.closePanel(); };
|
||
document.addEventListener('keydown', this._escHandler);
|
||
}
|
||
|
||
closePanel() {
|
||
const overlay = document.getElementById('update-panel-overlay');
|
||
if (overlay) overlay.remove();
|
||
if (this._escHandler) {
|
||
document.removeEventListener('keydown', this._escHandler);
|
||
this._escHandler = null;
|
||
}
|
||
}
|
||
|
||
// ---- actions -------------------------------------------------------------
|
||
|
||
async runUpdate() {
|
||
this.closePanel();
|
||
try {
|
||
await this._createTask('libreportal update apply');
|
||
this._toast('LibrePortal update started — follow progress on the Tasks page.', 'info');
|
||
this._goToTasks();
|
||
} catch (e) {
|
||
this._toast('Could not start the update: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async checkNow() {
|
||
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);
|
||
} catch (e) {
|
||
this._toast('Could not check for updates: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ---- helpers -------------------------------------------------------------
|
||
|
||
async _createTask(command) {
|
||
if (window.tasksManager?.taskManager?.createTask) {
|
||
return window.tasksManager.taskManager.createTask(command, 'update', null, '');
|
||
}
|
||
if (typeof TaskManager !== 'undefined') {
|
||
return new TaskManager().createTask(command, 'update', null, '');
|
||
}
|
||
const res = await fetch('/api/tasks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ command, type: 'update', app: null, config: '' })
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
_goToTasks() {
|
||
if (window.librePortalSPA?.navigate) window.librePortalSPA.navigate('/tasks');
|
||
else if (typeof navigateToRoute === 'function') navigateToRoute('/tasks');
|
||
else window.location.href = '/tasks';
|
||
}
|
||
|
||
_toast(message, type = 'info') {
|
||
const ns = window.notificationSystem || window.ensureNotificationSystem?.();
|
||
if (ns?.show) ns.show(message, type);
|
||
else console.log(`[update] ${message}`);
|
||
}
|
||
|
||
_integrityLabel() {
|
||
switch (this.verify && this.verify.state) {
|
||
case 'verified': return 'Verified · matches signed release';
|
||
case 'modified': return `Modified (${this.verify.files_modified || 0} changed)`;
|
||
case 'tampered': return 'Signature invalid';
|
||
case 'unsigned': return 'Unsigned build';
|
||
case 'unverifiable': return 'Can’t verify';
|
||
case 'development': return 'Development build';
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
_versionLabel() {
|
||
const s = this.status;
|
||
if (!s) return '';
|
||
const cur = s.current_version, latest = s.latest_version;
|
||
if (cur && latest && cur !== latest && latest !== 'unknown') return `v${cur} → v${latest}`;
|
||
if (latest && latest !== 'unknown') return `v${latest}`;
|
||
return '';
|
||
}
|
||
|
||
_formatTime(iso) {
|
||
if (!iso) return '—';
|
||
const d = new Date(iso);
|
||
if (isNaN(d.getTime())) return '—';
|
||
return d.toLocaleString();
|
||
}
|
||
|
||
_escape(str) {
|
||
return String(str).replace(/[&<>"']/g, (c) => (
|
||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||
));
|
||
}
|
||
}
|
||
|
||
window.updateNotifier = window.updateNotifier || new UpdateNotifier();
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => window.updateNotifier.start());
|
||
} else {
|
||
window.updateNotifier.start();
|
||
}
|