// Admin → System — orchestrator + index view. // // One AdminSystem instance per page mount. Reads the URL path on init and // dispatches to one of four sub-views: // // /admin/system → index (gauges + trends + per-app table) // /admin/system/metric/ → single-metric deep-dive page // /admin/system/app/ → per-container app deep-dive // /admin/system/storage → Docker disk breakdown // // Sub-views are separate page renderers (system-metric-page.js etc.) that // each own their own DOM + lifecycle inside #config-section. We mount one // at a time; switching means tearing down the active renderer and starting // a fresh one. The SPA re-runs handleAdmin() on every navigation, which // re-runs ConfigManager.renderConfig('system') → AdminSystem.init, so the // dispatch happens organically with the router. // // Live (1 Hz, via LiveSystem SSE) data flows directly to whichever sub-view // is mounted. The index view ticks gauges in place; the metric page splices // the live value into its chart's tail. class AdminSystem { constructor(rootId = 'config-section') { this.rootId = rootId; this.range = 60; // minutes of history to chart this._timer = null; this._unsubLive = null; this.d = {}; // Active sub-view renderer. Disposed on each init(). this._subview = null; } root() { return document.getElementById(this.rootId); } // Path → view dispatch. AdminPath base is /admin/system; sub-paths // add segments after that. Falls through to 'index' for an unknown shape // so a typo'd URL doesn't blank the page. _parsePath() { const segs = String(window.location.pathname || '').split('/').filter(Boolean); // Locate 'system' and read the sub-view after it, so dispatch works for // /admin/system// regardless of leading segments. const i = segs.indexOf('system'); const sub = i >= 0 ? segs[i + 1] : undefined; const arg = i >= 0 ? segs[i + 2] : undefined; if (sub === 'metric' && arg) return { view: 'metric', key: decodeURIComponent(arg) }; if (sub === 'app' && arg) return { view: 'app', name: decodeURIComponent(arg) }; if (sub === 'storage') return { view: 'storage' }; return { view: 'index' }; } async init() { // Tear down any previous sub-view first (re-mount across nav). this._stopLive(); if (this._timer) { clearInterval(this._timer); this._timer = null; } if (this._subview && typeof this._subview.dispose === 'function') { try { this._subview.dispose(); } catch (_) {} } this._subview = null; const parsed = this._parsePath(); const r = this.root(); if (!r) return; // Sub-pages live in their own classes; index lives here. if (parsed.view === 'metric' && window.SystemMetricPage) { this._subview = new window.SystemMetricPage(this.rootId); await this._subview.mount(parsed.key); return; } if (parsed.view === 'app') { // Per-app container deep-dive lives on the app page's Services // tab, not under /admin/system. Redirect any direct hit so old // bookmarks still resolve. if (window.navigateToRoute) { window.navigateToRoute(`/app/${encodeURIComponent(parsed.name)}/services`); } return; } if (parsed.view === 'storage' && window.SystemStoragePage) { this._subview = new window.SystemStoragePage(this.rootId); await this._subview.mount(); return; } // Default: index view. r.innerHTML = '
Loading system stats…
'; await this.refresh(); this.bind(); if (window.LiveSystem) { this._unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s)); } this._timer = setInterval(() => { if (!document.querySelector('.sys-page')) { clearInterval(this._timer); this._timer = null; this._stopLive(); return; } this.refresh(); }, 30000); } _stopLive() { if (this._unsubLive) { try { this._unsubLive(); } catch (_) {} this._unsubLive = null; } } async refresh() { const [metrics, history, apps, appsHist, info, storage, appStorage] = await Promise.all([ this.fetchJson('/data/system/metrics.json'), this.fetchJson(`/api/system/history?range=${this.range}`), this.fetchJson('/data/system/metrics_apps.json'), this.fetchJson('/data/system/metrics_apps_history.json'), this.fetchJson('/data/system/system_info.json'), this.fetchJson('/api/system/storage'), this.fetchJson('/data/system/app_storage.json') ]); this.d = { metrics, history, apps, appsHist, info, storage, appStorage }; 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; } } bind() { if (this._bound) return; this._bound = true; document.addEventListener('click', (e) => { if (!document.querySelector('.sys-page')) return; const rb = e.target.closest('[data-sys-range]'); if (rb) { this.range = parseInt(rb.dataset.sysRange) || 60; this.refresh(); return; } // Expand a metric → navigate to its detail page. The SPA picks up // the new URL, ConfigManager re-renders, AdminSystem.init mounts // the metric page. const ex = e.target.closest('[data-sys-expand]'); if (ex) { const k = ex.dataset.sysExpand; if (window.navigateToRoute) window.navigateToRoute(`/admin/system/metric/${encodeURIComponent(k)}`); return; } const ap = e.target.closest('[data-sys-app]'); if (ap) { const name = ap.dataset.sysApp; // App rows jump to the app page's Services tab — the existing // home for per-container detail (logs, restart, IP, ports) // which the new docker-info endpoints now enrich with live // stats, limits, mounts, networks and healthcheck. if (window.navigateToRoute) window.navigateToRoute(`/app/${encodeURIComponent(name)}/services`); return; } const st = e.target.closest('[data-sys-storage]'); if (st) { if (window.navigateToRoute) window.navigateToRoute('/admin/system/storage'); return; } }); } /* ---- formatting helpers (used by sub-pages via window.SystemFmt) ---- */ escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } bytes(n) { n = Number(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]}`; } rate(n) { return `${this.bytes(n)}/s`; } series(key) { const pts = (this.d.history && Array.isArray(this.d.history.points)) ? this.d.history.points : []; return pts.slice(-this.range).map(p => Number(p[key]) || 0); } rangeBtns() { const opts = [[60, '1h'], [360, '6h'], [1440, '24h'], [10080, '7d']]; return `
${opts.map(([m, l]) => `` ).join('')}
`; } chartCard(title, bodyHtml, meta, expandKey) { const exBtn = expandKey ? `` : ''; return `
${title}${meta ? `${meta}` : ''}${exBtn}
${bodyHtml}
`; } _gaugesHtml() { const C = window.LPCharts; const m = this.d.metrics || {}; const cpu = m.cpu || {}, mem = m.memory || {}; const disks = Array.isArray(m.disks) ? m.disks : []; const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {}; const wrap = (key, inner) => ``; // Load is read relative to core count: a load equal to the number of // cores means "fully used, no queue" — normal, not alarming. Only flag // it red once load clearly exceeds capacity (tasks genuinely queuing). // The ring fills toward 2x cores so load == cores sits mid-gauge // instead of maxing out (which read as a constant red alarm on a // low-core box). The old backend load1_percent capped at cores == 100%, // so anything near capacity pinned the gauge red. const cores = cpu.cores || 1; const load1 = Number(cpu.load1 ?? 0); const loadRatio = load1 / cores; const loadColor = loadRatio >= 1.7 ? 'status-danger' : loadRatio >= 1.0 ? 'status-warning' : 'status-success'; // The disk ring colours its leading portion to show the slice that's // LibrePortal (app data on disk + Docker images/cache) — one ring, total // disk used overall, the LibrePortal part highlighted within it. const lpBytes = ((this.d.appStorage && this.d.appStorage.total_local) || 0) + ((this.d.storage && this.d.storage.total) || 0); const diskTotal = Number(rootDisk.total) || 0; const lpPct = diskTotal > 0 ? (lpBytes / diskTotal) * 100 : 0; return ` ${wrap('cpu', C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` }))} ${wrap('mem', C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` }))} ${wrap('disk', C.gauge(rootDisk.percent || 0, { label: 'Disk', segment: { value: lpPct, color: 'accent' } }))} ${wrap('load1', C.gauge(load1, { label: 'Load', display: load1.toFixed(2), suffix: '', max: cores * 2, color: loadColor, sublabel: `1m · ${cpu.load5 ?? '–'}/${cpu.load15 ?? '–'}` }))}`; } _applyLive(s) { if (!s || !document.querySelector('.sys-page')) return; const m = this.d.metrics || {}; this.d.metrics = { ...m, cpu: s.cpu || m.cpu, memory: s.memory || m.memory, disks: Array.isArray(s.disks) && s.disks.length ? s.disks : m.disks, network: s.network || m.network, docker: s.docker || m.docker, updated: new Date(s.t || Date.now()).toISOString() }; const gaugesEl = document.querySelector('.sys-page .sys-gauges'); if (gaugesEl) gaugesEl.innerHTML = this._gaugesHtml(); const subEl = document.querySelector('.sys-page .page-header-title p'); if (subEl) subEl.textContent = `Live host and per-app statistics. Updated ${new Date(s.t || Date.now()).toLocaleTimeString()}.`; } render() { const root = this.root(); if (!root) return; const C = window.LPCharts; const m = this.d.metrics || {}; const cpu = m.cpu || {}, mem = m.memory || {}; const info = this.d.info || {}; const gauges = `
${this._gaugesHtml()}
`; const rx = this.series('net_rx'), tx = this.series('net_tx'); const lastRx = rx[rx.length - 1] || 0, lastTx = tx[tx.length - 1] || 0; const hasSwap = (mem.swap_total || 0) > 0; const charts = `

Trends

${this.rangeBtns()}
${this.chartCard('CPU usage', C.areaChart(this.series('cpu'), { color: 'accent', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'cpu')} ${this.chartCard('Memory usage', C.areaChart(this.series('mem'), { color: 'status-info', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'mem')} ${this.chartCard('Network', C.multiLine([{ values: rx, color: 'status-success' }, { values: tx, color: 'accent' }]) + `
↓ ${this.rate(lastRx)}↑ ${this.rate(lastTx)}
`, 'rx / tx', 'net_rx')} ${this.chartCard('Load (1m)', C.areaChart(this.series('load1'), { color: 'accent', fmt: v => v.toFixed(2) }), `${cpu.cores || '?'} cores`, 'load1')} ${this.chartCard('Disk usage', C.areaChart(this.series('disk'), { color: 'status-success', max: 100, fmt: v => `${Math.round(v)}%` }), 'root %', 'disk')} ${hasSwap ? this.chartCard('Swap usage', C.areaChart(this.series('swap'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'swap') : ''}
`; const infoStrip = `

Host

${this._osStat(info.os)} ${this.stat('Kernel', info.kernel || '—')} ${this.stat('Uptime', (info.uptime || '—').replace(/^up /, ''))} ${this._cpuStat(info.cpu)} ${this.stat('Swap', mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'none')}
`; const apps = (this.d.apps && Array.isArray(this.d.apps.apps)) ? this.d.apps.apps : []; const appsHist = (this.d.appsHist && this.d.appsHist.apps) ? this.d.appsHist.apps : {}; const appsBody = apps.length ? apps.map(a => { const spark = (appsHist[a.app] || []).map(p => Number(p.cpu) || 0); const statusCls = a.status === 'running' ? 'ok' : 'none'; return ` ${this.escape(a.app)} ${a.running}/${a.containers} up ${C.bar(a.cpu_percent)}${(a.cpu_percent || 0).toFixed(1)}% ${C.bar(a.mem_percent)}${this.bytes(a.mem_bytes)} ↓${this.bytes(a.net_rx)} ↑${this.bytes(a.net_tx)} ${C.sparkline(spark, { color: 'accent' })} › `; }).join('') : `No running containers — install an app to see per-app stats.`; const appsTable = `

Per-app usage

click a row to open the app's Services tab · sorted by CPU
${appsBody}
AppCPUMemoryNetworkCPU trend
`; const updated = m.updated ? new Date(m.updated).toLocaleTimeString() : '—'; root.innerHTML = `
${gauges} ${charts} ${this._storageSection()} ${infoStrip} ${appsTable}
`; } // Docker storage summary: the breakdown donut + per-category legend + // reclaimable, promoted onto the index so it's discoverable. Donut/segments // are shared with the full breakdown page; this links through to it. _storageSection() { const dk = (this.d.metrics && this.d.metrics.docker) || {}; const s = this.d.storage; const SP = window.SystemStoragePage; const head = `

Storage

${dk.containers_running ?? 0}/${dk.containers_total ?? 0} running · ${dk.images ?? 0} images
`; if (!s || !s.total || !SP) { const msg = (s && !s.total) ? 'No Docker storage in use yet.' : 'Storage usage unavailable.'; return head + `
${msg}
`; } const C = window.LPCharts; // Summary breakdown: one "Applications" total + Docker images/cache. The // per-app split lives on the full Storage page this links to. const segments = SP.summarySegments(this.d.appStorage, s); const grandTotal = segments.reduce((t, seg) => t + ((seg.data && seg.data.size) || 0), 0); const donut = SP.donutSvg(segments, grandTotal, 'in use'); const recl = s.reclaimable || 0; const reclPct = s.total ? Math.round((recl / s.total) * 100) : 0; const rows = segments.map(seg => { const sz = (seg.data && seg.data.size) || 0; const pct = grandTotal ? (sz / grandTotal) * 100 : 0; return `
${this.escape(seg.label)} ${C.bar(pct, { color: seg.color })} ${this.bytes(sz)}
`; }).join(''); return head + `
${rows}
${this.bytes(recl)} reclaimable${reclPct ? ` · ${reclPct}% of total` : ''}
`; } stat(label, value) { return `
${this.escape(label)}${this.escape(value)}
`; } // OS stat with a distro icon (bundled under /core/icons/os/, generic Linux glyph // for anything we don't have a logo for). _osStat(os) { const slug = String(os || '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'linux'; return `
OS ${this.escape(os || '—')}
`; } // Strip the trademark noise (®, ™, "Intel", "AMD", "CPU") from a CPU model // string — the vendor is shown as a logo, so the words are redundant clutter. _cleanCpu(cpu) { return String(cpu || '') .replace(/\((?:R|TM|r|tm)\)|®|™/g, '') .replace(/\b(?:Intel|AMD)\b/gi, '') .replace(/\bCPU\b/gi, '') .replace(/\s*@\s*/g, ' @ ') .replace(/\s{2,}/g, ' ') .trim() || '—'; } // CPU stat: vendor logo (Intel/AMD) + the cleaned model string. _cpuStat(cpu) { const raw = String(cpu || ''); const vendor = /amd/i.test(raw) ? 'amd' : /intel/i.test(raw) ? 'intel' : null; const icon = vendor ? `${vendor}` : ''; return `
CPU ${icon}${this.escape(this._cleanCpu(raw))}
`; } } // Lightweight formatter helpers shared with sub-pages so they don't each // reimplement bytes()/rate(). Attached as a global so a sub-page that mounts // before AdminSystem still has them. window.SystemFmt = window.SystemFmt || { escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }, bytes(n) { n = Number(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]}`; }, rate(n) { return `${this.bytes(n)}/s`; }, timeAgo(unixSec) { if (!unixSec) return ''; const diff = Math.floor(Date.now() / 1000) - unixSec; if (diff < 60) return `${diff}s ago`; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; return `${Math.floor(diff / 86400)}d ago`; }, timeAgoIso(iso) { if (!iso) return ''; const t = Math.floor(new Date(iso).getTime() / 1000); return Number.isFinite(t) && t > 0 ? this.timeAgo(t) : ''; }, rgbVar(name) { return (getComputedStyle(document.documentElement).getPropertyValue(`--${name}-rgb`) || '').trim() || '0, 212, 255'; }, }; window.AdminSystem = AdminSystem;