librelad 046d69bad7 feat(updater): Upgrade button for cross-version moves
Puts the stepped engine behind the "34 available" chip so it is not
CLI-only. Wired end to end: updater_upgrade task type -> task-router ->
updaterUpgrade action -> `libreportal updater upgrade <app> [version]`,
with a label and icon in the tasks list.

The button always confirms, and the dialog states the plan and the
guarantee rather than asking "are you sure?" — which app, from which
version to which, that every step snapshots first and waits for the app
to confirm it is serving with no migration outstanding, that a failure
stops the ladder on the last version that verified, and that it can take
a long time because each release runs its own migration.

Both delegated dispatchers (per-app Updates tab and the fleet Overview
rows) learn the action, so the button works wherever the chip appears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:08:16 +01:00

727 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 'upgrade': this.upgrade(app, action.dataset.version); 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)
? `<span class="updater-cve-ver">${this.escape(c.installed || '?')} <span class="updater-arrow">→</span> <strong>${this.escape(c.fixed_in)}</strong></span>`
: `<span class="updater-cve-ver updater-cve-ver-none">no patch</span>`;
return `<div class="updater-cve sev-${sev}">
<span class="updater-cve-sev">${this.escape((c.severity || '').toUpperCase())}</span>
<a class="updater-cve-id" href="${this.escape(c.url || ('https://nvd.nist.gov/vuln/detail/' + (c.id || '')))}" target="_blank" rel="noopener">${this.escape(c.id || 'CVE')}</a>
<span class="updater-cve-pkg">${this.escape(c.package || '')}</span>
${origin ? `<span class="updater-cve-origin">${this.escape(origin)}</span>` : ''}
${ver}
</div>`;
}
renderCveList(cves, opts = {}) {
const list = [...(cves || [])].sort((x, y) => this.sevRank(x.severity) - this.sevRank(y.severity));
const fixable = list.filter((c) => this.cveHasFix(c));
const noFix = list.filter((c) => !this.cveHasFix(c));
// The "patched upstream" pile is only user-actionable when an app update
// exists — otherwise the patch just waits for the maintainer's rebuild.
const fixHint = opts.updateAvailable ? 'may be cleared by updating' : 'lands when the image is rebuilt';
const group = (title, hint, mod, rows) => rows.length
? `<div class="updater-cve-group${mod}"><div class="updater-cve-group-head"><span class="updater-cve-group-title">${title}</span>${
hint ? `<span class="updater-cve-group-hint">${hint}</span>` : ''}<span class="updater-cve-group-n">${rows.length}</span></div>${
rows.map((c) => this.cveRow(c)).join('')}</div>`
: '';
const body = group('Patch released upstream', fixHint, '', fixable)
+ group('No patch yet', 'waiting on upstream', ' is-nofix', noFix);
return `<div class="updater-cve-scroll${list.length > 6 ? ' is-scrollable' : ''}">${body}</div>`;
}
// 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…`);
}
// Cross-version upgrade. Always confirmed, and never quietly: this walks
// real migrations one release at a time and can take a long while, so the
// dialog states the plan and the guarantee rather than asking "are you sure?".
upgrade(app, version) {
if (!app) return;
const a = this.apps.find((x) => x.name === app) || {};
const from = a.channel || a.current_version || 'the current version';
const to = version || a.newer_version || 'the newest release';
const body = `
<div class="updater-detail-section">
<p><strong>${this.escape(app)}</strong> will move from <strong>${this.escape(from)}</strong>
to <strong>${this.escape(to)}</strong>, one release at a time.</p>
<p class="updater-detail-meta">Every step takes its own recovery snapshot first, then waits for
${this.escape(app)} to confirm it is serving that version with no migration outstanding.
If any step fails it is rolled back and the upgrade stops there, leaving the app on the last
version that verified.</p>
<p class="updater-detail-meta">This can take a long time — each release runs its own database
migration. Watch it in Tasks.</p>
</div>`;
const go = () => this.dispatch('updater_upgrade', { app, version: version || '' },
`Upgrading ${app} to ${to}, one release at a time…`);
if (window.showConfirmation) {
window.showConfirmation(`Upgrade ${app} to ${to}?`, '', go, 'Start upgrade', 'Cancel', 'warning', false, '', body);
} else if (window.confirm(`Upgrade ${app} from ${from} to ${to}, one release at a time?`)) {
go();
}
}
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 = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>';
}
}
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'
? `<div class="updater-hint">🛡️ Trivy is installed and downloading its vulnerability database — CVE results will fill in here automatically shortly.</div>`
: (st === 'absent'
? `<div class="updater-hint">🛡️ CVE scanning is off — add <strong>Trivy</strong> from the App Center to see vulnerabilities here.</div>`
: '');
const card = (hue, big, label, sub, action) => `
<div class="updater-stat" style="--page: var(--page-${hue}); --page-rgb: var(--page-${hue}-rgb);">
<div class="updater-stat-big">${big}</div>
<div class="updater-stat-label">${label}</div>
${sub ? `<div class="updater-stat-sub">${sub}</div>` : ''}
${action || ''}
</div>`;
return `
<div class="updater-stat-grid">
${card('updates', c.updatesAvailable, 'Updates available', c.updatesAvailable ? 'across your apps' : "you're current",
`<button class="updater-btn updater-btn-primary" data-updater-action="goto" data-tab="updates">Review</button>`)}
${card('verify', c.totalCves, 'Known CVEs', cveSub,
`<button class="updater-btn" data-updater-action="goto" data-tab="security">View</button>`)}
${card('setup', c.improvements, 'Improvements', c.improvements ? 'signed hotfixes to apply' : 'nothing pending',
`<button class="updater-btn" data-updater-action="goto" data-tab="improvements">View</button>`)}
${card('backups', `${c.drReady}/${c.apps}`, 'Recovery-ready', 'snapshot taken before each update',
`<button class="updater-btn" data-updater-action="goto" data-tab="recovery">Recovery</button>`)}
${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`,
`<button class="updater-btn" data-updater-action="check">Check now</button>`)}
</div>
${this.updates ? '' : `<div class="updater-hint">No scan data yet — showing your installed apps. The first automatic scan runs within a couple of minutes, or hit <strong>Check now</strong>.</div>`}
${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
? `<span class="updater-badge updater-badge-update">update</span>`
: (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
const sev = a.worstSeverity ? `<span class="updater-badge sev-${a.worstSeverity}">${a.worstSeverity}</span>` : '';
const btn = a.update_available
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${this.escape(a.name)}">Update</button>`
: '';
return `<div class="updater-row">
<div class="updater-row-main"><span class="updater-row-name">${this.escape(a.displayName)}</span> ${badge} ${sev}</div>
<div class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</div>
<div class="updater-row-actions">${btn}</div>
</div>`;
}).join('');
const anyUpdate = this.apps.some(a => a.update_available);
return `
<div class="updater-toolbar">
<button class="updater-btn" data-updater-action="check">↻ Check for updates</button>
${anyUpdate ? `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>` : ''}
</div>
<div class="updater-list">${rows}</div>`;
}
// 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 ? '<span class="updater-badge updater-badge-ok">applied</span>' : '';
const autoBadge = a.auto ? '<span class="updater-badge updater-badge-update">auto</span>' : '';
const naBadge = a.applicable ? '' : '<span class="updater-badge updater-badge-unknown">not applicable</span>';
let btn = '';
if (a.applied) btn = `<button class="updater-btn" data-updater-action="revert-artifact" data-id="${this.escape(a.id)}">Revert</button>`;
else if (a.applicable && signed) btn = `<button class="updater-btn updater-btn-primary" data-updater-action="apply-artifact" data-id="${this.escape(a.id)}">Apply</button>`;
return `<div class="updater-row">
<div class="updater-row-main"><span class="updater-row-name">${this.escape(a.title || a.id)}</span>
<span class="updater-badge ${sv}">${this.escape(a.severity || 'tweak')}</span>
<span class="updater-badge updater-badge-unknown">${scope}</span>
${appliedBadge} ${autoBadge} ${naBadge}</div>
<div class="updater-row-ver">${this.escape(a.why || '')}</div>
<div class="updater-row-actions">${btn}</div>
</div>`;
}).join('');
const banner = signed
? `<div class="updater-hint">Small, signed, individually-reversible improvements curated by the LibrePortal team. Security &amp; breakage fixes apply automatically (a snapshot is taken first); the rest are one click. Every apply is logged in History and can be reverted.</div>`
: `<div class="updater-hint">⚠ The improvements index is <strong>unsigned</strong> (signing not activated on this build) — applying is disabled for safety.</div>`;
return `${banner}
${withToolbar ? `<div class="updater-toolbar"><button class="updater-btn" data-updater-action="check">↻ Check for improvements</button></div>` : ''}
<div class="updater-list">${rows}</div>`;
}
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 isnt 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 =>
`<div class="updater-cve-app"><div class="updater-cve-app-name">${this.escape(a.displayName)} <span class="updater-badge sev-${a.worstSeverity}">${(a.cves || []).length}</span></div>${this.renderCveList(a.cves, { updateAvailable: a.update_available })}</div>`
).join('');
return `<div class="updater-list">${blocks}</div>`;
}
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 `<div class="updater-row">
<div class="updater-row-main"><span class="updater-row-name">${this.escape(a.displayName)}</span>
<span class="updater-badge ${can ? 'updater-badge-ok' : 'updater-badge-unknown'}">${can ? 'recoverable' : 'protected'}</span></div>
<div class="updater-row-ver">${snap}</div>
<div class="updater-row-actions">${can ? `<button class="updater-btn" data-updater-action="rollback" data-app="${this.escape(a.name)}">Roll back</button>` : ''}</div>
</div>`;
}).join('');
return `
<div class="updater-hint">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.</div>
<div class="updater-list">${rows}</div>`;
}
renderHistory() {
const entries = (this.history && this.history.entries) || [];
if (!entries.length) return this.empty('No update activity yet.');
const rows = entries.map(e => `
<div class="updater-row">
<div class="updater-row-main"><span class="updater-row-name">${this.escape(e.app)}</span>
<span class="updater-badge ${e.result === 'ok' ? 'updater-badge-ok' : (e.result === 'rolled-back' ? 'updater-badge-update' : 'sev-high')}">${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}</span>${this.triggerBadge(e)}</div>
<div class="updater-row-ver">${this.escape(e.from || '')}${e.to ? ` <span class="updater-arrow">→</span> ${this.escape(e.to)}` : ''}</div>
<div class="updater-row-actions">${this.fmtRel(e.ts)}</div>
</div>`).join('');
return `<div class="updater-list">${rows}</div>`;
}
// 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
? `<span class="updater-badge updater-badge-update">update available</span>`
: (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
// What happens next, in the app's own words — the Updates setting on this
// app's Configure page decides, so say which way it is set rather than
// leaving "update available" to imply someone must act. One honest
// exception: an auto app whose available build was already attempted and
// rolled back will NOT retry (by design — one shot per build), so saying
// "installs on its own" there would promise an install that never comes.
const policyLine = a.update_type === 'manual'
? 'Set to <strong>manual</strong> — this app updates only when you press Update.'
: (this.autoAttemptFailed(a)
? 'Set to <strong>automatic</strong>, but this build failed to apply and was rolled back — it won\'t be retried. Press Update to try again, or wait for the next build.'
: 'Set to <strong>automatic</strong> — new builds install on their own, after a recovery snapshot.');
// A newer release line, if one has been published. Separate from the
// update state above on purpose: "up to date" stays true — you ARE
// current on the version you track — while still saying a newer one
// exists and what to do about it.
const newerLine = a.newer_version
? `<div class="updater-detail-row"><span class="updater-detail-meta">A newer release line is available: <strong>${this.escape(a.newer_version)}</strong> (you track ${this.escape(a.channel || '—')}). Automatic updates keep you current within your line. Upgrading walks the releases one at a time, snapshotting and verifying each — read the release notes first.</span>
<button class="updater-btn" data-updater-action="upgrade" data-app="${this.escape(a.name)}" data-version="${this.escape(a.newer_version)}">Upgrade to ${this.escape(a.newer_version)}</button></div>`
: '';
versionSection = `<div class="updater-detail-section"><h4>Version</h4>
<div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div>
<div class="updater-detail-row"><span class="updater-detail-meta">${policyLine}</span></div>
${newerLine}</div>`;
}
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
? `<p class="updater-cve-explain">Vulnerabilities in the packages bundled inside this app's image — not ${appLabel}${verLabel} itself.</p>
<p class="updater-cve-status">${statusLine}</p>`
: '';
const security = `<div class="updater-detail-section"><h4>Security${
cves.length ? ` <span class="updater-cve-count">${cves.length}</span>` : ''}</h4>${
cves.length ? `${secIntro}<div class="updater-cve-box">${this.renderCveList(cves, { updateAvailable: a.update_available })}</div>` : '<p class="updater-detail-empty">No known CVEs. 🎉</p>'}</div>`;
// 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 = `<div class="updater-detail-section"><h4>Recovery</h4>
<div class="updater-detail-row"><span class="updater-badge ${can ? 'updater-badge-ok' : 'updater-badge-unknown'}">${can ? 'recoverable' : 'protected'}</span>
<span class="updater-detail-meta">${snap}</span>
${can ? `<button class="updater-btn" data-updater-action="rollback" data-app="${this.escape(a.name)}">Roll back</button>` : ''}</div></div>`;
const entries = ((this.history && this.history.entries) || []).filter((e) => e.app === a.name).slice(0, 8);
const history = entries.length ? `<div class="updater-detail-section"><h4>History</h4>${entries.map((e) => `
<div class="updater-detail-row"><span class="updater-badge ${e.result === 'ok' ? 'updater-badge-ok' : (e.result === 'rolled-back' ? 'updater-badge-update' : 'sev-high')}">${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}</span>${this.triggerBadge(e)}
<span class="updater-detail-meta">${this.escape(e.from || '')}${e.to ? `${this.escape(e.to)}` : ''}</span>
<span class="updater-detail-meta">${this.fmtRel(e.ts)}</span></div>`).join('')}</div>` : '';
return `<div class="updater-detail">${versionSection}${security}${recovery}${history}</div>`;
}
// True when this app's AVAILABLE build is the one the auto-updater already
// attempted and rolled back (the one-shot no-retry stamp). Such an update is
// effectively manual now: it sits until a person retries or a newer build
// ships. Both this page and the Overview board branch on it.
autoAttemptFailed(a) {
return !!(a && a.update_available && a.update_type !== 'manual'
&& a.auto_attempted_digest && a.auto_attempted_digest === a.available_digest);
}
// "automatic" marker for a History entry. Only automatic runs are labelled —
// a hand-pressed update needs no explanation, and entries written before the
// trigger field existed carry none, so silence is also the honest default.
triggerBadge(e) {
return e && e.trigger === 'auto'
? ' <span class="updater-badge updater-badge-unknown">automatic</span>'
: '';
}
empty(msg, withCheck) {
return `<div class="updater-empty">${this.escape(msg)}${withCheck ? `<div><button class="updater-btn updater-btn-primary" data-updater-action="check">Check now</button></div>` : ''}</div>`;
}
// ---- utils ---------------------------------------------------------------
escape(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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)}`;
}
// Say plainly whether found updates install themselves. The scan carries each
// app's resolved policy, so this counts what will actually happen rather than
// restating a config value: all / some / none on automatic.
// Nothing installs itself while the scan that finds updates is off, so with
// iv === 0 the policy is moot and claiming otherwise would be a lie.
// The window says WHEN: checks run all day, installs land inside it.
const win = this.updates && this.updates.auto_window;
const winBit = (win && win !== 'always') ? ` during ${this.escape(win).replace('-', '')}` : '';
const auto = this.apps.filter((a) => a.update_type !== 'manual').length;
let autoBit = '';
if (iv === 0 || !this.apps.length) autoBit = '';
else if (auto === this.apps.length) autoBit = ` · updates install automatically${winBit}`;
else if (auto) autoBit = ` · ${auto} of ${this.apps.length} apps install updates automatically${winBit}`;
else autoBit = ' · updates wait for you';
return `<div class="updater-autocheck${off}"><span class="updater-autocheck-dot"></span>` +
`<span class="updater-autocheck-text">Checked automatically · last checked <strong>${last}</strong>${nextBit}${autoBit}</span>` +
`<button class="updater-btn updater-autocheck-btn" data-updater-action="check" title="Check for updates now">↻ Check now</button></div>`;
}
}
window.UpdaterPage = UpdaterPage;