`;
}
// 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 = `
`;
// 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}