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>
727 lines
41 KiB
JavaScript
727 lines
41 KiB
JavaScript
// 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 & 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 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 =>
|
||
`<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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|