Bundle a small set of distro marks (Debian/Ubuntu/Fedora/Arch) plus a generic Linux/Tux fallback under /icons/os/, and show the icon next to the OS value, keyed off the cleaned distro name. Locally bundled — no external calls — and unknown distros fall back to the generic glyph. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
448 lines
23 KiB
JavaScript
448 lines
23 KiB
JavaScript
// 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/<key> → single-metric deep-dive page
|
||
// /admin/system/app/<name> → 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/<sub>/<arg> 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 = '<div class="admin-page"><div class="backup-empty-state">Loading system stats…</div></div>';
|
||
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 `<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, expandKey) {
|
||
const exBtn = expandKey
|
||
? `<button type="button" class="sys-expand" data-sys-expand="${expandKey}" aria-label="Expand ${title}">
|
||
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||
</button>` : '';
|
||
return `<div class="sys-chart-card">
|
||
<div class="sys-chart-head"><span>${title}</span><span class="sys-chart-head-right">${meta ? `<span class="sys-chart-meta">${meta}</span>` : ''}${exBtn}</span></div>
|
||
<div class="sys-chart-body">${bodyHtml}</div>
|
||
</div>`;
|
||
}
|
||
|
||
_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) =>
|
||
`<button type="button" class="sys-gauge-wrap" data-sys-expand="${key}" aria-label="Expand ${key}">
|
||
${inner}
|
||
<span class="sys-gauge-expand">
|
||
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||
</span>
|
||
</button>`;
|
||
|
||
// 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 = `<div class="sys-gauges">${this._gaugesHtml()}</div>`;
|
||
|
||
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 = `
|
||
<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 %', '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' }]) +
|
||
`<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', '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') : ''}
|
||
</div>`;
|
||
|
||
const infoStrip = `
|
||
<div class="sys-section-head"><h2>Host</h2></div>
|
||
<div class="sys-strip">
|
||
${this._osStat(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 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 class="sys-app-row" data-sys-app="${this.escape(a.app)}" tabindex="0" role="button" aria-label="Open ${this.escape(a.app)} details">
|
||
<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>
|
||
<td class="sys-app-arrow">›</td>
|
||
</tr>`;
|
||
}).join('') : `<tr><td colspan="6" 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">click a row to open the app's Services tab · 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><th></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}
|
||
${this._storageSection()}
|
||
${infoStrip}
|
||
${appsTable}
|
||
</div>`;
|
||
}
|
||
|
||
// 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 = `
|
||
<div class="sys-section-head">
|
||
<h2>Storage</h2>
|
||
<span class="sys-chart-meta">${dk.containers_running ?? 0}/${dk.containers_total ?? 0} running · ${dk.images ?? 0} images</span>
|
||
</div>`;
|
||
if (!s || !s.total || !SP) {
|
||
const msg = (s && !s.total) ? 'No Docker storage in use yet.' : 'Storage usage unavailable.';
|
||
return head + `
|
||
<div class="sys-storage-summary sys-storage-summary-empty">
|
||
<span>${msg}</span>
|
||
<button type="button" class="sys-storage-cta" data-sys-storage title="Open the full storage breakdown">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>
|
||
Open storage breakdown
|
||
</button>
|
||
</div>`;
|
||
}
|
||
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 `
|
||
<div class="sys-storage-srow">
|
||
<span class="sys-storage-swatch" style="background: var(--${seg.color})"></span>
|
||
<span class="sys-storage-srow-k">${this.escape(seg.label)}</span>
|
||
<span class="sys-storage-srow-bar">${C.bar(pct, { color: seg.color })}</span>
|
||
<span class="sys-storage-srow-v">${this.bytes(sz)}</span>
|
||
</div>`;
|
||
}).join('');
|
||
return head + `
|
||
<div class="sys-storage-summary">
|
||
<button type="button" class="sys-storage-summary-donut" data-sys-storage aria-label="Open storage breakdown">${donut}</button>
|
||
<div class="sys-storage-summary-main">
|
||
<div class="sys-storage-srows">${rows}</div>
|
||
<div class="sys-storage-summary-foot">
|
||
<span class="sys-storage-recl-pill">${this.bytes(recl)} reclaimable${reclPct ? ` · ${reclPct}% of total` : ''}</span>
|
||
<button type="button" class="sys-storage-cta" data-sys-storage title="Open the full storage breakdown">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>
|
||
View storage breakdown
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</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>`;
|
||
}
|
||
|
||
// OS stat with a distro icon (bundled under /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 `<div class="sys-stat">
|
||
<span class="sys-stat-label">OS</span>
|
||
<strong class="sys-stat-value sys-os-value">
|
||
<img class="sys-os-icon" src="/icons/os/${slug}.svg" alt="" onerror="this.onerror=null;this.src='/icons/os/linux.svg'">
|
||
${this.escape(os || '—')}
|
||
</strong>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
// 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;
|