// 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 = '
Loading…
'; 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 => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: 'Can’t 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 `
` + `Integrity` + `${this.escape(disp.label)}` + `
`; } /* A status card: kind sets the dot colour (ok/warn/none). actionsHtml is the footer (Manage link / button). */ card(title, kind, lines, actionsHtml) { return `
${this.escape(title)}
${lines}
${actionsHtml || ''}
`; } line(label, value) { return `
${this.escape(label)}${this.escape(value)}
`; } // Small inline icon for a card action button. Inherits the button's text // colour (no per-button colour) — the icon + the card's status dot do the // distinguishing, so the footer stays calm. icon(name) { const paths = { update: '', verify: '', backup: '', ssh: '', system: '', }; return ``; } 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. // Update now = updates blue (primary); Verify = green (its own convention). const updActions = (updAvail && upd.can_update) ? `` : ``; 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)), `` ); // 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 || '—'), `` ); // 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)), `` ); root.innerHTML = `
${updCard} ${backupCard} ${sshCard} ${sysCard}
`; } 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;