Merge claude/2
This commit is contained in:
commit
c6a27d28c4
@ -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; }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = `
|
||||
|
||||
@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|
||||
143
containers/libreportal/frontend/js/components/admin/charts.js
Normal file
143
containers/libreportal/frontend/js/components/admin/charts.js
Normal 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;
|
||||
@ -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 = `
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user