Matrix was uninstalled and the Updates tab kept listing it as up to date. Not an instance problem — updates.json and cves.json are scan-time snapshots on a 30-minute cadence, and nothing rewrote them at uninstall, so any removed app haunted every updater surface until the next scan happened to run. The backend was never wrong: the DB, the apps data and the app's own page all said uninstalled within seconds. Fixed at both ends. Uninstall now deletes the app's rows from both generated files, surgically — a full rescan re-runs CVE checks against every image and has no place inside an uninstall. And the updater's merge drops any row whose app window.apps does not list as installed, which covers every other way the snapshot can go stale (a crashed uninstall, a hand-edited file, the next bug). The filter only applies when the installed list has actually loaded, preserving the page's degrade-gracefully contract when it has not. The stale Matrix rows on this install were purged the same surgical way; the tab now shows 14 rows with the merge still intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
921 lines
52 KiB
JavaScript
921 lines
52 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)
|
||
this.inflight = new Map(); // app name -> { verb, taskId } while its task runs
|
||
}
|
||
|
||
// ---- 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;
|
||
// updates.json is a scan-time snapshot and nothing rewrites it at
|
||
// uninstall, so for up to a scan cycle it can still carry an app that no
|
||
// longer exists — Matrix kept a ghost row (and an "installed"-looking
|
||
// presence) for half an hour after being removed. window.apps IS refreshed
|
||
// by the uninstall flow, so where it can answer, an app it does not list
|
||
// as installed is dropped from the merge. Only applied when window.apps
|
||
// has content: this page must keep degrading gracefully when the installed
|
||
// list has not loaded, per the generator's own contract.
|
||
if (base && Array.isArray(window.apps) && window.apps.length) {
|
||
const installedSlugs = new Set(
|
||
window.apps
|
||
.filter(a => a && a.installed)
|
||
.map(a => ((a.command || '').split(' ').pop() || '').toLowerCase())
|
||
.filter(Boolean)
|
||
);
|
||
base = base.filter(a => installedSlugs.has(String(a.name).toLowerCase()));
|
||
}
|
||
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;
|
||
}
|
||
|
||
// "06:00-08:00" -> "6am–8am"; "22:30-01:15" -> "10:30pm–1:15am".
|
||
// 24-hour time is unambiguous on paper and ambiguous at a glance: people read
|
||
// "06:00–08:00" and still have to work out whether that is morning or night.
|
||
// The config stays 24-hour (one canonical way to type it); only the display
|
||
// spells it out. Anything that is not a plain HH:MM-HH:MM is passed through.
|
||
fmtWindow(win) {
|
||
const m = String(win || '').match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
|
||
if (!m) return String(win || '').replace('-', '–');
|
||
const one = (h, mi) => {
|
||
const H = parseInt(h, 10), M = parseInt(mi, 10);
|
||
const ap = H < 12 ? 'am' : 'pm';
|
||
const hh = (H % 12) === 0 ? 12 : (H % 12);
|
||
return M ? `${hh}:${String(M).padStart(2, '0')}${ap}` : `${hh}${ap}`;
|
||
};
|
||
return `${one(m[1], m[2])}–${one(m[3], m[4])}`;
|
||
}
|
||
|
||
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 || this.inflight.has(app)) return; // one task per app at a time
|
||
this.dispatch('updater_apply', { app }, `Updating ${app} (a recovery snapshot is taken first)…`,
|
||
{ track: { apps: [app], verb: 'update' } });
|
||
}
|
||
applyAll() {
|
||
const list = this.apps.filter(a => a.update_available).map(a => a.name);
|
||
this.applySelected(list);
|
||
}
|
||
|
||
// Update a chosen subset. Same task and same per-app contract as Update all —
|
||
// each app is snapshotted before it is touched and rolled back on failure —
|
||
// so picking three of eight is a narrower choice, not a lesser guarantee.
|
||
// Filtered against update_available rather than trusted: a selection can
|
||
// outlive the scan that justified it, and asking to update an app that has
|
||
// nothing to apply would spend a snapshot to achieve nothing.
|
||
applySelected(names) {
|
||
const want = new Set(names || []);
|
||
const list = this.apps.filter(a => a.update_available && want.has(a.name)).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…`,
|
||
{ track: { apps: list, verb: 'update' } });
|
||
}
|
||
rollback(app) {
|
||
if (!app || this.inflight.has(app)) return;
|
||
this.dispatch('updater_rollback', { app }, `Rolling ${app} back to its pre-update snapshot…`,
|
||
{ track: { apps: [app], verb: 'rollback' } });
|
||
}
|
||
// 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 || this.inflight.has(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';
|
||
// Display name, not the slug: the dialog now leads with the app's icon, and
|
||
// "Upgrade matrix to…" beside the Matrix logo reads as a different thing.
|
||
const label = (window.getAppDisplayName ? window.getAppDisplayName(app) : null) || a.displayName || app;
|
||
const body = `
|
||
<div class="updater-detail-section">
|
||
<p><strong>${this.escape(label)}</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(label)} 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…`, { track: { apps: [app], verb: 'upgrade' } });
|
||
if (window.showConfirmation) {
|
||
window.showConfirmation(`Upgrade ${label} to ${to}?`, '', go, 'Start upgrade', 'Cancel', 'warning', false, '', body,
|
||
`/core/icons/apps/${app}.svg`);
|
||
} else if (window.confirm(`Upgrade ${label} 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, opts) {
|
||
const route = this.services.tasks && this.services.tasks.route;
|
||
if (route && typeof route.routeAction === 'function') {
|
||
const started = route.routeAction(action, params || {});
|
||
this.toast(note || 'Working…', 'info');
|
||
if (opts && opts.track) this.trackTask(opts.track.apps, opts.track.verb, started);
|
||
} 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');
|
||
}
|
||
}
|
||
|
||
// In-place feedback for a started task. Pressing Update used to produce only
|
||
// a toast: the row did not change, nothing on the page moved, and the work was
|
||
// real but invisible — the button read as though it had done nothing. Now the
|
||
// button itself becomes "Updating…" with a spinner and stays disabled until
|
||
// the task reaches a terminal state, at which point the data is refetched and
|
||
// the row repaints showing the result. (An earlier iteration navigated to the
|
||
// tasks page and back instead; superseded — watching the button beats being
|
||
// moved around, and the tasks page is one click away for whoever wants logs.)
|
||
//
|
||
// Correlated by task id rather than by app name — routeAction resolves to the
|
||
// created task — so a second task started elsewhere cannot end this one's
|
||
// busy state early, and an unrelated app's completion cannot either.
|
||
_busyLabel(verb) {
|
||
return { update: 'Updating…', upgrade: 'Upgrading…', rollback: 'Rolling back…' }[verb] || 'Working…';
|
||
}
|
||
|
||
// One action button, busy-aware. Every renderer builds these through here so
|
||
// a repaint that lands MID-task (the auto-refresh poll, a filter change)
|
||
// reconstructs the busy state instead of silently re-enabling the button.
|
||
actionBtn(app, verb, label, opts) {
|
||
const o = opts || {};
|
||
const f = this.inflight.get(app);
|
||
const busy = !!(f && f.verb === verb);
|
||
// A SIBLING action is held while any task runs on the app — Roll back on an
|
||
// app that is mid-update is not a thing to offer. Disabled but no spinner:
|
||
// the spinner marks the action that is running, not the ones waiting on it.
|
||
const held = !!(f && f.verb !== verb);
|
||
return `<button class="${o.cls || 'updater-btn'}${busy ? ' updater-btn-busy' : ''}"`
|
||
+ ` data-updater-action="${verb}" data-app="${this.escape(app)}"${o.extra || ''}${busy || held ? ' disabled' : ''}>`
|
||
+ `${busy ? `<span class="btn-spin" aria-hidden="true"></span>${this._busyLabel(verb)}` : label}</button>`;
|
||
}
|
||
|
||
// Patch buttons already in the DOM, both on this page and on the fleet
|
||
// Overview (they share the data-updater-action markup). This is what makes
|
||
// the click feel acknowledged INSTANTLY — no waiting for a re-render pass.
|
||
// The original face is stashed on the element so clearing restores it even
|
||
// when no repaint follows (e.g. the dispatch itself failed).
|
||
_paintInflight() {
|
||
document.querySelectorAll('button[data-updater-action][data-app]').forEach((btn) => {
|
||
const f = this.inflight.get(btn.dataset.app);
|
||
const busy = !!(f && f.verb === btn.dataset.updaterAction);
|
||
const held = !!(f && f.verb !== btn.dataset.updaterAction);
|
||
if (held && !btn.dataset.busyOrig) { btn.disabled = true; btn.dataset.held = '1'; }
|
||
else if (!held && btn.dataset.held) { btn.disabled = false; delete btn.dataset.held; }
|
||
if (busy && !btn.dataset.busyOrig) {
|
||
btn.dataset.busyOrig = btn.innerHTML;
|
||
btn.innerHTML = `<span class="btn-spin" aria-hidden="true"></span>${this._busyLabel(btn.dataset.updaterAction)}`;
|
||
btn.classList.add('updater-btn-busy');
|
||
btn.disabled = true;
|
||
} else if (!busy && btn.dataset.busyOrig) {
|
||
btn.innerHTML = btn.dataset.busyOrig;
|
||
delete btn.dataset.busyOrig;
|
||
btn.classList.remove('updater-btn-busy');
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
trackTask(apps, verb, started) {
|
||
const names = (Array.isArray(apps) ? apps : [apps]).filter(Boolean);
|
||
if (!names.length) return;
|
||
names.forEach((n) => this.inflight.set(n, { verb, taskId: null }));
|
||
this._paintInflight();
|
||
const clear = () => names.forEach((n) => this.inflight.delete(n));
|
||
|
||
Promise.resolve(started).then((task) => {
|
||
const id = task && (task.id || task.taskId);
|
||
if (!id) { clear(); this._paintInflight(); return; } // nothing to correlate on
|
||
names.forEach((n) => this.inflight.set(n, { verb, taskId: String(id) }));
|
||
|
||
let done = false;
|
||
const finish = () => {
|
||
if (done) return;
|
||
done = true;
|
||
window.removeEventListener('taskCompleted', onEvent);
|
||
window.removeEventListener('taskUpdated', onEvent);
|
||
clearTimeout(timer);
|
||
};
|
||
const onEvent = (e) => {
|
||
const d = e && e.detail;
|
||
if (!d || String(d.taskId) !== String(id)) return;
|
||
const st = (d.status || (d.task && d.task.status) || '').toLowerCase();
|
||
// taskUpdated also fires mid-run; only a terminal state ends the busy face.
|
||
if (st && !['completed', 'failed', 'cancelled'].includes(st)) return;
|
||
finish();
|
||
clear();
|
||
// Refetch, then repaint: render() no-ops when this page is embedded in
|
||
// the fleet Overview, whose own refresh coordinator repaints its rows.
|
||
this.refreshAll().then(() => { this._paintInflight(); this.render(); });
|
||
};
|
||
window.addEventListener('taskCompleted', onEvent);
|
||
window.addEventListener('taskUpdated', onEvent);
|
||
// A task that never reports terminal must not pin a spinner (or leak two
|
||
// listeners) forever.
|
||
const timer = setTimeout(() => { finish(); clear(); this._paintInflight(); }, 30 * 60 * 1000);
|
||
}).catch(() => { clear(); this._paintInflight(); });
|
||
}
|
||
|
||
|
||
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
|
||
? this.actionBtn(a.name, 'update', 'Update', { cls: 'updater-btn updater-btn-primary' })
|
||
: '';
|
||
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 ? `${this.actionBtn(a.name, 'rollback', 'Roll back')}` : ''}</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>
|
||
${this.actionBtn(a.name, 'upgrade', `Upgrade to ${this.escape(a.newer_version)}`, { extra: ` data-version="${this.escape(a.newer_version)}"` })}</div>`
|
||
: '';
|
||
// Maintenance, which "up to date" cannot express. Phrased as an
|
||
// observation with a date, not an accusation — plenty of small tools are
|
||
// simply finished — but it does say what it means for security, because
|
||
// that is the part a user cannot infer.
|
||
const age = this.imageAgeDays(a);
|
||
const staleLine = this.isStale(a)
|
||
? `<div class="updater-detail-row"><span class="updater-badge sev-medium">possibly unmaintained</span>
|
||
<span class="updater-detail-meta">Upstream last rebuilt this image <strong>${this.fmtAge(age)}</strong> ago
|
||
(${this.escape((a.image_updated_at || '').slice(0, 10))}). It is genuinely up to date — that tag has not moved —
|
||
but an image nobody rebuilds gets no security patches either. Worth checking whether the project is still active,
|
||
or replacing it with a maintained alternative.</span></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}${staleLine}</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 ? `${this.actionBtn(a.name, 'rollback', 'Roll back')}` : ''}</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>`;
|
||
}
|
||
|
||
// Days since this app's image was last rebuilt upstream, or null if unknown
|
||
// (non-Docker-Hub registry, locally built image, or never scanned).
|
||
imageAgeDays(a) {
|
||
const t = a && a.image_updated_at ? Date.parse(a.image_updated_at) : NaN;
|
||
if (!t || isNaN(t)) return null;
|
||
return Math.floor((Date.now() - t) / 86400000);
|
||
}
|
||
|
||
// Has upstream stopped rebuilding this image? This is NOT "an update is
|
||
// waiting" — it is the opposite, and far more insidious: the tag never moves,
|
||
// so the digest never changes, so the app is genuinely "up to date" forever
|
||
// while receiving no security patches at all. Nothing else in the UI can
|
||
// distinguish a healthy stable app from an abandoned one.
|
||
isStale(a) {
|
||
const limit = (this.updates && Number(this.updates.stale_after_days));
|
||
if (!limit || !(limit > 0)) return false; // 0 / missing disables it
|
||
const age = this.imageAgeDays(a);
|
||
return age != null && age >= limit;
|
||
}
|
||
|
||
// "2.8 years" reads better than "1037 days" at this scale.
|
||
fmtAge(days) {
|
||
if (days == null) return 'unknown';
|
||
if (days < 60) return `${days} days`;
|
||
if (days < 730) return `${Math.round(days / 30)} months`;
|
||
return `${(days / 365).toFixed(1)} years`;
|
||
}
|
||
|
||
// 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(this.fmtWindow(win))}` : '';
|
||
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;
|