// Admin → System — in-depth host + per-app statistics. Live ring gauges for the // headline numbers, SVG trend charts driven by the metrics history ring buffer, // a Docker summary, and a per-app resource table. Data comes from the // frontend/data/system/*.json files the webui_system_metrics generator refreshes // every minute; this page just polls and draws. Renders into #config-section. class AdminSystem { constructor(rootId = 'config-section') { this.rootId = rootId; this.range = 60; // minutes of history to chart this._timer = null; this.d = {}; } root() { return document.getElementById(this.rootId); } async init() { const r = this.root(); if (r) r.innerHTML = '
Loading system stats…
'; await this.refresh(); this.bind(); // Data regenerates ~1/min; poll at 30s so the view tracks it without // hammering. Stop once the user has navigated off this page. if (this._timer) clearInterval(this._timer); this._timer = setInterval(() => { if (!document.querySelector('.sys-page')) { clearInterval(this._timer); this._timer = null; return; } this.refresh(); }, 30000); } async refresh() { const [metrics, history, apps, appsHist, info] = await Promise.all([ this.fetchJson('/data/system/metrics.json'), this.fetchJson('/data/system/metrics_history.json'), this.fetchJson('/data/system/metrics_apps.json'), this.fetchJson('/data/system/metrics_apps_history.json'), this.fetchJson('/data/system/system_info.json') ]); this.d = { metrics, history, apps, appsHist, 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; } } bind() { if (this._bound) return; this._bound = true; document.addEventListener('click', (e) => { const rb = e.target.closest('[data-sys-range]'); if (rb && document.querySelector('.sys-page')) { this.range = parseInt(rb.dataset.sysRange) || 60; this.render(); } }); } /* ---- formatting helpers ---- */ 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`; } // Last N minutes of a given key from the history ring buffer. 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']]; return `
${opts.map(([m, l]) => `` ).join('')}
`; } chartCard(title, bodyHtml, meta) { return `
${title}${meta ? `${meta}` : ''}
${bodyHtml}
`; } render() { const root = this.root(); if (!root) return; const C = window.LPCharts; const m = this.d.metrics || {}; const cpu = m.cpu || {}, mem = m.memory || {}, net = m.network || {}, dk = m.docker || {}; const info = this.d.info || {}; const disks = Array.isArray(m.disks) ? m.disks : []; const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {}; // Gauges const gauges = `
${C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` })} ${C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` })} ${C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: rootDisk.mount || '/' })} ${C.gauge(cpu.load1_percent || 0, { label: 'Load', display: (cpu.load1 ?? 0), suffix: '', sublabel: `1m · ${cpu.load5 ?? '–'}/${cpu.load15 ?? '–'}` })}
`; // Trend charts 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 charts = `

Trends

${this.rangeBtns()}
${this.chartCard('CPU usage', C.areaChart(this.series('cpu'), { color: 'accent', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %')} ${this.chartCard('Memory usage', C.areaChart(this.series('mem'), { color: 'status-info', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %')} ${this.chartCard('Disk usage', C.areaChart(this.series('disk'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), rootDisk.mount || '/')} ${this.chartCard('Network', C.multiLine([{ values: rx, color: 'status-success' }, { values: tx, color: 'accent' }]) + `
↓ ${this.rate(lastRx)}↑ ${this.rate(lastTx)}
`, 'rx / tx')}
`; // Host info + swap + docker summary const infoStrip = `
${this.stat('OS', info.os || '—')} ${this.stat('Kernel', info.kernel || '—')} ${this.stat('Uptime', (info.uptime || '—').replace(/^up /, ''))} ${this.stat('CPU', info.cpu || '—')} ${this.stat('Swap', mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'none')}
`; const dockerStrip = `

Docker

${this.stat('Containers running', `${dk.containers_running ?? 0} / ${dk.containers_total ?? 0}`)} ${this.stat('Images', String(dk.images ?? 0))} ${this.stat('Volumes', String(dk.volumes ?? 0))} ${this.stat('Mounts', String(disks.length))}
`; // Per-app table 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

sorted by CPU
${appsBody}
AppCPUMemoryNetworkCPU trend
`; const updated = m.updated ? new Date(m.updated).toLocaleTimeString() : '—'; root.innerHTML = `
${gauges} ${charts} ${infoStrip} ${dockerStrip} ${appsTable}
`; } stat(label, value) { return `
${this.escape(label)}${this.escape(value)}
`; } } window.AdminSystem = AdminSystem;