// 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/config/system/metric/. 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._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(); } 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/config/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/config/system'); } } // 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 = `
`; } _renderShell() { const root = this.root(); if (!root) return; const fmt = window.SystemFmt; root.innerHTML = `
${['now','peak','avg','min'].map(k => `
${k}
`).join('')}
Loading history…
Esc returns to System · hover the chart to scrub · range tier auto-selects
`; const canvas = root.querySelector('.sys-metric-canvas'); if (canvas) { canvas.addEventListener('pointermove', this._onPointerMove); canvas.addEventListener('pointerleave', this._onPointerLeave); } } _subline() { // Stub — populated more fully once data lands (sample count + tier). return 'Live · binary ring backed'; } 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; if (this.points.length === 0 && empty) empty.hidden = false; 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); if (!this.points.length) { svg.innerHTML = ''; return; } 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 += ``; yLabels += `${fmtY(v)}`; } 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 += `${this._fmtTime(t)}`; } const peakDot = (stats.peakIdx >= 0) ? `` : ''; const minDot = (stats.minIdx >= 0) ? `` : ''; const nowIdx = n - 1; const nowDot = ``; const crosshair = ` `; const gradId = 'sys-detail-grad'; svg.innerHTML = ` ${grid} ${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 = `
${fmt(v)}
${this._fmtTimeFull(t)}
`; 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;