feat(webui): Admin System page with gauges, trend charts & per-app stats

New 'System' admin page (sidebar Tools group) rendering the metrics the
collector now produces:
- live ring gauges for CPU, memory, disk and load
- SVG trend charts (CPU/mem/disk/network) with 1h/6h/24h range toggle
- host info + swap + docker summary strips
- per-app table: CPU/mem bars, network, status, CPU sparkline

Charts are hand-rolled SVG in charts.js (LPCharts) — no third-party libs or
CDN calls — themed entirely from the active theme's CSS variables. The
Overview System card now links here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-24 16:47:20 +01:00
parent a09cf4e0e8
commit 62f7a84126
7 changed files with 586 additions and 3 deletions

View File

@ -98,3 +98,219 @@
text-decoration: none;
}
.dashboard-admin-link:hover { text-decoration: underline; }
/* ============================================================
Admin System (in-depth statistics page)
============================================================ */
.sys-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 26px 0 12px;
}
.sys-section-head h2 {
font-size: 1.05rem;
font-weight: 700;
margin: 0;
}
.sys-chart-meta {
font-size: 0.78rem;
color: rgba(var(--text-rgb), 0.5);
}
/* Range selector (1h / 6h / 24h) */
.sys-range { display: inline-flex; gap: 4px; }
.sys-range-btn {
padding: 4px 12px;
font-size: 0.78rem;
font-weight: 600;
color: rgba(var(--text-rgb), 0.7);
background: rgba(var(--text-rgb), 0.06);
border: 1px solid rgba(var(--text-rgb), 0.12);
border-radius: 999px;
cursor: pointer;
transition: background .15s ease, border-color .15s ease, color .15s ease;
}
.sys-range-btn:hover { background: rgba(var(--accent-rgb), 0.15); }
.sys-range-btn.active {
color: var(--text-primary);
background: rgba(var(--accent-rgb), 0.22);
border-color: rgba(var(--accent-rgb), 0.55);
}
/* Gauges row */
.sys-gauges {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-top: 10px;
}
.lp-gauge {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 14px 12px 10px;
background: var(--card-bg);
border: 1px solid rgba(var(--text-rgb), 0.10);
border-radius: 12px;
}
.lp-gauge-svg { width: 116px; height: 116px; display: block; }
.lp-gauge-center {
position: absolute;
top: 72px;
left: 0; right: 0;
transform: translateY(-50%);
text-align: center;
pointer-events: none;
}
.lp-gauge-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.lp-gauge-value span { font-size: 0.8rem; font-weight: 600; opacity: 0.6; margin-left: 1px; }
.lp-gauge-sub { font-size: 0.68rem; color: rgba(var(--text-rgb), 0.5); margin-top: 3px; }
.lp-gauge-label {
margin-top: 6px;
font-size: 0.82rem;
font-weight: 600;
color: rgba(var(--text-rgb), 0.75);
}
/* Trend chart cards */
.sys-charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.sys-chart-card {
background: var(--card-bg);
border: 1px solid rgba(var(--text-rgb), 0.10);
border-radius: 12px;
padding: 14px 16px 10px;
}
.sys-chart-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 0.88rem;
font-weight: 700;
margin-bottom: 8px;
}
.sys-chart-head .sys-chart-meta { font-weight: 500; }
.sys-chart-body { position: relative; }
.lp-chart { width: 100%; height: 92px; display: block; overflow: visible; }
.lp-chart-last {
position: absolute;
top: 0; right: 0;
font-size: 0.85rem;
font-weight: 700;
}
.lp-chart-empty {
height: 92px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.82rem;
color: rgba(var(--text-rgb), 0.4);
}
.sys-net-legend {
display: flex;
gap: 16px;
margin-top: 6px;
font-size: 0.8rem;
color: rgba(var(--text-rgb), 0.7);
}
.sys-net-legend .dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 5px;
}
.sys-net-legend .dot.ok { background: var(--status-success); }
.sys-net-legend .dot.accent { background: var(--accent); }
/* Info / docker stat strips */
.sys-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.sys-stat {
display: flex;
flex-direction: column;
gap: 3px;
padding: 12px 14px;
background: var(--card-bg);
border: 1px solid rgba(var(--text-rgb), 0.10);
border-radius: 10px;
}
.sys-stat-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(var(--text-rgb), 0.45);
}
.sys-stat-value {
font-size: 0.92rem;
font-weight: 600;
color: var(--text-primary);
word-break: break-word;
}
/* Per-app table */
.sys-apps-wrap {
background: var(--card-bg);
border: 1px solid rgba(var(--text-rgb), 0.10);
border-radius: 12px;
overflow: hidden;
}
table.sys-apps {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
table.sys-apps th {
text-align: left;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(var(--text-rgb), 0.45);
padding: 12px 14px;
border-bottom: 1px solid rgba(var(--text-rgb), 0.10);
}
table.sys-apps td {
padding: 11px 14px;
border-bottom: 1px solid rgba(var(--text-rgb), 0.06);
vertical-align: middle;
color: var(--text-primary);
}
table.sys-apps tr:last-child td { border-bottom: none; }
table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
.sys-app-name { font-weight: 600; display: flex; align-items: center; gap: 8px; }
.sys-app-sub { font-size: 0.72rem; font-weight: 500; color: rgba(var(--text-rgb), 0.45); }
.sys-cell-val { font-size: 0.78rem; color: rgba(var(--text-rgb), 0.65); margin-left: 2px; }
.sys-net-cell { font-size: 0.78rem; color: rgba(var(--text-rgb), 0.7); white-space: nowrap; }
.sys-spark-cell { width: 110px; }
.sys-apps-empty {
text-align: center;
color: rgba(var(--text-rgb), 0.45);
padding: 24px 14px !important;
}
/* Bars + sparklines (shared by LPCharts) */
.lp-bar {
display: inline-block;
width: 90px;
max-width: 40%;
height: 6px;
border-radius: 3px;
background: rgba(var(--text-rgb), 0.12);
overflow: hidden;
vertical-align: middle;
}
.lp-bar-fill { display: block; height: 100%; border-radius: 3px; transition: width .4s ease; }
.lp-spark { width: 100px; height: 24px; display: block; }

View File

@ -100,7 +100,9 @@
<script src="/js/components/backup/backup-page.js"></script>
<script src="/js/components/backup/backup-app-card.js"></script>
<script src="/js/components/ssh/ssh-page.js"></script>
<script src="/js/components/admin/charts.js"></script>
<script src="/js/components/admin/admin-overview.js"></script>
<script src="/js/components/admin/admin-system.js"></script>
<script src="/js/spa.js"></script>
</body>
</html>

View File

@ -56,8 +56,8 @@ class AdminOverview {
go(where) {
if (where === 'backup') {
window.librePortalSPA?.navigate('/backup', true);
} else if (where === 'ssh' || where === 'security') {
const target = where === 'ssh' ? 'ssh-access' : 'security';
} 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);
@ -152,7 +152,7 @@ class AdminOverview {
+ 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>`
`<button type="button" class="backup-secondary-btn" data-admin-go="system">View system stats →</button>`
);
root.innerHTML = `

View File

@ -0,0 +1,194 @@
// 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 = '<div class="admin-page"><div class="backup-empty-state">Loading system stats…</div></div>';
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<div class="sys-range">${opts.map(([m, l]) =>
`<button type="button" class="sys-range-btn ${this.range === m ? 'active' : ''}" data-sys-range="${m}">${l}</button>`
).join('')}</div>`;
}
chartCard(title, bodyHtml, meta) {
return `<div class="sys-chart-card">
<div class="sys-chart-head"><span>${title}</span>${meta ? `<span class="sys-chart-meta">${meta}</span>` : ''}</div>
<div class="sys-chart-body">${bodyHtml}</div>
</div>`;
}
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 = `
<div class="sys-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 ?? ''}` })}
</div>`;
// 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 = `
<div class="sys-section-head">
<h2>Trends</h2>
${this.rangeBtns()}
</div>
<div class="sys-charts">
${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' }]) +
`<div class="sys-net-legend"><span><i class="dot ok"></i>↓ ${this.rate(lastRx)}</span><span><i class="dot accent"></i>↑ ${this.rate(lastTx)}</span></div>`,
'rx / tx')}
</div>`;
// Host info + swap + docker summary
const infoStrip = `
<div class="sys-strip">
${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')}
</div>`;
const dockerStrip = `
<div class="sys-section-head"><h2>Docker</h2></div>
<div class="sys-strip">
${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))}
</div>`;
// 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 `<tr>
<td class="sys-app-name"><span class="admin-status-dot ${statusCls}"></span>${this.escape(a.app)}
<span class="sys-app-sub">${a.running}/${a.containers} up</span></td>
<td>${C.bar(a.cpu_percent)}<span class="sys-cell-val">${(a.cpu_percent || 0).toFixed(1)}%</span></td>
<td>${C.bar(a.mem_percent)}<span class="sys-cell-val">${this.bytes(a.mem_bytes)}</span></td>
<td class="sys-net-cell">${this.bytes(a.net_rx)} ${this.bytes(a.net_tx)}</td>
<td class="sys-spark-cell">${C.sparkline(spark, { color: 'accent' })}</td>
</tr>`;
}).join('') : `<tr><td colspan="5" class="sys-apps-empty">No running containers — install an app to see per-app stats.</td></tr>`;
const appsTable = `
<div class="sys-section-head"><h2>Per-app usage</h2><span class="sys-chart-meta">sorted by CPU</span></div>
<div class="sys-apps-wrap">
<table class="sys-apps">
<thead><tr><th>App</th><th>CPU</th><th>Memory</th><th>Network</th><th>CPU trend</th></tr></thead>
<tbody>${appsBody}</tbody>
</table>
</div>`;
const updated = m.updated ? new Date(m.updated).toLocaleTimeString() : '—';
root.innerHTML = `
<div class="admin-page sys-page">
<div class="page-header config-page-header">
<div class="page-header-title">
<div class="admin-breadcrumb">Admin</div>
<h1>System</h1>
<p>Live host and per-app statistics. Updated ${updated}.</p>
</div>
</div>
${gauges}
${charts}
${infoStrip}
${dockerStrip}
${appsTable}
</div>`;
}
stat(label, value) {
return `<div class="sys-stat"><span class="sys-stat-label">${this.escape(label)}</span><strong class="sys-stat-value">${this.escape(value)}</strong></div>`;
}
}
window.AdminSystem = AdminSystem;

View File

@ -0,0 +1,143 @@
// LPCharts — tiny, dependency-free SVG charts for the Admin → System page.
// No external libraries or CDN calls (LibrePortal ships no third-party frontend
// assets). Every renderer returns an SVG string and colours itself from the
// active theme's CSS variables, so charts re-theme for free. Stroke widths use
// vector-effect:non-scaling-stroke so lines stay crisp when the SVG is stretched
// to its container width.
const LPCharts = (() => {
let _uid = 0;
const uid = (p) => `${p}-${(++_uid)}`;
// name -> { line: "var(--name)", fill: "rgba(var(--name-rgb), a)" }
const palette = (name = 'accent') => ({
line: `var(--${name})`,
rgb: `var(--${name}-rgb)`
});
// Map a series of numbers to SVG path data within a [0,W]x[0,H] box.
// Returns { line, area } path strings. `max`/`min` fix the y-range.
function paths(values, W, H, pad, min, max) {
const n = values.length;
if (n === 0) return { line: '', area: '' };
const span = (max - min) || 1;
const innerH = H - pad * 2;
const stepX = n > 1 ? (W / (n - 1)) : 0;
const pts = values.map((v, i) => {
const x = n > 1 ? i * stepX : W / 2;
const y = pad + innerH * (1 - (Math.max(min, Math.min(max, v)) - min) / span);
return [x, y];
});
const line = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
const area = `${line} L${pts[n - 1][0].toFixed(1)},${H} L${pts[0][0].toFixed(1)},${H} Z`;
return { line, area };
}
// Single filled area chart. values: number[]. opts: { color, height, max,
// min, unit, fmt }. Returns an <svg> that fills its container width.
function areaChart(values, opts = {}) {
const W = 300, H = opts.height || 90, pad = 6;
const color = palette(opts.color || 'accent');
if (!values || values.length === 0) {
return `<div class="lp-chart-empty">No data yet</div>`;
}
const dataMax = Math.max(...values);
const dataMin = Math.min(...values);
const max = (opts.max !== undefined) ? opts.max : (dataMax <= 0 ? 1 : dataMax * 1.15);
const min = (opts.min !== undefined) ? opts.min : Math.min(0, dataMin);
const { line, area } = paths(values, W, H, pad, min, max);
const id = uid('grad');
const last = values[values.length - 1];
const fmt = opts.fmt || ((v) => v);
return `
<svg class="lp-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img">
<defs>
<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(${getRGB(color.rgb)}, 0.35)"/>
<stop offset="100%" stop-color="rgba(${getRGB(color.rgb)}, 0.02)"/>
</linearGradient>
</defs>
<path d="${area}" fill="url(#${id})" stroke="none"/>
<path d="${line}" fill="none" stroke="${color.line}" stroke-width="2"
vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round"/>
</svg>
<div class="lp-chart-last" style="color:${color.line}">${fmt(last)}</div>`;
}
// Multiple overlaid lines on a shared y-range. series: [{values, color, label}].
function multiLine(series, opts = {}) {
const W = 300, H = opts.height || 90, pad = 6;
const all = series.flatMap(s => s.values || []);
if (all.length === 0) return `<div class="lp-chart-empty">No data yet</div>`;
const max = (opts.max !== undefined) ? opts.max : (Math.max(...all) * 1.15 || 1);
const min = (opts.min !== undefined) ? opts.min : 0;
const lines = series.map(s => {
const c = palette(s.color || 'accent');
const { line } = paths(s.values || [], W, H, pad, min, max);
return `<path d="${line}" fill="none" stroke="${c.line}" stroke-width="2"
vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round"/>`;
}).join('');
return `<svg class="lp-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img">${lines}</svg>`;
}
// Inline mini line (no axes/fill). For per-app rows.
function sparkline(values, opts = {}) {
const W = 100, H = opts.height || 24, pad = 2;
const color = palette(opts.color || 'accent');
if (!values || values.length < 2) return `<svg class="lp-spark" viewBox="0 0 ${W} ${H}"></svg>`;
const max = Math.max(...values) || 1;
const { line } = paths(values, W, H, pad, 0, max * 1.1);
return `<svg class="lp-spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
<path d="${line}" fill="none" stroke="${color.line}" stroke-width="1.5"
vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>`;
}
// Circular ring gauge. value 0..max. opts: { label, color, sublabel, max }.
function gauge(value, opts = {}) {
const max = opts.max || 100;
const pct = Math.max(0, Math.min(1, (value || 0) / max));
const color = palette(opts.color || pickColor(pct));
const r = 52, C = 2 * Math.PI * r, off = C * (1 - pct);
return `
<div class="lp-gauge">
<svg viewBox="0 0 120 120" class="lp-gauge-svg">
<circle cx="60" cy="60" r="${r}" fill="none" stroke="rgba(var(--text-rgb),0.10)" stroke-width="10"/>
<circle cx="60" cy="60" r="${r}" fill="none" stroke="${color.line}" stroke-width="10"
stroke-linecap="round" stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${off.toFixed(1)}"
transform="rotate(-90 60 60)" style="transition:stroke-dashoffset .5s ease"/>
</svg>
<div class="lp-gauge-center">
<div class="lp-gauge-value">${opts.display !== undefined ? opts.display : Math.round(value)}<span>${opts.suffix || '%'}</span></div>
${opts.sublabel ? `<div class="lp-gauge-sub">${opts.sublabel}</div>` : ''}
</div>
<div class="lp-gauge-label">${opts.label || ''}</div>
</div>`;
}
// Horizontal percentage bar.
function bar(pct, opts = {}) {
const p = Math.max(0, Math.min(100, pct || 0));
const color = palette(opts.color || pickColor(p / 100));
return `<div class="lp-bar"><span class="lp-bar-fill" style="width:${p}%;background:${color.line}"></span></div>`;
}
// Green < 70% < amber < 90% < red — the universal "headroom" cue.
function pickColor(frac) {
if (frac >= 0.9) return 'status-danger';
if (frac >= 0.7) return 'status-warning';
return 'status-success';
}
// Resolve "var(--x-rgb)" to its literal triplet so it can sit inside a
// gradient stop's rgba() (SVG gradients don't inherit CSS custom props
// reliably across browsers when nested in rgba()).
function getRGB(varExpr) {
const name = varExpr.match(/--[a-z0-9-]+/i);
if (!name) return '0,212,255';
const v = getComputedStyle(document.documentElement).getPropertyValue(name[0]).trim();
return v || '0,212,255';
}
return { areaChart, multiLine, sparkline, gauge, bar, pickColor };
})();
window.LPCharts = LPCharts;

View File

@ -50,6 +50,19 @@ if (typeof window.ConfigManager === 'undefined') {
return;
}
// System is an admin tool page (live host + per-app statistics) with its
// own controller, like SSH Access above.
if (category === 'system') {
try { this.sidebar.populateSidebar(); } catch (e) {}
if (typeof AdminSystem !== 'undefined') {
window.adminSystem = new AdminSystem('config-section');
await window.adminSystem.init();
} else {
configSection.innerHTML = '<div class="error">System page failed to load.</div>';
}
return;
}
try {
// Show loading state with enhanced box styling
configSection.innerHTML = `

View File

@ -118,6 +118,21 @@ class ConfigSidebar {
});
self.categoriesList.appendChild(sshItem);
const systemItem = document.createElement('div');
systemItem.className = 'category';
systemItem.setAttribute('data-category', 'system');
systemItem.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px;vertical-align:middle"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg> System';
systemItem.addEventListener('click', function () {
window.history.pushState({}, '', window.adminPath('system'));
document.querySelectorAll('.category').forEach(function (item) { item.classList.remove('active'); });
this.classList.add('active');
window.configCategory = 'system';
if (window.configManager && typeof window.configManager.renderConfig === 'function') {
window.configManager.renderConfig('system');
}
});
self.categoriesList.appendChild(systemItem);
// Set initial active category
this.setActiveCategory(window.configCategory || 'overview');