// components/updater/js/updater-page.js — App Updater controller. // // Surfaces, per installed app: version state (current -> available), security // posture (CVEs by severity), and disaster-recovery readiness (a snapshot is // taken before every update so any update is reversible). Read-only data comes // from /data/updater/generated/*.json (written host-side by the updater // generator); every action is dispatched through the task system via // services.tasks.route(...) — the same locked-down mutation path apps/backups // use. No mutating API is added. class UpdaterPage { constructor(services) { this.services = services || (window.LP && window.LP.services) || {}; this.currentTab = 'overview'; this.updates = null; // { generated_at, apps: [...] } this.cves = null; // { generated_at, apps: [...], totals: {...} } this.history = null; // { entries: [...] } this.artifacts = null; // { signed, serial, artifacts: [...] } (hotfixes) this.apps = []; // merged per-app view rendered in the table this._pushedAnyTab = false; this._eventBound = false; } // ---- lifecycle ----------------------------------------------------------- async init() { this.currentTab = this.parseTabFromUrl() || this.currentTab; this.applyActiveTabUi(this.currentTab); this.bindEvents(); await this.refreshAll(); this.render(); this.updateHeader(); } parseTabFromUrl() { const allowed = new Set(['overview', 'updates', 'improvements', 'security', 'recovery', 'history']); const seg = window.location.pathname.replace(/^\/updater\/?/, '').split('/')[0]; if (seg && allowed.has(seg)) return seg; return null; } bindEvents() { if (this._eventBound) return; this._eventBound = true; // Repaint when an updater/backup task completes (debounced via the // coordinator). Self-guards against a torn-down page. this.services.tasks && this.services.tasks.refresh && this.services.tasks.refresh.register({ id: 'updater', match: (d) => /^(updater_|artifact_|libreportal\s+(updater|artifact))/.test((d && (d.action || (d.task && d.task.command))) || ''), run: () => { if (window.updaterPage === this && document.getElementById('updater-page')) { return this.refreshAll().then(() => this.render()); } }, debounceMs: 800, }); // Delegated click handling on the whole layout (sidebar is a sibling of the // page card, both inside .updater-layout). The element is replaced on // navigation, so the listener is GC'd with it — no cross-page leak. const root = document.querySelector('.updater-layout'); if (!root) return; root.addEventListener('click', (e) => { const tabBtn = e.target.closest('.sidebar .category[data-updater-tab]'); if (tabBtn) { this.switchTab(tabBtn.dataset.updaterTab); return; } const action = e.target.closest('[data-updater-action]'); if (!action) return; const app = action.dataset.app || null; switch (action.dataset.updaterAction) { case 'check': this.checkForUpdates(); break; case 'update': this.applyUpdate(app); break; case 'update-all': this.applyAll(); break; case 'rollback': this.rollback(app); break; case 'apply-artifact': this.applyArtifact(action.dataset.id); break; case 'revert-artifact': this.revertArtifact(action.dataset.id); break; case 'goto': this.switchTab(action.dataset.tab); break; } }); } applyActiveTabUi(tab) { document.querySelectorAll('.updater-layout .sidebar .category[data-updater-tab]').forEach(b => { b.classList.toggle('active', b.dataset.updaterTab === tab); }); document.querySelectorAll('.updater-tabpanel').forEach(p => { p.classList.toggle('active', p.id === `updater-panel-${tab}`); }); } switchTab(tab) { if (!tab || tab === this.currentTab) return; this.currentTab = tab; this.applyActiveTabUi(tab); this.updateHeader(); this.render(); const url = `/updater/${tab}`; if (!this._pushedAnyTab) { window.history.replaceState({ route: url }, '', url); this._pushedAnyTab = true; } else { window.history.pushState({ route: url }, '', url); } } // ---- data ---------------------------------------------------------------- async refreshAll() { const get = (url) => fetch(url, { cache: 'no-store' }).then(r => r.ok ? r.json() : null).catch(() => null); const [u, c, h, av] = await Promise.all([ get('/data/updater/generated/updates.json'), get('/data/updater/generated/cves.json'), get('/data/updater/generated/history.json'), get('/data/updater/generated/artifacts_available.json'), ]); this.updates = u; this.cves = c; this.history = h; this.artifacts = av; this.apps = this.mergeApps(); } // Build the per-app view. Prefer the generator's updates.json; otherwise fall // back to the installed-apps list (window.apps / DataLoader) so the page is // still useful before the first scan ("status unknown — run a check"). mergeApps() { const cveByApp = {}; if (this.cves && Array.isArray(this.cves.apps)) { for (const a of this.cves.apps) cveByApp[a.name] = a.cves || []; } let base = (this.updates && Array.isArray(this.updates.apps)) ? this.updates.apps : null; if (!base) { const installed = (window.apps || []).filter(a => a && (a.status === 1 || a.installed || a.is_installed)); base = installed.map(a => ({ name: a.name || a.app_name, displayName: a.displayName || a.title || a.name || a.app_name, current_version: a.version || null, available_version: null, update_available: false, scanned: false, })); } return base.map(a => { const cves = cveByApp[a.name] || []; const sev = this.worstSeverity(cves); return Object.assign({ displayName: a.displayName || a.name, scanned: a.scanned !== false }, a, { cves, worstSeverity: sev }); }); } worstSeverity(cves) { const order = ['critical', 'high', 'medium', 'low']; for (const s of order) if (cves.some(c => (c.severity || '').toLowerCase() === s)) return s; return null; } // ---- derived counts ------------------------------------------------------ counts() { const updatesAvailable = this.apps.filter(a => a.update_available).length; const cveTotals = (this.cves && this.cves.totals) || this.tallyCves(); const totalCves = (cveTotals.critical || 0) + (cveTotals.high || 0) + (cveTotals.medium || 0) + (cveTotals.low || 0); const drReady = this.apps.filter(a => a.dr_ready !== false).length; // snapshot-before-update is on by default const artList = (this.artifacts && Array.isArray(this.artifacts.artifacts)) ? this.artifacts.artifacts : []; const improvements = artList.filter(a => a.applicable && !a.applied).length; return { apps: this.apps.length, updatesAvailable, cveTotals, totalCves, drReady, improvements, lastChecked: this.updates && this.updates.generated_at }; } tallyCves() { const t = { critical: 0, high: 0, medium: 0, low: 0 }; for (const a of this.apps) for (const c of (a.cves || [])) { const s = (c.severity || '').toLowerCase(); if (t[s] != null) t[s]++; } return t; } // ---- actions (all via the task system) ----------------------------------- checkForUpdates() { this.dispatch('updater_check', {}, 'Checking apps for updates & vulnerabilities…'); } applyUpdate(app) { if (!app) return; this.dispatch('updater_apply', { app }, `Updating ${app} (a recovery snapshot is taken first)…`); } applyAll() { const list = this.apps.filter(a => a.update_available).map(a => a.name); if (!list.length) { this.toast('Everything is up to date.', 'info'); return; } this.dispatch('updater_apply_all', { apps: list.join(',') }, `Updating ${list.length} app(s) — each is snapshotted first…`); } rollback(app) { if (!app) return; this.dispatch('updater_rollback', { app }, `Rolling ${app} back to its pre-update snapshot…`); } applyArtifact(id) { if (!id) return; this.dispatch('artifact_apply', { id }, `Applying hotfix ${id} (a snapshot is taken first)…`); } revertArtifact(id) { if (!id) return; this.dispatch('artifact_revert', { id }, `Reverting hotfix ${id}…`); } dispatch(action, params, note) { const route = this.services.tasks && this.services.tasks.route; if (route && typeof route.routeAction === 'function') { route.routeAction(action, params || {}); this.toast(note || 'Working…', 'info'); } else if (typeof route === 'function') { route(action, params || {}); this.toast(note || 'Working…', 'info'); } else { this.toast('Task system not ready — try again in a moment.', 'error'); } } toast(msg, type) { const n = this.services.notify; if (n && typeof n.show === 'function') n.show(msg, type || 'info'); } // ---- rendering ----------------------------------------------------------- updateHeader() { const titles = { overview: ['Overview', 'Update health, security posture, and recovery readiness at a glance.'], updates: ['Updates', 'Available versions per app. Every update is snapshotted first, so it is reversible.'], improvements: ['Improvements', 'Signed, individually-reversible hotfixes from the LibrePortal team — applied with a snapshot first.'], security: ['Security', 'Known vulnerabilities (CVEs) in your installed app images, by severity.'], recovery: ['Disaster Recovery', 'Pre-update snapshots and rollback points — undo any update.'], history: ['History', 'A log of update and rollback activity.'], }; const t = titles[this.currentTab] || titles.overview; const titleEl = document.getElementById('updater-section-title'); const subEl = document.getElementById('updater-section-subtitle'); if (titleEl) titleEl.textContent = t[0]; if (subEl) subEl.textContent = t[1]; // Fill the shared page-header icon slot once (update-cycle glyph). const iconEl = document.getElementById('updater-page-header-icon'); if (iconEl && !iconEl.firstChild) { iconEl.innerHTML = ''; } } render() { const panel = document.getElementById(`updater-panel-${this.currentTab}`); if (!panel) return; switch (this.currentTab) { case 'overview': panel.innerHTML = this.renderOverview(); break; case 'updates': panel.innerHTML = this.renderUpdates(); break; case 'improvements': panel.innerHTML = this.renderImprovements(); break; case 'security': panel.innerHTML = this.renderSecurity(); break; case 'recovery': panel.innerHTML = this.renderRecovery(); break; case 'history': panel.innerHTML = this.renderHistory(); break; } } renderOverview() { const c = this.counts(); const sev = c.cveTotals; const checked = c.lastChecked ? this.fmtRel(c.lastChecked) : 'never'; const card = (hue, big, label, sub, action) => `