librelad bae9a79158 feat(webui): central task-refresh registry + close stale-UI gaps
Post-task UI refresh was scattered: every page added its own taskCompleted
listener and hard-coded which actions it cared about, so it was easy to add a
task and forget the refresh (stale UI), with no single place to see the wiring.

Adds TaskRefreshCoordinator (window.taskRefresh): one listener, with dedupe
(the SSE bus + synthetic fallbacks double-fire) and opt-in debounce (bursts
coalesce; per-task handlers run every time). Components now register a refresh
entry; window.taskRefresh.table() is the introspectable "what reloads when" map.

Migrated onto it: apps (install/uninstall/tool/config_update lifecycle +
restore/update/rebuild state), backups (backup/restore/delete), the update
badge, and the admin overview integrity badge. Gaps closed: restore/update/
rebuild now repaint app+service data. (start/stop/restart intentionally omitted —
no live status surface to refresh today; revisit if a running/stopped badge is
added. Storage reclaim/image-rm keep their own in-page refresh.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-28 22:06:39 +01:00

262 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Admin Overview — the Admin area's landing page. An ops/health board (distinct
// from the user Dashboard, which is app-centric): it summarises updates,
// backups, SSH/security and system health from data we already generate, and
// each card links to where you act on it. Renders into #config-section.
class AdminOverview {
constructor(rootId = 'config-section') {
this.rootId = rootId;
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this._bound = false;
}
root() { return document.getElementById(this.rootId); }
async init() {
const r = this.root();
if (r) r.innerHTML = '<div class="admin-page"><div class="backup-empty-state">Loading…</div></div>';
this.bindEvents();
const [upd, verify, backup, ssh, disk, mem, info] = await Promise.all([
this.fetchJson('/data/system/update_status.json'),
this.fetchJson('/data/system/verify_status.json'),
this.fetchJson('/data/backup/generated/dashboard.json'),
this.fetchJson('/data/ssh/access.json'),
this.fetchJson('/data/system/disk_usage.json'),
this.fetchJson('/data/system/memory_usage.json'),
this.fetchJson('/data/system/system_info.json')
]);
this.d = { upd, verify, backup, ssh, disk, mem, info };
this.render();
}
async fetchJson(url) {
try { const r = await fetch(`${url}?t=${Date.now()}`); if (!r.ok) return null; return await r.json(); }
catch { return null; }
}
escape(s) {
return String(s ?? '').replace(/[&<>"']/g, c => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
notify(msg, type) {
if (window.notificationSystem) window.notificationSystem.show(msg, type || 'info');
else console.log(`[admin ${type || 'info'}] ${msg}`);
}
bindEvents() {
if (this._bound) return;
this._bound = true;
document.addEventListener('click', (e) => {
const go = e.target.closest('[data-admin-go]');
if (go) { this.go(go.dataset.adminGo); return; }
if (e.target.closest('[data-admin-update]')) { this.runUpdate(); return; }
if (e.target.closest('[data-admin-verify]')) { this.runVerify(); return; }
});
// When a verify or update task finishes, re-read the integrity status
// and re-render so the badge reflects reality without a manual reload.
// Registered with the task-refresh coordinator (single source of truth).
window.taskRefresh?.register({
id: 'admin-overview',
match: (d) => ['verify', 'update', 'system_update'].includes(d.action)
|| /^libreportal (verify|update)\b/.test((d.task && d.task.command) || d.command || ''),
run: () => this.refreshVerify(),
debounceMs: 1500,
});
}
go(where) {
if (where === 'backup') {
window.spaClean?.navigate('/backup', true);
} else if (where === 'ssh' || where === 'security' || where === 'system') {
const target = where === 'ssh' ? 'ssh-access' : where;
window.history.pushState({}, '', window.adminPath(target));
window.configCategory = target;
window.configManager?.renderConfig?.(target);
}
}
async runUpdate() {
if (!this.taskManager) { this.notify('Task system unavailable', 'error'); return; }
if (!confirm('Update LibrePortal now? The WebUI may restart briefly.')) return;
try { await this.taskManager.createTask('libreportal update apply', 'update', null); }
catch (e) { this.notify(`Failed to start update: ${e.message || e}`, 'error'); }
}
async runVerify() {
if (!this.taskManager) { this.notify('Task system unavailable', 'error'); return; }
try {
await this.taskManager.createTask('libreportal verify', 'verify', null);
this.notify('Verifying installation…', 'info');
} catch (e) { this.notify(`Failed to start verification: ${e.message || e}`, 'error'); }
}
async refreshVerify() {
const verify = await this.fetchJson('/data/system/verify_status.json');
if (this.d) { this.d.verify = verify; this.render(); }
}
/* Map verify_status.json → { kind (dot colour), label, note }. Red (warn)
only for genuine problems (modified/tampered); everything else neutral. */
verifyDisplay(v) {
switch (v && v.state) {
case 'verified': return { kind: 'ok', label: 'Verified', note: 'Files match the signed release' };
case 'modified': return { kind: 'warn', label: 'Modified', note: `${v.files_modified || 0} changed, ${v.files_missing || 0} missing` };
case 'tampered': return { kind: 'warn', label: 'Signature invalid', note: v.error || 'Manifest signature failed' };
case 'unsigned': return { kind: 'none', label: 'Unsigned build', note: 'Matches an unsigned manifest' };
case 'unverifiable': return { kind: 'none', label: 'Cant verify', note: v.error || 'minisign unavailable' };
case 'development': return { kind: 'none', label: 'Development build', note: 'No signed manifest to check' };
default: return null;
}
}
// Integrity readout line: a coloured dot + label, with an honest tooltip
// about the limits of a self-check.
integrityLine(disp) {
const tip = 'Confirms your installed files match the signed release manifest. '
+ 'This is a self-check — for an independent guarantee, verify the release with `minisign -Vm`.';
return `<div class="admin-card-line" title="${this.escape(tip)}">`
+ `<span>Integrity</span>`
+ `<strong><span class="admin-integrity"><span class="admin-status-dot ${disp.kind}"></span>${this.escape(disp.label)}</span></strong>`
+ `</div>`;
}
/* A status card: kind sets the dot colour (ok/warn/none). actionsHtml is
the footer (Manage link / button). */
card(title, kind, lines, actionsHtml) {
return `
<div class="admin-card">
<div class="admin-card-head">
<span class="admin-card-title">${this.escape(title)}</span>
<span class="admin-status-dot ${kind}"></span>
</div>
<div class="admin-card-body">${lines}</div>
<div class="admin-card-actions">${actionsHtml || ''}</div>
</div>`;
}
line(label, value) {
return `<div class="admin-card-line"><span>${this.escape(label)}</span><strong>${this.escape(value)}</strong></div>`;
}
render() {
const root = this.root();
if (!root) return;
const d = this.d || {};
// Updates (+ integrity)
const upd = d.upd || {};
const updAvail = upd.update_available === true;
const vDisp = this.verifyDisplay(d.verify);
const integrityBad = vDisp && vDisp.kind === 'warn';
let updBody = updAvail
? this.line('Status', 'Update available') + this.line('Current → latest', `${upd.current_version || '?'}${upd.latest_version || '?'}`)
: this.line('Status', 'Up to date') + this.line('Version', upd.current_version || '—');
if (vDisp) updBody += this.integrityLine(vDisp);
// Update now takes priority when one's available; otherwise offer Verify now.
const updActions = (updAvail && upd.can_update)
? `<button type="button" class="backup-primary-btn" data-admin-update>Update now</button>`
: `<button type="button" class="backup-secondary-btn" data-admin-verify>Verify now</button>`;
const updCard = this.card(
'Updates',
(updAvail || integrityBad) ? 'warn' : 'ok',
updBody,
updActions
);
// Backups
const b = d.backup || {};
const apps = Array.isArray(b.apps) ? b.apps : [];
const locs = Array.isArray(b.locations) ? b.locations : [];
const protectedApps = apps.filter(a => a.latest_snapshot).length;
const totalSize = locs.reduce((a, r) => a + (parseInt(r.total_size_bytes) || 0), 0);
const noBackups = apps.length && protectedApps === 0;
const backupCard = this.card(
'Backups',
!locs.length ? 'none' : (noBackups ? 'warn' : 'ok'),
this.line('Apps protected', `${protectedApps} / ${apps.length}`)
+ this.line('Locations', String(locs.length))
+ this.line('Stored', this.bytes(totalSize)),
`<button type="button" class="backup-secondary-btn" data-admin-go="backup">Manage backups →</button>`
);
// SSH & Security
const s = d.ssh || {};
const keyCount = Array.isArray(s.keys) ? s.keys.length : 0;
const pwOn = s.password_auth === true;
// Warn when password login is on AND no keys (the weakest posture).
const sshKind = (pwOn && keyCount === 0) ? 'warn' : 'ok';
const sshCard = this.card(
'SSH & Security',
sshKind,
this.line('Password login', pwOn ? 'On' : 'Key-only')
+ this.line('Authorized keys', String(keyCount))
+ this.line('Login user', s.user || '—'),
`<button type="button" class="backup-secondary-btn" data-admin-go="ssh">Manage SSH access →</button>`
);
// System health
const disk = d.disk?.root || {};
const mem = d.mem || {};
const info = d.info || {};
const diskPct = parseInt(disk.percent) || 0;
const sysKind = diskPct >= 90 ? 'warn' : 'ok';
const sysCard = this.card(
'System',
sysKind,
this.line('Disk', disk.text || `${diskPct}%`)
+ this.line('Memory', mem.total
? `${this.gb(mem.used)} / ${this.gb(mem.total)} (${Math.round(mem.percent) || 0}%)`
: (mem.text || '—'))
+ this.line('Uptime', this.shortUptime(info.uptime)),
`<button type="button" class="backup-secondary-btn" data-admin-go="system">View system stats →</button>`
);
root.innerHTML = `
<div class="admin-page">
<div class="page-header config-page-header">
<div class="page-header-title">
<div class="admin-breadcrumb">Admin</div>
<h1>Overview</h1>
<p>System health and admin status at a glance. Manage anything from the cards below.</p>
</div>
</div>
<div class="admin-card-grid">
${updCard}
${backupCard}
${sshCard}
${sysCard}
</div>
</div>`;
}
bytes(n) {
n = parseInt(n) || 0;
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
}
// Compact gigabytes, e.g. 2030043136 → "1.9G", matching the Disk row's style.
gb(n) {
return `${((parseInt(n) || 0) / 1073741824).toFixed(1)}G`;
}
// "up 1 hour, 11 minutes" → "1h 11m".
shortUptime(u) {
return (u || '—')
.replace(/^up\s+/, '')
.replace(/\s*weeks?/g, 'w')
.replace(/\s*days?/g, 'd')
.replace(/\s*hours?/g, 'h')
.replace(/\s*minutes?/g, 'm')
.replace(/,/g, '');
}
}
window.AdminOverview = AdminOverview;