// 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 res = await fetch('/data/system/update_status.json', { cache: 'no-store' }); if (!res.ok) return null; this.status = await res.json(); 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/check task finishes so the badge // clears (or the version updates) without waiting for the next poll. const onTask = (event) => { const cmd = event?.detail?.command || event?.detail?.task?.command || ''; const action = event?.detail?.action; if (/^libreportal update\b/.test(cmd) || action === 'update') { // Give the host a beat to finish writing update_status.json. setTimeout(() => this.refresh(), 1500); } }; window.addEventListener('taskCompleted', onTask); window.addEventListener('taskUpdated', onTask); } // 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 = ` Update`; } // ---- 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 = ` `; const checkIcon = ` `; 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 = `
`; } 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 = ` `; } 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 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 = `Updating backs up your configuration, pulls the latest version, and restarts LibrePortal. Progress streams on the Tasks page.
' : ''}