librelad a06b6cd1d8 feat(overview): match fleet tab content to the app-detail tab layout
Every fleet Overview tab now follows the per-app detail tab idiom the
rest of the app uses: title + description on the left, action buttons
on the right, a divider underneath, and the body inside the recessed
dark container (.tasks-container recipe).

- renderHeader() gains an action slot; Check/Check now/Update all move
  out of in-body toolbars into the header (Updates keeps its filter
  chips in the body; the Apps-tracked stat card drops its duplicate
  Check button; UpdaterPage.renderImprovements can skip its toolbar).
- String tabs wrap their body in .ov-tab-body — margin/padding 16px,
  rgba(bg,.2) panel — mirroring backup/tasks/updater containers.
- The Backups tab's embedded nested strip (Dashboard/Backups/Locations/
  Configuration) now sits on the same surface as every other tab strip:
  added to the nebula sidebar-bg anchor rule (it was stuck on the
  lighter --hover-bg) and its buttons use .main-tab-button type.

Signed-off-by: librelad <librelad@digitalangels.vip>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:55:10 +01:00

467 lines
26 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;
}
// ---- lifecycle -----------------------------------------------------------
async init() {
this.currentTab = this.parseTabFromUrl() || this.currentTab;
this.applyActiveTabUi(this.currentTab);
this.bindEvents();
await this.refreshAll();
this.render();
this.updateHeader();
}
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;
}
// ---- 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 = '<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 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',
sev.critical || sev.high ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues',
`<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. Run <strong>Check now</strong> to fetch versions & vulnerabilities.</div>`}`;
}
renderUpdates() {
if (!this.apps.length) return this.empty('No installed apps to track.');
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) {
if (!this.artifacts) return this.empty('No hotfix data yet. Run a check to fetch the signed improvements index.', true);
const list = Array.isArray(this.artifacts.artifacts) ? this.artifacts.artifacts : [];
const signed = !!this.artifacts.signed;
if (!list.length) return this.empty('No improvements available right now — you are all caught up. 🎉');
// Map the hotfix severities onto the existing CVE severity colour classes.
const sevClass = { security: 'sev-critical', breakage: 'sev-high', compat: 'sev-medium', tweak: 'sev-low' };
const rows = list.map(a => {
const sv = sevClass[a.severity] || 'sev-low';
const scope = a.app ? this.escape(a.app) : 'system';
const appliedBadge = a.applied ? '<span class="updater-badge updater-badge-ok">applied</span>' : '';
const autoBadge = a.auto ? '<span class="updater-badge updater-badge-update">auto</span>' : '';
const naBadge = a.applicable ? '' : '<span class="updater-badge updater-badge-unknown">not applicable</span>';
let btn = '';
if (a.applied) btn = `<button class="updater-btn" data-updater-action="revert-artifact" data-id="${this.escape(a.id)}">Revert</button>`;
else if (a.applicable && signed) btn = `<button class="updater-btn updater-btn-primary" data-updater-action="apply-artifact" data-id="${this.escape(a.id)}">Apply</button>`;
return `<div class="updater-row">
<div class="updater-row-main"><span class="updater-row-name">${this.escape(a.title || a.id)}</span>
<span class="updater-badge ${sv}">${this.escape(a.severity || 'tweak')}</span>
<span class="updater-badge updater-badge-unknown">${scope}</span>
${appliedBadge} ${autoBadge} ${naBadge}</div>
<div class="updater-row-ver">${this.escape(a.why || '')}</div>
<div class="updater-row-actions">${btn}</div>
</div>`;
}).join('');
const banner = signed
? `<div class="updater-hint">Small, signed, individually-reversible improvements curated by the LibrePortal team. Security &amp; breakage fixes apply automatically (a snapshot is taken first); the rest are one click. Every apply is logged in History and can be reverted.</div>`
: `<div class="updater-hint">⚠ The improvements index is <strong>unsigned</strong> (signing not activated on this build) — applying is disabled for safety.</div>`;
return `${banner}
${withToolbar ? `<div class="updater-toolbar"><button class="updater-btn" data-updater-action="check">↻ Check for improvements</button></div>` : ''}
<div class="updater-list">${rows}</div>`;
}
renderSecurity() {
const withCves = this.apps.filter(a => (a.cves || []).length);
if (!this.cves) return this.empty('No vulnerability scan yet. Run a check to scan your app images for known CVEs.', true);
if (!withCves.length) return this.empty('No known vulnerabilities in your installed apps. 🎉');
const blocks = withCves.map(a => {
const items = (a.cves || []).map(c => `
<div class="updater-cve sev-${(c.severity || 'low').toLowerCase()}">
<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>
${c.fixed_in ? `<span class="updater-cve-fix">fixed in ${this.escape(c.fixed_in)}</span>` : ''}
</div>`).join('');
return `<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>${items}</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></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>`);
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>`;
}
const cves = a.cves || [];
const cveItems = cves.map((c) => `
<div class="updater-cve sev-${(c.severity || 'low').toLowerCase()}">
<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>
${c.fixed_in ? `<span class="updater-cve-fix">fixed in ${this.escape(c.fixed_in)}</span>` : ''}
</div>`).join('');
const security = `<div class="updater-detail-section"><h4>Security</h4>${
cves.length ? cveItems : '<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>
<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>`;
}
empty(msg, withCheck) {
return `<div class="updater-empty">${this.escape(msg)}${withCheck ? `<div><button class="updater-btn updater-btn-primary" data-updater-action="check">Check now</button></div>` : ''}</div>`;
}
// ---- utils ---------------------------------------------------------------
escape(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
fmtRel(ts) {
if (!ts) return 'never';
const t = typeof ts === 'number' ? ts : Date.parse(ts);
if (!t || isNaN(t)) return this.escape(String(ts));
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
}
window.UpdaterPage = UpdaterPage;