- features/admin/: the 10 admin-owned config controllers, the 5 admin pages
(overview/system/charts/metric/storage), ssh-page.js, peers-page.js, plus
admin.css/ip-whitelist.css/ssh.css (eager). config-manager.js kept last in
the load order (it news the sub-managers).
- shared/js/: config-shared.js + config-options.js (ConfigShared/ConfigOptions
globals consumed cross-feature by backup/apps/tasks).
- shared/css/: forms.css + config.css (generic form + config-form primitives
borrowed by apps/backup/admin).
- Updated all path strings in system-loader.js (config component) and
config-manager.js (lazyLoad of admin/ssh/peers controllers); index.html CSS
hrefs. No /js/components/{config,admin,ssh,peers}/ refs remain.
js/components/ now holds only shared UI (topbar, notifications, eo-modal,
update-notifier, mobile-menu, confirmation-dialog).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
528 lines
25 KiB
JavaScript
528 lines
25 KiB
JavaScript
// Admin → System → Metric — single-metric deep-dive PAGE.
|
|
//
|
|
// Replaces the previous fullscreen overlay (system-detail.js) with a real
|
|
// in-flow page mounted at /admin/system/metric/<key>. Bookmarkable,
|
|
// browser-back navigates, refresh keeps you here.
|
|
//
|
|
// Renders into #config-section as a full-width admin page. Owns its own
|
|
// DOM, range picker, live SSE subscription, and hover/scrub interactions.
|
|
//
|
|
// Mount lifecycle:
|
|
// const p = new SystemMetricPage('config-section');
|
|
// await p.mount('cpu');
|
|
// ...
|
|
// p.dispose(); // tears down SSE sub + global listeners
|
|
|
|
class SystemMetricPage {
|
|
constructor(rootId = 'config-section') {
|
|
this.rootId = rootId;
|
|
this.metricKey = null;
|
|
this.metric = null;
|
|
this.rangeMin = 360; // default 6h
|
|
this.points = [];
|
|
this.tier = '1m';
|
|
this.hoverIdx = -1;
|
|
this._rafHover = null;
|
|
this._lpPct = null; // disk only: LibrePortal's share of the disk (%)
|
|
this._unsubLive = null;
|
|
this._onKey = this._onKey.bind(this);
|
|
this._onResize = this._onResize.bind(this);
|
|
this._onClick = this._onClick.bind(this);
|
|
this._onPointerMove = this._onPointerMove.bind(this);
|
|
this._onPointerLeave = this._onPointerLeave.bind(this);
|
|
}
|
|
|
|
root() { return document.getElementById(this.rootId); }
|
|
|
|
async mount(key) {
|
|
this.metricKey = key;
|
|
this.metric = this._metricDef(key);
|
|
if (!this.metric) {
|
|
this._renderUnknown();
|
|
return;
|
|
}
|
|
// Optional ?range= override on the URL.
|
|
const params = new URLSearchParams(window.location.search);
|
|
const r = parseInt(params.get('range'), 10);
|
|
if (Number.isFinite(r) && r > 0) this.rangeMin = Math.min(10080, Math.max(1, r));
|
|
|
|
this._renderShell();
|
|
this._bind();
|
|
await this._loadRange();
|
|
this._attachLive();
|
|
this._loadLibrePortalShare();
|
|
}
|
|
|
|
// Disk page only: compute LibrePortal's current share of the disk so the
|
|
// chart can mark it with a reference line beside the disk-usage trend.
|
|
// It's a "now" value (app_storage + Docker df ÷ disk total) — there's no
|
|
// historical LP series, so the line is flat across the range.
|
|
async _loadLibrePortalShare() {
|
|
if (this.metricKey !== 'disk') return;
|
|
try {
|
|
const [app, dock, metrics] = await Promise.all([
|
|
fetch(`/data/system/app_storage.json?t=${Date.now()}`).then(r => r.ok ? r.json() : null).catch(() => null),
|
|
fetch('/api/system/storage').then(r => r.ok ? r.json() : null).catch(() => null),
|
|
fetch(`/data/system/metrics.json?t=${Date.now()}`).then(r => r.ok ? r.json() : null).catch(() => null),
|
|
]);
|
|
const lpBytes = ((app && app.total_local) || 0) + ((dock && dock.total) || 0);
|
|
const disks = (metrics && Array.isArray(metrics.disks)) ? metrics.disks : [];
|
|
const root = disks.find(d => d.mount === '/') || disks[0];
|
|
const diskTotal = root ? Number(root.total) || 0 : 0;
|
|
this._lpPct = (diskTotal > 0 && lpBytes > 0) ? (lpBytes / diskTotal) * 100 : null;
|
|
this._lpBytes = lpBytes;
|
|
this._renderChart();
|
|
} catch (_) { this._lpPct = null; }
|
|
}
|
|
|
|
dispose() {
|
|
if (this._unsubLive) { try { this._unsubLive(); } catch (_) {} this._unsubLive = null; }
|
|
document.removeEventListener('keydown', this._onKey);
|
|
window.removeEventListener('resize', this._onResize);
|
|
// Click handler is delegated on root and dies with the DOM swap.
|
|
if (this._rafHover) cancelAnimationFrame(this._rafHover);
|
|
}
|
|
|
|
_onKey(e) {
|
|
if (e.key === 'Escape' && window.navigateToRoute) {
|
|
window.navigateToRoute('/admin/system');
|
|
}
|
|
}
|
|
_onResize() { this._renderChart(); }
|
|
|
|
_bind() {
|
|
document.addEventListener('keydown', this._onKey);
|
|
window.addEventListener('resize', this._onResize);
|
|
const r = this.root();
|
|
if (r) r.addEventListener('click', this._onClick);
|
|
}
|
|
|
|
_onClick(e) {
|
|
const rb = e.target.closest('[data-rng]');
|
|
if (rb) {
|
|
const r = parseInt(rb.dataset.rng, 10) || 360;
|
|
this.rangeMin = r;
|
|
// Reflect in URL so refresh / share preserves the choice.
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('range', String(r));
|
|
window.history.replaceState({}, '', url.toString());
|
|
this._loadRange();
|
|
return;
|
|
}
|
|
const back = e.target.closest('[data-back]');
|
|
if (back && window.navigateToRoute) {
|
|
window.navigateToRoute('/admin/system');
|
|
return;
|
|
}
|
|
const go = e.target.closest('[data-go-storage]');
|
|
if (go && window.navigateToRoute) {
|
|
window.navigateToRoute('/admin/system/storage');
|
|
}
|
|
}
|
|
|
|
// Metric registry — keys map to label, unit, formatter, accent. Mirrors
|
|
// the same list the index view exposes; lives here so the metric page
|
|
// can mount cleanly without AdminSystem present (direct URL load).
|
|
_metricDef(key) {
|
|
const fmt = window.SystemFmt || { bytes: (n) => `${n}`, rate: (n) => `${n}/s`, rgbVar: () => '0, 212, 255' };
|
|
const pct = (v) => `${(v ?? 0).toFixed(1)}%`;
|
|
const rateFmt = (v) => fmt.rate(v);
|
|
const loadFmt = (v) => (v ?? 0).toFixed(2);
|
|
const all = {
|
|
cpu: { key: 'cpu', label: 'CPU usage', unit: '%', max: 100, fmt: pct, accent: 'accent' },
|
|
mem: { key: 'mem', label: 'Memory usage', unit: '%', max: 100, fmt: pct, accent: 'status-info' },
|
|
swap: { key: 'swap', label: 'Swap usage', unit: '%', max: 100, fmt: pct, accent: 'status-warning' },
|
|
disk: { key: 'disk', label: 'Disk usage', unit: '%', max: 100, fmt: pct, accent: 'status-warning' },
|
|
load1: { key: 'load1', label: 'Load average (1m)', fmt: loadFmt, accent: 'accent' },
|
|
net_rx: { key: 'net_rx', label: 'Network — receive', fmt: rateFmt, accent: 'status-success' },
|
|
net_tx: { key: 'net_tx', label: 'Network — transmit', fmt: rateFmt, accent: 'accent' },
|
|
};
|
|
const m = all[key];
|
|
if (!m) return null;
|
|
m.accentRgb = fmt.rgbVar(m.accent);
|
|
return m;
|
|
}
|
|
|
|
_renderUnknown() {
|
|
const root = this.root();
|
|
if (!root) return;
|
|
root.innerHTML = `
|
|
<div class="admin-page sys-metric-page">
|
|
<div class="page-header config-page-header">
|
|
<div class="page-header-title">
|
|
<div class="admin-breadcrumb">
|
|
<a href="/admin/system" data-back>Admin · System</a>
|
|
</div>
|
|
<h1>Unknown metric</h1>
|
|
<p>No metric registered under "${(window.SystemFmt?.escape || String)(this.metricKey)}".</p>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
_renderShell() {
|
|
const root = this.root();
|
|
if (!root) return;
|
|
const fmt = window.SystemFmt;
|
|
root.innerHTML = `
|
|
<div class="admin-page sys-metric-page" style="--metric-rgb:${this.metric.accentRgb}">
|
|
<div class="page-header config-page-header">
|
|
<div class="page-header-title">
|
|
<div class="admin-breadcrumb">
|
|
<a href="/admin/system" data-back>Admin · System</a>
|
|
</div>
|
|
<h1 class="sys-metric-name">${fmt.escape(this.metric.label)}</h1>
|
|
<p class="sys-metric-sub">${fmt.escape(this._subline())}</p>
|
|
</div>
|
|
<div class="sys-metric-actions">
|
|
<div class="sys-detail-range" role="tablist" aria-label="Time range">
|
|
${[[60,'1h'],[360,'6h'],[1440,'24h'],[10080,'7d']]
|
|
.map(([m,l]) => `<button type="button" role="tab" class="sys-detail-range-btn ${this.rangeMin===m?'active':''}" data-rng="${m}">${l}</button>`).join('')}
|
|
</div>
|
|
${this.metricKey === 'disk' ? `<button type="button" class="sys-storage-cta" data-go-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>
|
|
<section class="sys-detail-stats">
|
|
${['now','peak','avg','min'].map(k =>
|
|
`<div class="sys-detail-stat" data-stat="${k}">
|
|
<span class="sys-detail-stat-k">${k}</span>
|
|
<strong class="sys-detail-stat-v">—</strong>
|
|
<span class="sys-detail-stat-t"></span>
|
|
</div>`).join('')}
|
|
</section>
|
|
<section class="sys-metric-canvas">
|
|
<div class="sys-detail-empty" hidden>No samples in this range yet — check back in a minute.</div>
|
|
<div class="sys-detail-loading">Loading history…</div>
|
|
<svg class="sys-detail-svg" preserveAspectRatio="none" aria-hidden="true"></svg>
|
|
<div class="sys-detail-tooltip" hidden></div>
|
|
</section>
|
|
<footer class="sys-detail-foot">
|
|
<span class="sys-detail-foot-meta"></span>
|
|
<span class="sys-detail-foot-hint">Esc returns to System · hover the chart to scrub · range tier auto-selects</span>
|
|
</footer>
|
|
</div>`;
|
|
const canvas = root.querySelector('.sys-metric-canvas');
|
|
if (canvas) {
|
|
canvas.addEventListener('pointermove', this._onPointerMove);
|
|
canvas.addEventListener('pointerleave', this._onPointerLeave);
|
|
}
|
|
}
|
|
|
|
// Plain-language description of what the page shows, per metric.
|
|
_subline() {
|
|
const descs = {
|
|
cpu: 'Processor load over time, across all cores.',
|
|
mem: 'Memory in use over time, as a share of total RAM.',
|
|
swap: 'Swap in use over time, as a share of total swap.',
|
|
disk: 'Root-filesystem usage over time.',
|
|
load1: 'System load average (1-minute) over time.',
|
|
net_rx: 'Inbound network throughput over time.',
|
|
net_tx: 'Outbound network throughput over time.',
|
|
};
|
|
return (this.metric && descs[this.metric.key]) || 'Live history over time.';
|
|
}
|
|
|
|
async _loadRange() {
|
|
const root = this.root();
|
|
if (!root) return;
|
|
const loading = root.querySelector('.sys-detail-loading');
|
|
const empty = root.querySelector('.sys-detail-empty');
|
|
if (empty) empty.hidden = true;
|
|
if (loading) loading.hidden = false;
|
|
// Range button active states.
|
|
for (const b of root.querySelectorAll('.sys-detail-range-btn')) {
|
|
b.classList.toggle('active', parseInt(b.dataset.rng, 10) === this.rangeMin);
|
|
}
|
|
try {
|
|
const r = await fetch(`/api/system/history?range=${this.rangeMin}&keys=${encodeURIComponent(this.metric.key)}`, { credentials: 'same-origin' });
|
|
const j = await r.json().catch(() => ({}));
|
|
this.points = Array.isArray(j?.points) ? j.points : [];
|
|
this.tier = j?.tier || (this.rangeMin > 1440 ? '5m' : '1m');
|
|
} catch (_) {
|
|
this.points = [];
|
|
}
|
|
if (loading) loading.hidden = true;
|
|
this._renderChart();
|
|
this._renderStats();
|
|
this._renderFootMeta();
|
|
}
|
|
|
|
_attachLive() {
|
|
if (this._unsubLive) { try { this._unsubLive(); } catch (_) {} this._unsubLive = null; }
|
|
if (!window.LiveSystem) return;
|
|
this._unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
|
|
}
|
|
|
|
_applyLive(s) {
|
|
if (!s || !this.metric) return;
|
|
const v = this._extractLive(s);
|
|
if (v == null) return;
|
|
const t = Math.floor((s.t || Date.now()) / 1000);
|
|
if (this.points.length && this.points[this.points.length - 1].__live) {
|
|
this.points[this.points.length - 1] = { t, [this.metric.key]: v, __live: true };
|
|
} else {
|
|
this.points.push({ t, [this.metric.key]: v, __live: true });
|
|
}
|
|
this._renderChart();
|
|
this._renderStats(true);
|
|
}
|
|
|
|
_extractLive(s) {
|
|
const k = this.metric.key;
|
|
if (k === 'cpu') return Number(s?.cpu?.percent) || 0;
|
|
if (k === 'mem') return Number(s?.memory?.percent) || 0;
|
|
if (k === 'swap') return Number(s?.memory?.swap_percent) || 0;
|
|
if (k === 'load1') return Number(s?.cpu?.load1) || 0;
|
|
if (k === 'disk') {
|
|
const ds = Array.isArray(s?.disks) ? s.disks : [];
|
|
const r = ds.find(d => d.mount === '/') || ds[0];
|
|
return r ? Number(r.percent) || 0 : null;
|
|
}
|
|
if (k === 'net_rx') return Number(s?.network?.rx_rate) || 0;
|
|
if (k === 'net_tx') return Number(s?.network?.tx_rate) || 0;
|
|
return null;
|
|
}
|
|
|
|
_renderStats(liveOnly = false) {
|
|
const root = this.root();
|
|
if (!root) return;
|
|
const m = this.metric;
|
|
const vals = this.points.map(p => Number(p[m.key]) || 0);
|
|
const fmt = m.fmt;
|
|
const stats = this._computeStats(vals);
|
|
const now = vals[vals.length - 1] ?? 0;
|
|
const nowAt = this.points.length ? this.points[this.points.length - 1].t : null;
|
|
const peakAt = stats.peakIdx >= 0 ? this.points[stats.peakIdx]?.t : null;
|
|
const minAt = stats.minIdx >= 0 ? this.points[stats.minIdx]?.t : null;
|
|
const set = (k, v, t) => {
|
|
const card = root.querySelector(`[data-stat="${k}"]`);
|
|
if (!card) return;
|
|
card.querySelector('.sys-detail-stat-v').textContent = vals.length ? fmt(v) : '—';
|
|
card.querySelector('.sys-detail-stat-t').textContent = t ? window.SystemFmt.timeAgo(t) : '';
|
|
};
|
|
set('now', now, nowAt);
|
|
if (!liveOnly) {
|
|
set('peak', stats.peak, peakAt);
|
|
set('avg', stats.avg, null);
|
|
set('min', stats.min, minAt);
|
|
}
|
|
}
|
|
|
|
_renderFootMeta() {
|
|
const root = this.root();
|
|
if (!root) return;
|
|
const meta = root.querySelector('.sys-detail-foot-meta');
|
|
if (!meta) return;
|
|
const n = this.points.length;
|
|
const span = n > 1 ? (this.points[n - 1].t - this.points[0].t) : 0;
|
|
meta.textContent = n
|
|
? `${n} samples · tier ${this.tier} · spans ${this._formatDuration(span)}`
|
|
: `tier ${this.tier} · empty range`;
|
|
}
|
|
|
|
_computeStats(vals) {
|
|
if (!vals.length) return { peak: 0, min: 0, avg: 0, peakIdx: -1, minIdx: -1 };
|
|
let peak = -Infinity, min = Infinity, sum = 0, peakIdx = 0, minIdx = 0;
|
|
for (let i = 0; i < vals.length; i++) {
|
|
const v = vals[i];
|
|
sum += v;
|
|
if (v > peak) { peak = v; peakIdx = i; }
|
|
if (v < min) { min = v; minIdx = i; }
|
|
}
|
|
return { peak, min, avg: sum / vals.length, peakIdx, minIdx };
|
|
}
|
|
|
|
_renderChart() {
|
|
const m = this.metric;
|
|
if (!m) return;
|
|
const root = this.root();
|
|
if (!root) return;
|
|
const svg = root.querySelector('.sys-detail-svg');
|
|
const canvas = root.querySelector('.sys-metric-canvas');
|
|
if (!svg || !canvas) return;
|
|
const W = canvas.clientWidth || 1200;
|
|
const H = canvas.clientHeight || 480;
|
|
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
svg.setAttribute('width', W);
|
|
svg.setAttribute('height', H);
|
|
// Single source of truth for the empty overlay: it tracks whether we
|
|
// actually have points. Without this the "no samples" message, shown
|
|
// by _loadRange when history came back empty, never cleared once live
|
|
// ticks started filling the chart + stats — a confusing contradiction.
|
|
const empty = root.querySelector('.sys-detail-empty');
|
|
if (!this.points.length) {
|
|
svg.innerHTML = '';
|
|
if (empty) empty.hidden = false;
|
|
return;
|
|
}
|
|
if (empty) empty.hidden = true;
|
|
const padL = 64, padR = 24, padT = 18, padB = 36;
|
|
const innerW = Math.max(1, W - padL - padR);
|
|
const innerH = Math.max(1, H - padT - padB);
|
|
const vals = this.points.map(p => Number(p[m.key]) || 0);
|
|
const stats = this._computeStats(vals);
|
|
const yMin = (m.min !== undefined) ? m.min : 0;
|
|
const yMax = (m.max !== undefined) ? m.max : Math.max(stats.peak * 1.15, 1);
|
|
const span = yMax - yMin || 1;
|
|
const n = vals.length;
|
|
const stepX = n > 1 ? (innerW / (n - 1)) : 0;
|
|
const xAt = (i) => padL + i * stepX;
|
|
const yAt = (v) => padT + innerH * (1 - (Math.max(yMin, Math.min(yMax, v)) - yMin) / span);
|
|
|
|
let line = '';
|
|
for (let i = 0; i < n; i++) line += `${i ? 'L' : 'M'}${xAt(i).toFixed(1)},${yAt(vals[i]).toFixed(1)} `;
|
|
const area = `${line} L${xAt(n - 1).toFixed(1)},${padT + innerH} L${padL.toFixed(1)},${padT + innerH} Z`;
|
|
|
|
const yTicks = 5;
|
|
const fmtY = m.fmt;
|
|
let grid = '', yLabels = '';
|
|
for (let i = 0; i <= yTicks; i++) {
|
|
const v = yMax - (i * span / yTicks);
|
|
const y = padT + (i * innerH / yTicks);
|
|
grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${padL + innerW}" y2="${y.toFixed(1)}" class="sys-detail-grid"/>`;
|
|
yLabels += `<text x="${(padL - 10).toFixed(0)}" y="${(y + 4).toFixed(0)}" class="sys-detail-axis sys-detail-axis-y">${fmtY(v)}</text>`;
|
|
}
|
|
const xTicks = Math.min(6, n);
|
|
let xLabels = '';
|
|
for (let i = 0; i < xTicks; i++) {
|
|
const idx = Math.round(i * (n - 1) / (xTicks - 1 || 1));
|
|
const x = xAt(idx);
|
|
const t = this.points[idx]?.t;
|
|
xLabels += `<text x="${x.toFixed(0)}" y="${(padT + innerH + 22).toFixed(0)}" class="sys-detail-axis sys-detail-axis-x">${this._fmtTime(t)}</text>`;
|
|
}
|
|
const peakDot = (stats.peakIdx >= 0)
|
|
? `<circle cx="${xAt(stats.peakIdx).toFixed(1)}" cy="${yAt(stats.peak).toFixed(1)}" r="4" class="sys-detail-peak"/>` : '';
|
|
const minDot = (stats.minIdx >= 0)
|
|
? `<circle cx="${xAt(stats.minIdx).toFixed(1)}" cy="${yAt(stats.min).toFixed(1)}" r="3" class="sys-detail-min"/>` : '';
|
|
const nowIdx = n - 1;
|
|
const nowDot = `<circle cx="${xAt(nowIdx).toFixed(1)}" cy="${yAt(vals[nowIdx]).toFixed(1)}" r="5" class="sys-detail-now"/>`;
|
|
// Disk page: a flat reference line marking LibrePortal's share of the disk.
|
|
let lpLine = '';
|
|
if (this.metricKey === 'disk' && Number.isFinite(this._lpPct)) {
|
|
const ly = yAt(this._lpPct);
|
|
const label = `LibrePortal ${this._lpPct.toFixed(1)}%${this._lpBytes ? ` · ${window.SystemFmt.bytes(this._lpBytes)}` : ''}`;
|
|
lpLine = `
|
|
<line x1="${padL}" y1="${ly.toFixed(1)}" x2="${(padL + innerW).toFixed(1)}" y2="${ly.toFixed(1)}" class="sys-detail-lpline"/>
|
|
<text x="${(padL + innerW - 6).toFixed(0)}" y="${(ly - 7).toFixed(0)}" text-anchor="end" class="sys-detail-lplabel">${label}</text>`;
|
|
}
|
|
const crosshair = `<line class="sys-detail-cross" x1="0" y1="${padT}" x2="0" y2="${(padT + innerH).toFixed(1)}" visibility="hidden"/>
|
|
<circle class="sys-detail-cross-dot" cx="0" cy="0" r="5" visibility="hidden"/>`;
|
|
const gradId = 'sys-detail-grad';
|
|
svg.innerHTML = `
|
|
<defs>
|
|
<linearGradient id="${gradId}" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="rgba(var(--metric-rgb), 0.45)"/>
|
|
<stop offset="60%" stop-color="rgba(var(--metric-rgb), 0.12)"/>
|
|
<stop offset="100%" stop-color="rgba(var(--metric-rgb), 0.00)"/>
|
|
</linearGradient>
|
|
<linearGradient id="${gradId}-line" x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0%" stop-color="rgba(var(--metric-rgb), 0.7)"/>
|
|
<stop offset="100%" stop-color="rgba(var(--metric-rgb), 1.0)"/>
|
|
</linearGradient>
|
|
</defs>
|
|
${grid}
|
|
<line x1="${padL}" y1="${padT}" x2="${padL}" y2="${(padT + innerH).toFixed(1)}" class="sys-detail-axis-line"/>
|
|
<line x1="${padL}" y1="${(padT + innerH).toFixed(1)}" x2="${(padL + innerW).toFixed(1)}" y2="${(padT + innerH).toFixed(1)}" class="sys-detail-axis-line"/>
|
|
<path d="${area}" fill="url(#${gradId})" stroke="none"/>
|
|
<path d="${line}" fill="none" stroke="url(#${gradId}-line)" stroke-width="2.25"
|
|
vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round"/>
|
|
${lpLine}
|
|
${minDot}
|
|
${peakDot}
|
|
${nowDot}
|
|
${crosshair}
|
|
${yLabels}
|
|
${xLabels}
|
|
`;
|
|
this._geo = { padL, padT, innerW, innerH, n, xAt, yAt, vals };
|
|
}
|
|
|
|
_onPointerMove(e) {
|
|
if (!this._geo) return;
|
|
const canvas = e.currentTarget;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const { padL, innerW, n } = this._geo;
|
|
const rel = (x - padL) / innerW;
|
|
if (rel < -0.02 || rel > 1.02) { this._setHover(-1); return; }
|
|
const idx = Math.max(0, Math.min(n - 1, Math.round(rel * (n - 1))));
|
|
if (idx === this.hoverIdx) return;
|
|
if (this._rafHover) cancelAnimationFrame(this._rafHover);
|
|
this._rafHover = requestAnimationFrame(() => this._setHover(idx, e.clientY - rect.top));
|
|
}
|
|
_onPointerLeave() { this._setHover(-1); }
|
|
|
|
_setHover(idx, yCursor) {
|
|
this.hoverIdx = idx;
|
|
const root = this.root();
|
|
if (!root) return;
|
|
const svg = root.querySelector('.sys-detail-svg');
|
|
const tooltip = root.querySelector('.sys-detail-tooltip');
|
|
if (!svg || !tooltip) return;
|
|
const cross = svg.querySelector('.sys-detail-cross');
|
|
const dot = svg.querySelector('.sys-detail-cross-dot');
|
|
if (!cross || !dot) return;
|
|
if (idx < 0 || !this._geo) {
|
|
cross.setAttribute('visibility', 'hidden');
|
|
dot.setAttribute('visibility', 'hidden');
|
|
tooltip.hidden = true;
|
|
return;
|
|
}
|
|
const { xAt, yAt, vals } = this._geo;
|
|
const x = xAt(idx);
|
|
const v = vals[idx];
|
|
const y = yAt(v);
|
|
cross.setAttribute('x1', x); cross.setAttribute('x2', x);
|
|
cross.setAttribute('visibility', 'visible');
|
|
dot.setAttribute('cx', x); dot.setAttribute('cy', y);
|
|
dot.setAttribute('visibility', 'visible');
|
|
const m = this.metric;
|
|
const fmt = m.fmt;
|
|
const t = this.points[idx]?.t;
|
|
tooltip.innerHTML = `
|
|
<div class="sys-detail-tip-v">${fmt(v)}</div>
|
|
<div class="sys-detail-tip-t">${this._fmtTimeFull(t)}</div>
|
|
`;
|
|
tooltip.hidden = false;
|
|
const canvas = root.querySelector('.sys-metric-canvas');
|
|
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
|
const tw = tooltip.offsetWidth || 140, th = tooltip.offsetHeight || 40;
|
|
let tx = x + 14, ty = (yCursor !== undefined ? yCursor : y) - th - 8;
|
|
if (tx + tw > cw - 8) tx = x - tw - 14;
|
|
if (ty < 8) ty = 8;
|
|
if (ty + th > ch - 8) ty = ch - th - 8;
|
|
tooltip.style.transform = `translate(${tx.toFixed(0)}px, ${ty.toFixed(0)}px)`;
|
|
}
|
|
|
|
_fmtTime(unixSec) {
|
|
if (!unixSec) return '';
|
|
const d = new Date(unixSec * 1000);
|
|
const sameDay = (new Date()).toDateString() === d.toDateString();
|
|
if (this.rangeMin > 1440) {
|
|
const dd = String(d.getDate()).padStart(2, '0');
|
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
return `${dd}/${mm}`;
|
|
}
|
|
const hh = String(d.getHours()).padStart(2, '0');
|
|
const min = String(d.getMinutes()).padStart(2, '0');
|
|
return sameDay ? `${hh}:${min}` : `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${hh}:${min}`;
|
|
}
|
|
|
|
_fmtTimeFull(unixSec) {
|
|
if (!unixSec) return '';
|
|
return new Date(unixSec * 1000).toLocaleString();
|
|
}
|
|
|
|
_formatDuration(sec) {
|
|
if (sec < 60) return `${sec}s`;
|
|
if (sec < 3600) return `${Math.round(sec / 60)} min`;
|
|
if (sec < 86400) return `${(sec / 3600).toFixed(1)} h`;
|
|
return `${(sec / 86400).toFixed(1)} d`;
|
|
}
|
|
}
|
|
|
|
window.SystemMetricPage = SystemMetricPage;
|