librelad d5fe1bc56b feat(webui): out-of-date detection + one-click update
Surface when LibrePortal is behind upstream and let users update from the
WebUI, reusing the proven git-update path instead of reinventing it.

Detection (host): webuiSystemUpdateCheck writes
frontend/data/system/update_status.json from a throttled git fetch +
behind-count + VERSION compare, off the existing per-minute
`webui generate system` cron. A new /VERSION file is the canonical version.

Display (frontend): update-notifier.js/.css render a global topbar badge
(every page) and a dashboard banner (prominent when behind, subtle "up to
date" with a manual check otherwise), plus a details panel.

Actions go through the task pipeline:
- `libreportal update apply` -> webuiRunUpdate (non-interactive: guards,
  forced check, gitPerformUpdate, then dockerInstallApp libreportal)
- `libreportal update check` -> forced recheck

gitFolderResetAndBackup's body is extracted into gitPerformUpdate (no exit)
so the WebUI path can reuse it; the interactive CLI flow is unchanged.

Detection JSON verified against the repo (up-to-date and behind cases).
webuiRunUpdate's re-clone + redeploy still needs validation on a live host.

The latest-version source is git for now and is the single swap point for
get.libreportal.org later — the JSON contract and frontend stay unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 23:33:43 +01:00

352 lines
14 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 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 = `
<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 ? ` &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>`;
} 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 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">&times;</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}`);
}
_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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
window.updateNotifier = window.updateNotifier || new UpdateNotifier();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => window.updateNotifier.start());
} else {
window.updateNotifier.start();
}