Add an Admin Overview as the Admin landing (default when you open Admin): an
ops/health board distinct from the user Dashboard. Four cards built from data
we already generate — Updates (update_status.json, with one-click update),
Backups (backup dashboard.json), SSH & Security (access.json), System
(disk/memory/system_info) — each with a Manage link into the right section.
Styled like the backup dashboard (tiles/status dots).
Wire-up: 'Overview' is the top sidebar item and the default category
(handleConfig + sidebar), rendered by AdminOverview into #config-section via a
renderConfig('overview') special case. Every Admin page now shows the same
'Admin' breadcrumb header (Overview, SSH Access, and the config categories) for
a consistent Admin → Section feel. User Dashboard gets an 'Admin overview →'
link.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
186 lines
7.7 KiB
JavaScript
186 lines
7.7 KiB
JavaScript
// 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, backup, ssh, disk, mem, info] = await Promise.all([
|
|
this.fetchJson('/data/system/update_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, 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; }
|
|
});
|
|
}
|
|
|
|
go(where) {
|
|
if (where === 'backup') {
|
|
window.librePortalSPA?.navigate('/backup', true);
|
|
} else if (where === 'ssh' || where === 'security') {
|
|
const target = where === 'ssh' ? 'ssh-access' : 'security';
|
|
window.history.pushState({}, '', `/config?=${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'); }
|
|
}
|
|
|
|
/* 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
|
|
const upd = d.upd || {};
|
|
const updAvail = upd.update_available === true;
|
|
const updCard = this.card(
|
|
'Updates',
|
|
updAvail ? 'warn' : 'ok',
|
|
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 || '—'),
|
|
updAvail && upd.can_update
|
|
? `<button type="button" class="backup-primary-btn" data-admin-update>Update now</button>`
|
|
: `<span class="admin-card-ok">Nothing to do</span>`
|
|
);
|
|
|
|
// 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.text || '—')
|
|
+ this.line('Uptime', (info.uptime || '—').replace(/^up /, ''))
|
|
+ this.line('OS', info.os || '—'),
|
|
`<span class="admin-card-ok">${diskPct >= 90 ? '⚠ Disk almost full' : 'Healthy'}</span>`
|
|
);
|
|
|
|
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]}`;
|
|
}
|
|
}
|
|
|
|
window.AdminOverview = AdminOverview;
|