// 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;
this._poll = null; // auto-refresh timer (startAutoRefresh/dispose)
}
// ---- lifecycle -----------------------------------------------------------
async init() {
this.currentTab = this.parseTabFromUrl() || this.currentTab;
this.applyActiveTabUi(this.currentTab);
this.bindEvents();
await this.refreshAll();
this.render();
this.updateHeader();
// Keep the open page in step with the host-side auto-scan (the task
// processor refreshes the generated JSON on its own schedule, with no task
// event to hook). Repaints only when the data actually changed.
this.startAutoRefresh(
() => this.render(),
() => window.updaterPage === this && !!document.getElementById('updater-page')
);
}
// The host auto-scan (`libreportal updater check auto`, run from the task
// processor's idle poll) rewrites the generated JSON without a task event,
// so an open page re-reads it on a slow timer: a few static-file GETs per
// minute, and onChange() only when a generated_at stamp moved. isActive lets
// the hosting surface skip ticks while it isn't visible; dispose() releases
// the timer (also called by the feature unmount).
startAutoRefresh(onChange, isActive) {
if (this._poll) return;
this._poll = setInterval(() => {
if (document.hidden || (isActive && !isActive())) return;
const before = this._dataStamp();
this.refreshAll().then(() => {
if (this._poll && this._dataStamp() !== before && onChange) onChange();
});
}, 60000);
}
dispose() {
if (this._poll) { clearInterval(this._poll); this._poll = null; }
}
// Cheap change detector for the auto-refresh: the generators stamp each file
// with generated_at; history has no stamp, so its newest entry stands in.
_dataStamp() {
const g = (d) => (d && d.generated_at) || '';
const h = this.history && this.history.entries && this.history.entries[0];
return [g(this.updates), g(this.cves), g(this.artifacts), (h && h.ts) || ''].join('|');
}
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;
}
sevRank(s) {
const r = { critical: 0, high: 1, medium: 2, low: 3 };
return r[(s || '').toLowerCase()] ?? 4;
}
// One CVE list, shared by the standalone Security tab and the per-app expander.
// Sorted worst-first (so the most severe are visible before any scroll) and
// wrapped in a height-capped scroll box once the list is long, so an app with
// dozens of CVEs (e.g. 28) stays compact instead of pushing the page down.
renderCveList(cves) {
const list = [...(cves || [])].sort((x, y) => this.sevRank(x.severity) - this.sevRank(y.severity));
const items = list.map(c => `
${this.escape((c.severity || '').toUpperCase())}
${this.escape(c.id || 'CVE')}
${this.escape(c.package || '')}
${c.fixed_in ? `
fixed in ${this.escape(c.fixed_in)} ` : ''}
`).join('');
return `${items}
`;
}
// The CVE scanner's live state, stamped on cves.json by the updater generator:
// 'ready' — Trivy's vulnerability DB is present; results are real
// 'db_updating' — Trivy is installed but still downloading its DB (no
// results yet — do NOT paint a green all-clear)
// 'absent' — Trivy isn't installed/running (CVE scanning unavailable)
// null before the first scan file exists.
scannerState() {
return (this.cves && this.cves.scanner && this.cves.scanner.state) || 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 st = this.scannerState();
// The CVE card's sub-line reflects the scanner state so an empty count reads
// honestly (updating vs not-installed vs genuinely clean).
const cveSub = st === 'db_updating' ? 'database updating…'
: st === 'absent' ? 'scanner not installed'
: (sev.critical || sev.high ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues');
const scanHint = st === 'db_updating'
? `🛡️ Trivy is installed and downloading its vulnerability database — CVE results will fill in here automatically shortly.
`
: (st === 'absent'
? `🛡️ CVE scanning is off — add Trivy from the App Center to see vulnerabilities here.
`
: '');
const card = (hue, big, label, sub, action) => `
${big}
${label}
${sub ? `
${sub}
` : ''}
${action || ''}
`;
return `
${card('updates', c.updatesAvailable, 'Updates available', c.updatesAvailable ? 'across your apps' : "you're current",
`Review `)}
${card('verify', c.totalCves, 'Known CVEs', cveSub,
`View `)}
${card('setup', c.improvements, 'Improvements', c.improvements ? 'signed hotfixes to apply' : 'nothing pending',
`View `)}
${card('backups', `${c.drReady}/${c.apps}`, 'Recovery-ready', 'snapshot taken before each update',
`Recovery `)}
${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`,
`Check now `)}
${this.updates ? '' : `No scan data yet — showing your installed apps. The first automatic scan runs within a couple of minutes, or hit Check now .
`}
${scanHint}`;
}
renderUpdates() {
if (!this.apps.length) return this.empty('No apps installed yet — add one from the App Center and its updates will show up here.');
const rows = this.apps.map(a => {
const cur = this.escape(a.current_version || a.current_image || '—');
const avail = a.update_available ? this.escape(a.available_version || a.available_image || 'newer') : null;
const badge = a.update_available
? `update `
: (a.scanned ? `up to date ` : `unscanned `);
const sev = a.worstSeverity ? `${a.worstSeverity} ` : '';
const btn = a.update_available
? `Update `
: '';
return `
${this.escape(a.displayName)} ${badge} ${sev}
${cur}${avail ? ` → ${avail} ` : ''}
${btn}
`;
}).join('');
const anyUpdate = this.apps.some(a => a.update_available);
return `
↻ Check for updates
${anyUpdate ? `Update all ` : ''}
${rows}
`;
}
// withToolbar=false lets an embedding surface (the fleet Overview tab) skip
// the inline Check button because it provides one in its own header.
renderImprovements(withToolbar = true) {
// No inline Check button on the empty state: the host auto-scan repopulates
// this within a couple of minutes (and the embedding header already carries
// a manual Check), so the message alone is the right, button-free empty UI.
if (!this.artifacts) return this.empty('Nothing here yet — LibrePortal checks for improvements automatically in the background.');
const list = Array.isArray(this.artifacts.artifacts) ? this.artifacts.artifacts : [];
const signed = !!this.artifacts.signed;
if (!list.length) return this.empty('No improvements available right now — you are all caught up. 🎉');
// Map the hotfix severities onto the existing CVE severity colour classes.
const sevClass = { security: 'sev-critical', breakage: 'sev-high', compat: 'sev-medium', tweak: 'sev-low' };
const rows = list.map(a => {
const sv = sevClass[a.severity] || 'sev-low';
const scope = a.app ? this.escape(a.app) : 'system';
const appliedBadge = a.applied ? 'applied ' : '';
const autoBadge = a.auto ? 'auto ' : '';
const naBadge = a.applicable ? '' : 'not applicable ';
let btn = '';
if (a.applied) btn = `Revert `;
else if (a.applicable && signed) btn = `Apply `;
return `
${this.escape(a.title || a.id)}
${this.escape(a.severity || 'tweak')}
${scope}
${appliedBadge} ${autoBadge} ${naBadge}
${this.escape(a.why || '')}
${btn}
`;
}).join('');
const banner = signed
? `Small, signed, individually-reversible improvements curated by the LibrePortal team. Security & breakage fixes apply automatically (a snapshot is taken first); the rest are one click. Every apply is logged in History and can be reverted.
`
: `⚠ The improvements index is unsigned (signing not activated on this build) — applying is disabled for safety.
`;
return `${banner}
${withToolbar ? `↻ Check for improvements
` : ''}
${rows}
`;
}
renderSecurity() {
const withCves = this.apps.filter(a => (a.cves || []).length);
// Scanner-state gates come first: an empty CVE list means very different
// things depending on whether the scanner has even run yet. Painting a green
// "no vulnerabilities" all-clear while Trivy's DB is still downloading would
// be a false all-clear, so that state gets its own loading UI.
const st = this.scannerState();
if (st === 'db_updating') {
const msg = 'Trivy is installed and downloading its vulnerability database. CVE results appear here automatically once it finishes — usually a minute or two.';
return (window.lpLoadingBox && window.lpLoadingBox(msg)) || this.empty(msg);
}
if (st === 'absent') {
return this.empty('CVE scanning isn’t set up yet. Add Trivy (the vulnerability scanner) from the App Center to check your app images for known CVEs — it scans entirely on your box.');
}
// No inline Check button: the host auto-scan runs the vulnerability scan on
// its own within a couple of minutes (and the embedding header carries a
// manual Check), so the message alone is the right button-free empty UI.
if (!this.cves) return this.empty('No vulnerability scan yet — one runs automatically within a couple of minutes.');
if (!withCves.length) return this.empty('No known vulnerabilities in your installed apps. 🎉');
const blocks = withCves.map(a =>
`${this.escape(a.displayName)} ${(a.cves || []).length}
${this.renderCveList(a.cves)}
`
).join('');
return `${blocks}
`;
}
renderRecovery() {
const rows = this.apps.map(a => {
const snap = a.last_snapshot ? `${this.escape(a.last_snapshot_version || '')} · ${this.fmtRel(a.last_snapshot_at)}` : 'will be created on next update';
const can = !!a.last_snapshot;
return `
${this.escape(a.displayName)}
${can ? 'recoverable' : 'protected'}
${snap}
${can ? `Roll back ` : ''}
`;
}).join('');
return `
Disaster recovery is automatic: before any app update, LibrePortal snapshots that app (via the Backup engine) so the update can be rolled back. Apps below show their latest rollback point.
${rows}
`;
}
renderHistory() {
const entries = (this.history && this.history.entries) || [];
if (!entries.length) return this.empty('No update activity yet.');
const rows = entries.map(e => `
${this.escape(e.app)}
${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}
${this.escape(e.from || '')}${e.to ? ` → ${this.escape(e.to)}` : ''}
${this.fmtRel(e.ts)}
`).join('');
return `${rows}
`;
}
// Per-app detail body — composes one app's security (CVEs), recovery point,
// and recent history. Pure HTML string with no DOM assumptions, so it is reused
// verbatim as the fleet Updates expander body (overview-manager.js) AND the
// per-app Updater tab. Action buttons keep the data-updater-action/data-app
// contract, so whichever delegated handler is in scope drives them.
renderAppDetail(app, opts = {}) {
const a = app || {};
// Optional leading "Version" section — the per-app Updates tab shows the
// current/available version + status badge as a section in the panel. The
// fleet rows omit it (the row head already shows the version).
let versionSection = '';
if (opts.includeVersion) {
const cur = this.escape(a.current_version || a.current_image || '—');
const avail = a.update_available ? this.escape(a.available_version || a.available_image || 'newer') : null;
const badge = a.update_available
? `update available `
: (a.scanned ? `up to date ` : `unscanned `);
versionSection = `Version
${badge} ${cur}${avail ? ` → ${avail} ` : ''}
`;
}
const cves = a.cves || [];
const security = `Security${
cves.length ? ` ${cves.length} ` : ''} ${
cves.length ? this.renderCveList(cves) : '
No known CVEs. 🎉
'}
`;
// A rollback target exists if a snapshot field is present (future-proofing)
// OR — the data the generator actually emits today — this app has a prior
// update/rollback in history (updater_apply always snapshots first, so that
// pre-update snapshot is the rollback point). Gating only on the never-
// written last_snapshot* fields would make the Roll back button unreachable.
const priorUpdate = ((this.history && this.history.entries) || [])
.some((e) => e.app === a.name && (e.action === 'update' || e.action === 'rollback'));
const can = !!a.last_snapshot || priorUpdate;
const snap = a.last_snapshot
? `${this.escape(a.last_snapshot_version || '')} · ${this.fmtRel(a.last_snapshot_at)}`
: (priorUpdate
? 'Roll back to the snapshot taken before the last update.'
: 'A recovery snapshot is taken automatically before the next update.');
const recovery = `Recovery
${can ? 'recoverable' : 'protected'}
${snap}
${can ? `Roll back ` : ''}
`;
const entries = ((this.history && this.history.entries) || []).filter((e) => e.app === a.name).slice(0, 8);
const history = entries.length ? `History ${entries.map((e) => `
${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}
${this.escape(e.from || '')}${e.to ? ` → ${this.escape(e.to)}` : ''}
${this.fmtRel(e.ts)}
`).join('')}
` : '';
return `${versionSection}${security}${recovery}${history}
`;
}
empty(msg, withCheck) {
return `${this.escape(msg)}${withCheck ? `
Check now
` : ''}
`;
}
// ---- utils ---------------------------------------------------------------
escape(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
fmtRel(ts) {
if (!ts) return 'never';
const t = typeof ts === 'number' ? ts : Date.parse(ts);
if (!t || isNaN(t)) return this.escape(String(ts));
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
}
window.UpdaterPage = UpdaterPage;