- features/admin/: the 10 admin-owned config controllers, the 5 admin pages
(overview/system/charts/metric/storage), ssh-page.js, peers-page.js, plus
admin.css/ip-whitelist.css/ssh.css (eager). config-manager.js kept last in
the load order (it news the sub-managers).
- shared/js/: config-shared.js + config-options.js (ConfigShared/ConfigOptions
globals consumed cross-feature by backup/apps/tasks).
- shared/css/: forms.css + config.css (generic form + config-form primitives
borrowed by apps/backup/admin).
- Updated all path strings in system-loader.js (config component) and
config-manager.js (lazyLoad of admin/ssh/peers controllers); index.html CSS
hrefs. No /js/components/{config,admin,ssh,peers}/ refs remain.
js/components/ now holds only shared UI (topbar, notifications, eo-modal,
update-notifier, mobile-menu, confirmation-dialog).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
278 lines
14 KiB
JavaScript
278 lines
14 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, 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 `<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>`;
|
||
}
|
||
|
||
// 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: '<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/>',
|
||
verify: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/>',
|
||
backup: '<rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8"/><line x1="10" y1="12" x2="14" y2="12"/>',
|
||
ssh: '<circle cx="7.5" cy="15.5" r="4.5"/><path d="M10.6 12.4 20 3l1.5 1.5L20 6l1.5 1.5L19 10l-2-2"/>',
|
||
system: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
|
||
};
|
||
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
|
||
}
|
||
|
||
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)
|
||
? `<button type="button" class="admin-action-btn is-primary" style="--page:var(--page-updates);--page-rgb:var(--page-updates-rgb)" data-admin-update>${this.icon('update')}Update now</button>`
|
||
: `<button type="button" class="admin-action-btn" style="--page:var(--page-verify);--page-rgb:var(--page-verify-rgb)" data-admin-verify>${this.icon('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="admin-action-btn" style="--page:var(--page-backups);--page-rgb:var(--page-backups-rgb)" data-admin-go="backup">${this.icon('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="admin-action-btn" style="--page:var(--page-ssh);--page-rgb:var(--page-ssh-rgb)" data-admin-go="ssh">${this.icon('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="admin-action-btn" style="--page:var(--page-system);--page-rgb:var(--page-system-rgb)" data-admin-go="system">${this.icon('system')}View system stats →</button>`
|
||
);
|
||
|
||
root.innerHTML = `
|
||
<div class="admin-page">
|
||
<div class="page-header config-page-header">
|
||
<div class="page-header-icon-slot"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"></rect><rect x="14" y="3" width="7" height="5"></rect><rect x="14" y="12" width="7" height="9"></rect><rect x="3" y="16" width="7" height="5"></rect></svg></div>
|
||
<div class="page-header-title">
|
||
<div class="admin-breadcrumb">Admin</div>
|
||
<h1>Dashboard</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;
|