librelad d39852aa3d refactor(webui): reorganize into components/ + core/ taxonomy
Final modularization layout (user-chosen): every page is a self-contained
folder under components/<id>/ (controllers + CSS + its html fragment), and all
shared/framework code folds into core/:
  core/kernel  (feature-registry, lifecycle, services, spa)
  core/boot    (auth, system-loader/orchestrator, setup, loaders)
  core/lib     (data-loader, router, helpers, the task kernel, shared modules)
  core/ui      (topbar, modal, notifications, … + topbar.html)
  core/css     (all shared stylesheets)
  core/icons
Top level is now just: components/, core/, themes/, index.html (+ runtime data/).

Every path reference rewritten (index.html, scripts arrays, fetch()/
loadFragment()/loadScript() literals, system-loader + config-manager controller
paths, kernel manifest URL, feature.json, backend FEATURES_DIR). The
/api/features/list endpoint NAME is unchanged (it now scans components/).
Deleted 3 dead files (app-content.html, apps-content.html, html-cache.js).
Verified: 0 stale prefixes, 0 double-rewrites, all JS/JSON valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-30 07:13:52 +01:00

278 lines
14 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>`;
}
// 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;