// 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.
// A CVE is actionable when upstream ships a fixed version AND hasn't flagged
// it won't-fix / EOL — those are the ones an image rebuild (i.e. updating the
// app) can actually clear. The rest are noise the user can only wait on.
cveHasFix(c) {
const st = ((c && c.status) || '').toLowerCase();
return !!(c && c.fixed_in) && st !== 'will_not_fix' && st !== 'end_of_life';
}
// Human label for where a CVE lives: an OS package baked into the base image
// vs one of the app's own bundled dependencies (Go module, npm, …). Sourced
// from Trivy's Class/Type; empty when the scan predates capturing them (so we
// show no tag rather than a wrong one).
cveOrigin(c) {
const cls = ((c && c.class) || '').toLowerCase();
const ty = ((c && c.type) || '').toLowerCase();
if (cls === 'os-pkgs') return 'OS package';
if (cls === 'lang-pkgs') {
if (/go/.test(ty)) return 'Go dependency';
if (/(node|npm|yarn|pnpm)/.test(ty)) return 'npm dependency';
if (/(python|pip|poetry|conda)/.test(ty)) return 'Python dependency';
if (/(gem|bundler|ruby)/.test(ty)) return 'Ruby dependency';
if (/(cargo|rust)/.test(ty)) return 'Rust dependency';
if (/(jar|pom|gradle|java)/.test(ty)) return 'Java dependency';
if (/(composer|php)/.test(ty)) return 'PHP dependency';
return 'App dependency';
}
return '';
}
cveRow(c) {
const sev = (c.severity || 'low').toLowerCase();
const origin = this.cveOrigin(c);
const ver = this.cveHasFix(c)
? `${this.escape(c.installed || '?')} →${this.escape(c.fixed_in)}`
: `no patch`;
return `
`;
}
// 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 = ``;
else if (a.applicable && signed) btn = ``;
return `
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 ? `` : ''}
${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 =>
`
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.
`;
}
// 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 appLabel = this.escape((window.getAppDisplayName ? window.getAppDisplayName(a.name) : null) || a.displayName || a.name || 'the app');
const verLabel = a.current_version ? ` ${this.escape(a.current_version)}` : '';
// Frame actionability by whether an app update actually EXISTS — not by
// Trivy's fixed_in. An upstream package patch does the user no good while
// they're already on the newest image; it only lands when the maintainer
// rebuilds. Telling them to "update to clear these" in that state is a lie.
const statusLine = a.update_available
? 'An update is available — installing it (a snapshot is taken first) may pull in patched packages.'
: `You're on the latest published version, so there's nothing to apply. These clear when ${appLabel}'s maintainer ships a rebuilt image.`;
const secIntro = cves.length
? `
Vulnerabilities in the packages bundled inside this app's image — not ${appLabel}${verLabel} itself.
`;
// 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 = `
`;
}
// ---- 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`;
}
// Approximate "when will the next automatic check run" — the auto-scan fires on
// the task processor's idle poll once updates.json is older than the interval,
// so this is an earliest-estimate, hence the "~".
fmtRelFuture(t) {
const s = (t - Date.now()) / 1000;
if (!t || isNaN(t) || s <= 0) return 'due now';
if (s < 90) return 'in ~1m';
if (s < 3600) return `in ~${Math.round(s / 60)}m`;
if (s < 86400) return `in ~${Math.round(s / 3600)}h`;
return `in ~${Math.round(s / 86400)}d`;
}
// "Checked automatically · last checked X · next check ~Y" — LibrePortal scans
// for updates on a schedule (CFG_UPDATER_SCAN_INTERVAL, stamped into
// updates.json), so a manual "Check" button is redundant; this line says when
// the last scan ran and when the next is due instead. interval 0 = auto off.
renderAutoCheckLine() {
const gen = this.updates && this.updates.generated_at;
const iv = this.updates ? Number(this.updates.scan_interval_minutes) : NaN;
if (!gen && !(iv >= 0)) return '';
const last = gen ? this.fmtRel(gen) : 'not yet';
let nextBit = '', off = '';
if (iv === 0) {
off = ' off';
nextBit = ' · automatic checks are off';
} else if (gen && iv > 0) {
nextBit = ` · next check ${this.fmtRelFuture(Date.parse(gen) + iv * 60000)}`;
}
return `
` +
`Checked automatically · last checked ${last}${nextBit}` +
`