// components/updater/js/updater-page.js — App Updater controller. // // Surfaces, per installed app: version state (current -> available), security // posture (CVEs by severity), and disaster-recovery readiness (a snapshot is // taken before every update so any update is reversible). Read-only data comes // from /data/updater/generated/*.json (written host-side by the updater // generator); every action is dispatched through the task system via // services.tasks.route(...) — the same locked-down mutation path apps/backups // use. No mutating API is added. class UpdaterPage { constructor(services) { this.services = services || (window.LP && window.LP.services) || {}; this.currentTab = 'overview'; this.updates = null; // { generated_at, apps: [...] } this.cves = null; // { generated_at, apps: [...], totals: {...} } this.history = null; // { entries: [...] } this.artifacts = null; // { signed, serial, artifacts: [...] } (hotfixes) this.apps = []; // merged per-app view rendered in the table this._pushedAnyTab = false; this._eventBound = false; this._poll = null; // auto-refresh timer (startAutoRefresh/dispose) } // ---- lifecycle ----------------------------------------------------------- async init() { this.currentTab = this.parseTabFromUrl() || this.currentTab; this.applyActiveTabUi(this.currentTab); this.bindEvents(); await this.refreshAll(); this.render(); this.updateHeader(); // Keep the open page in step with the host-side auto-scan (the task // processor refreshes the generated JSON on its own schedule, with no task // event to hook). Repaints only when the data actually changed. this.startAutoRefresh( () => this.render(), () => window.updaterPage === this && !!document.getElementById('updater-page') ); } // The host auto-scan (`libreportal updater check auto`, run from the task // processor's idle poll) rewrites the generated JSON without a task event, // so an open page re-reads it on a slow timer: a few static-file GETs per // minute, and onChange() only when a generated_at stamp moved. isActive lets // the hosting surface skip ticks while it isn't visible; dispose() releases // the timer (also called by the feature unmount). startAutoRefresh(onChange, isActive) { if (this._poll) return; this._poll = setInterval(() => { if (document.hidden || (isActive && !isActive())) return; const before = this._dataStamp(); this.refreshAll().then(() => { if (this._poll && this._dataStamp() !== before && onChange) onChange(); }); }, 60000); } dispose() { if (this._poll) { clearInterval(this._poll); this._poll = null; } } // Cheap change detector for the auto-refresh: the generators stamp each file // with generated_at; history has no stamp, so its newest entry stands in. _dataStamp() { const g = (d) => (d && d.generated_at) || ''; const h = this.history && this.history.entries && this.history.entries[0]; return [g(this.updates), g(this.cves), g(this.artifacts), (h && h.ts) || ''].join('|'); } parseTabFromUrl() { const allowed = new Set(['overview', 'updates', 'improvements', 'security', 'recovery', 'history']); const seg = window.location.pathname.replace(/^\/updater\/?/, '').split('/')[0]; if (seg && allowed.has(seg)) return seg; return null; } bindEvents() { if (this._eventBound) return; this._eventBound = true; // Repaint when an updater/backup task completes (debounced via the // coordinator). Self-guards against a torn-down page. this.services.tasks && this.services.tasks.refresh && this.services.tasks.refresh.register({ id: 'updater', match: (d) => /^(updater_|artifact_|libreportal\s+(updater|artifact))/.test((d && (d.action || (d.task && d.task.command))) || ''), run: () => { if (window.updaterPage === this && document.getElementById('updater-page')) { return this.refreshAll().then(() => this.render()); } }, debounceMs: 800, }); // Delegated click handling on the whole layout (sidebar is a sibling of the // page card, both inside .updater-layout). The element is replaced on // navigation, so the listener is GC'd with it — no cross-page leak. const root = document.querySelector('.updater-layout'); if (!root) return; root.addEventListener('click', (e) => { const tabBtn = e.target.closest('.sidebar .category[data-updater-tab]'); if (tabBtn) { this.switchTab(tabBtn.dataset.updaterTab); return; } const action = e.target.closest('[data-updater-action]'); if (!action) return; const app = action.dataset.app || null; switch (action.dataset.updaterAction) { case 'check': this.checkForUpdates(); break; case 'update': this.applyUpdate(app); break; case 'update-all': this.applyAll(); break; case 'rollback': this.rollback(app); break; case 'apply-artifact': this.applyArtifact(action.dataset.id); break; case 'revert-artifact': this.revertArtifact(action.dataset.id); break; case 'goto': this.switchTab(action.dataset.tab); break; } }); } applyActiveTabUi(tab) { document.querySelectorAll('.updater-layout .sidebar .category[data-updater-tab]').forEach(b => { b.classList.toggle('active', b.dataset.updaterTab === tab); }); document.querySelectorAll('.updater-tabpanel').forEach(p => { p.classList.toggle('active', p.id === `updater-panel-${tab}`); }); } switchTab(tab) { if (!tab || tab === this.currentTab) return; this.currentTab = tab; this.applyActiveTabUi(tab); this.updateHeader(); this.render(); const url = `/updater/${tab}`; if (!this._pushedAnyTab) { window.history.replaceState({ route: url }, '', url); this._pushedAnyTab = true; } else { window.history.pushState({ route: url }, '', url); } } // ---- data ---------------------------------------------------------------- async refreshAll() { const get = (url) => fetch(url, { cache: 'no-store' }).then(r => r.ok ? r.json() : null).catch(() => null); const [u, c, h, av] = await Promise.all([ get('/data/updater/generated/updates.json'), get('/data/updater/generated/cves.json'), get('/data/updater/generated/history.json'), get('/data/updater/generated/artifacts_available.json'), ]); this.updates = u; this.cves = c; this.history = h; this.artifacts = av; this.apps = this.mergeApps(); } // Build the per-app view. Prefer the generator's updates.json; otherwise fall // back to the installed-apps list (window.apps / DataLoader) so the page is // still useful before the first scan ("status unknown — run a check"). mergeApps() { const cveByApp = {}; if (this.cves && Array.isArray(this.cves.apps)) { for (const a of this.cves.apps) cveByApp[a.name] = a.cves || []; } let base = (this.updates && Array.isArray(this.updates.apps)) ? this.updates.apps : null; if (!base) { const installed = (window.apps || []).filter(a => a && (a.status === 1 || a.installed || a.is_installed)); base = installed.map(a => ({ name: a.name || a.app_name, displayName: a.displayName || a.title || a.name || a.app_name, current_version: a.version || null, available_version: null, update_available: false, scanned: false, })); } return base.map(a => { const cves = cveByApp[a.name] || []; const sev = this.worstSeverity(cves); return Object.assign({ displayName: a.displayName || a.name, scanned: a.scanned !== false }, a, { cves, worstSeverity: sev }); }); } worstSeverity(cves) { const order = ['critical', 'high', 'medium', 'low']; for (const s of order) if (cves.some(c => (c.severity || '').toLowerCase() === s)) return s; return null; } sevRank(s) { const r = { critical: 0, high: 1, medium: 2, low: 3 }; return r[(s || '').toLowerCase()] ?? 4; } // One CVE list, shared by the standalone Security tab and the per-app expander. // Sorted worst-first (so the most severe are visible before any scroll) and // wrapped in a height-capped scroll box once the list is long, so an app with // dozens of CVEs (e.g. 28) stays compact instead of pushing the page down. // A CVE is actionable when upstream ships a fixed version AND hasn't flagged // it won't-fix / EOL — those are the ones an image rebuild (i.e. updating the // app) can actually clear. The rest are noise the user can only wait on. cveHasFix(c) { const st = ((c && c.status) || '').toLowerCase(); return !!(c && c.fixed_in) && st !== 'will_not_fix' && st !== 'end_of_life'; } // Human label for where a CVE lives: an OS package baked into the base image // vs one of the app's own bundled dependencies (Go module, npm, …). Sourced // from Trivy's Class/Type; empty when the scan predates capturing them (so we // show no tag rather than a wrong one). cveOrigin(c) { const cls = ((c && c.class) || '').toLowerCase(); const ty = ((c && c.type) || '').toLowerCase(); if (cls === 'os-pkgs') return 'OS package'; if (cls === 'lang-pkgs') { if (/go/.test(ty)) return 'Go dependency'; if (/(node|npm|yarn|pnpm)/.test(ty)) return 'npm dependency'; if (/(python|pip|poetry|conda)/.test(ty)) return 'Python dependency'; if (/(gem|bundler|ruby)/.test(ty)) return 'Ruby dependency'; if (/(cargo|rust)/.test(ty)) return 'Rust dependency'; if (/(jar|pom|gradle|java)/.test(ty)) return 'Java dependency'; if (/(composer|php)/.test(ty)) return 'PHP dependency'; return 'App dependency'; } return ''; } cveRow(c) { const sev = (c.severity || 'low').toLowerCase(); const origin = this.cveOrigin(c); const ver = this.cveHasFix(c) ? `${this.escape(c.installed || '?')} ${this.escape(c.fixed_in)}` : `no patch`; return `
${this.escape((c.severity || '').toUpperCase())} ${this.escape(c.id || 'CVE')} ${this.escape(c.package || '')} ${origin ? `${this.escape(origin)}` : ''} ${ver}
`; } 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 ? `
${title}${ hint ? `${hint}` : ''}${rows.length}
${ rows.map((c) => this.cveRow(c)).join('')}
` : ''; const body = group('Patch released upstream', fixHint, '', fixable) + group('No patch yet', 'waiting on upstream', ' is-nofix', noFix); return `
${body}
`; } // 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…`); } 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 = ''; } } 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' ? `
🛡️ Trivy is installed and downloading its vulnerability database — CVE results will fill in here automatically shortly.
` : (st === 'absent' ? `
🛡️ CVE scanning is off — add Trivy from the App Center to see vulnerabilities here.
` : ''); const card = (hue, big, label, sub, action) => `
${big}
${label}
${sub ? `
${sub}
` : ''} ${action || ''}
`; return `
${card('updates', c.updatesAvailable, 'Updates available', c.updatesAvailable ? 'across your apps' : "you're current", ``)} ${card('verify', c.totalCves, 'Known CVEs', cveSub, ``)} ${card('setup', c.improvements, 'Improvements', c.improvements ? 'signed hotfixes to apply' : 'nothing pending', ``)} ${card('backups', `${c.drReady}/${c.apps}`, 'Recovery-ready', 'snapshot taken before each update', ``)} ${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`, ``)}
${this.updates ? '' : `
No scan data yet — showing your installed apps. The first automatic scan runs within a couple of minutes, or hit Check now.
`} ${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 ? `update` : (a.scanned ? `up to date` : `unscanned`); const sev = a.worstSeverity ? `${a.worstSeverity}` : ''; const btn = a.update_available ? `` : ''; return `
${this.escape(a.displayName)} ${badge} ${sev}
${cur}${avail ? ` ${avail}` : ''}
${btn}
`; }).join(''); const anyUpdate = this.apps.some(a => a.update_available); return `
${anyUpdate ? `` : ''}
${rows}
`; } // 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 `
${this.escape(a.title || a.id)} ${this.escape(a.severity || 'tweak')} ${scope} ${appliedBadge} ${autoBadge} ${naBadge}
${this.escape(a.why || '')}
${btn}
`; }).join(''); const banner = signed ? `
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 => `
${this.escape(a.displayName)} ${(a.cves || []).length}
${this.renderCveList(a.cves, { updateAvailable: a.update_available })}
` ).join(''); return `
${blocks}
`; } 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 `
${this.escape(a.displayName)} ${can ? 'recoverable' : 'protected'}
${snap}
${can ? `` : ''}
`; }).join(''); return `
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.
${rows}
`; } renderHistory() { const entries = (this.history && this.history.entries) || []; if (!entries.length) return this.empty('No update activity yet.'); const rows = entries.map(e => `
${this.escape(e.app)} ${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}
${this.escape(e.from || '')}${e.to ? ` ${this.escape(e.to)}` : ''}
${this.fmtRel(e.ts)}
`).join(''); return `
${rows}
`; } // Per-app detail body — composes one app's security (CVEs), recovery point, // and recent history. Pure HTML string with no DOM assumptions, so it is reused // verbatim as the fleet Updates expander body (overview-manager.js) AND the // per-app Updater tab. Action buttons keep the data-updater-action/data-app // contract, so whichever delegated handler is in scope drives them. renderAppDetail(app, opts = {}) { const a = app || {}; // Optional leading "Version" section — the per-app Updates tab shows the // current/available version + status badge as a section in the panel. The // fleet rows omit it (the row head already shows the version). let versionSection = ''; if (opts.includeVersion) { const cur = this.escape(a.current_version || a.current_image || '—'); const avail = a.update_available ? this.escape(a.available_version || a.available_image || 'newer') : null; const badge = a.update_available ? `update available` : (a.scanned ? `up to date` : `unscanned`); versionSection = `

Version

${badge} ${cur}${avail ? ` ${avail}` : ''}
`; } const cves = a.cves || []; const appLabel = this.escape((window.getAppDisplayName ? window.getAppDisplayName(a.name) : null) || a.displayName || a.name || 'the app'); const verLabel = a.current_version ? ` ${this.escape(a.current_version)}` : ''; // Frame actionability by whether an app update actually EXISTS — not by // Trivy's fixed_in. An upstream package patch does the user no good while // they're already on the newest image; it only lands when the maintainer // rebuilds. Telling them to "update to clear these" in that state is a lie. const statusLine = a.update_available ? 'An update is available — installing it (a snapshot is taken first) may pull in patched packages.' : `You're on the latest published version, so there's nothing to apply. These clear when ${appLabel}'s maintainer ships a rebuilt image.`; const secIntro = cves.length ? `

Vulnerabilities in the packages bundled inside this app's image — not ${appLabel}${verLabel} itself.

${statusLine}

` : ''; const security = `

Security${ cves.length ? ` ${cves.length}` : ''}

${ cves.length ? `${secIntro}
${this.renderCveList(cves, { updateAvailable: a.update_available })}
` : '

No known CVEs. 🎉

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

Recovery

${can ? 'recoverable' : 'protected'} ${snap} ${can ? `` : ''}
`; const entries = ((this.history && this.history.entries) || []).filter((e) => e.app === a.name).slice(0, 8); const history = entries.length ? `

History

${entries.map((e) => `
${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''} ${this.escape(e.from || '')}${e.to ? ` → ${this.escape(e.to)}` : ''} ${this.fmtRel(e.ts)}
`).join('')}
` : ''; return `
${versionSection}${security}${recovery}${history}
`; } empty(msg, withCheck) { return `
${this.escape(msg)}${withCheck ? `
` : ''}
`; } // ---- 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}` + `
`; } } window.UpdaterPage = UpdaterPage;