From 62f7a84126fa9d28a66a6fce7668322641e6cead Mon Sep 17 00:00:00 2001 From: librelad Date: Sun, 24 May 2026 16:47:20 +0100 Subject: [PATCH] feat(webui): Admin System page with gauges, trend charts & per-app stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: librelad --- containers/libreportal/frontend/css/admin.css | 216 ++++++++++++++++++ containers/libreportal/frontend/index.html | 2 + .../js/components/admin/admin-overview.js | 6 +- .../js/components/admin/admin-system.js | 194 ++++++++++++++++ .../frontend/js/components/admin/charts.js | 143 ++++++++++++ .../js/components/config/config-manager.js | 13 ++ .../js/components/config/config-sidebar.js | 15 ++ 7 files changed, 586 insertions(+), 3 deletions(-) create mode 100644 containers/libreportal/frontend/js/components/admin/admin-system.js create mode 100644 containers/libreportal/frontend/js/components/admin/charts.js diff --git a/containers/libreportal/frontend/css/admin.css b/containers/libreportal/frontend/css/admin.css index 2d0c68e..1d8abc7 100644 --- a/containers/libreportal/frontend/css/admin.css +++ b/containers/libreportal/frontend/css/admin.css @@ -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; } diff --git a/containers/libreportal/frontend/index.html b/containers/libreportal/frontend/index.html index b5022c6..9fb7f4e 100755 --- a/containers/libreportal/frontend/index.html +++ b/containers/libreportal/frontend/index.html @@ -100,7 +100,9 @@ + + diff --git a/containers/libreportal/frontend/js/components/admin/admin-overview.js b/containers/libreportal/frontend/js/components/admin/admin-overview.js index 376f3c1..0794913 100644 --- a/containers/libreportal/frontend/js/components/admin/admin-overview.js +++ b/containers/libreportal/frontend/js/components/admin/admin-overview.js @@ -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 || '—'), - `${diskPct >= 90 ? '⚠ Disk almost full' : 'Healthy'}` + `` ); root.innerHTML = ` diff --git a/containers/libreportal/frontend/js/components/admin/admin-system.js b/containers/libreportal/frontend/js/components/admin/admin-system.js new file mode 100644 index 0000000..944de9f --- /dev/null +++ b/containers/libreportal/frontend/js/components/admin/admin-system.js @@ -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 = '
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; diff --git a/containers/libreportal/frontend/js/components/admin/charts.js b/containers/libreportal/frontend/js/components/admin/charts.js new file mode 100644 index 0000000..02cad83 --- /dev/null +++ b/containers/libreportal/frontend/js/components/admin/charts.js @@ -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 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 `
No data yet
`; + } + 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 ` + + + + + + + + + + +
${fmt(last)}
`; + } + + // 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 `
No data yet
`; + 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 ``; + }).join(''); + return `${lines}`; + } + + // 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 ``; + const max = Math.max(...values) || 1; + const { line } = paths(values, W, H, pad, 0, max * 1.1); + return ` + `; + } + + // 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 ` +
+ + + + +
+
${opts.display !== undefined ? opts.display : Math.round(value)}${opts.suffix || '%'}
+ ${opts.sublabel ? `
${opts.sublabel}
` : ''} +
+
${opts.label || ''}
+
`; + } + + // 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 `
`; + } + + // 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; diff --git a/containers/libreportal/frontend/js/components/config/config-manager.js b/containers/libreportal/frontend/js/components/config/config-manager.js index b26cc6e..5589a35 100755 --- a/containers/libreportal/frontend/js/components/config/config-manager.js +++ b/containers/libreportal/frontend/js/components/config/config-manager.js @@ -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 = '
System page failed to load.
'; + } + return; + } + try { // Show loading state with enhanced box styling configSection.innerHTML = ` diff --git a/containers/libreportal/frontend/js/components/config/config-sidebar.js b/containers/libreportal/frontend/js/components/config/config-sidebar.js index c975f96..7f2e5ae 100755 --- a/containers/libreportal/frontend/js/components/config/config-sidebar.js +++ b/containers/libreportal/frontend/js/components/config/config-sidebar.js @@ -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 = ' 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');