// Admin → System — fullscreen single-metric deep-dive overlay. // // Opens when the user hits "Expand" on a gauge, chart card, or any metric // surface on the admin System page. Renders a large interactive chart with // axes, gridlines, hover crosshair + tooltip, and a stat strip (now / peak / // min / avg / Δ) — all driven from /api/system/history + the live SSE feed. // Range picker (1h / 6h / 24h / 7d) re-fetches and animates between datasets. // // Zero dependencies — everything is hand-rolled SVG and pointer events. The // overlay is a singleton instance attached to ; opening with a fresh // metric reuses the same DOM. class SystemDetail { constructor() { this.el = null; this.metric = null; this.rangeMin = 360; // default to 6 h this.points = []; this.unsubLive = null; this.hoverIdx = -1; this._rafHover = null; this._onKey = this._onKey.bind(this); this._onResize = this._onResize.bind(this); } // Public: open the overlay for a given metric definition. // m = { key, label, unit, color, max, fmt, sublabel?, accentRgb? } open(m) { this.metric = m; if (!this.el) this._mount(); this.el.classList.add('open'); this.el.setAttribute('aria-hidden', 'false'); document.body.classList.add('sys-detail-active'); document.addEventListener('keydown', this._onKey); window.addEventListener('resize', this._onResize); this._renderShell(); this._loadRange(); this._attachLive(); } close() { if (!this.el) return; this.el.classList.remove('open'); this.el.setAttribute('aria-hidden', 'true'); document.body.classList.remove('sys-detail-active'); document.removeEventListener('keydown', this._onKey); window.removeEventListener('resize', this._onResize); this._detachLive(); this.points = []; this.metric = null; } _onKey(e) { if (e.key === 'Escape') this.close(); } _onResize() { this._renderChart(); } _mount() { const el = document.createElement('div'); el.className = 'sys-detail'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true'); el.setAttribute('aria-hidden', 'true'); el.innerHTML = `
Admin · System · Live

${[[60,'1h'],[360,'6h'],[1440,'24h'],[10080,'7d']] .map(([m,l]) => ``).join('')}
${['now','peak','avg','min'].map(k => `
${k}
`).join('')}
Loading history…
`; document.body.appendChild(el); this.el = el; // Wire events once. el.addEventListener('click', (e) => { if (e.target.closest('[data-sys-detail-close]')) { this.close(); return; } const rb = e.target.closest('[data-rng]'); if (rb) { this.rangeMin = parseInt(rb.dataset.rng, 10) || 360; this._loadRange(); } }); const canvas = el.querySelector('.sys-detail-canvas'); canvas.addEventListener('pointermove', (e) => this._onHover(e)); canvas.addEventListener('pointerleave', () => this._setHover(-1)); } _renderShell() { const m = this.metric; this.el.querySelector('.sys-detail-name').textContent = m.label; this.el.querySelector('.sys-detail-sub').textContent = m.sublabel || ''; const panel = this.el.querySelector('.sys-detail-panel'); // Re-key accent so the panel borders + stat strip pick up the metric color. const rgb = m.accentRgb || 'var(--accent-rgb)'; panel.style.setProperty('--metric-rgb', rgb); // Range buttons reflect current selection. for (const b of this.el.querySelectorAll('.sys-detail-range-btn')) { b.classList.toggle('active', parseInt(b.dataset.rng, 10) === this.rangeMin); } } async _loadRange() { const m = this.metric; if (!m) return; const loadingEl = this.el.querySelector('.sys-detail-loading'); const emptyEl = this.el.querySelector('.sys-detail-empty'); emptyEl.hidden = true; loadingEl.hidden = false; for (const b of this.el.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(m.key)}`, { credentials: 'same-origin' }); const j = await r.json().catch(() => ({})); this.points = Array.isArray(j?.points) ? j.points : []; this._tier = j?.tier || '1m'; } catch (_) { this.points = []; } loadingEl.hidden = true; if (this.points.length === 0) emptyEl.hidden = false; this._renderChart(); this._renderStats(); this._renderFootMeta(); } _attachLive() { this._detachLive(); if (!window.LiveSystem) return; this.unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s)); } _detachLive() { if (this.unsubLive) { try { this.unsubLive(); } catch (_) {} this.unsubLive = null; } } // Splice the live sample's current value into the dataset's tail and // refresh the now-stat + the trailing edge of the chart only (cheaper // than a full redraw at 1 Hz). The new point joins the displayed series // but is not persisted to this.points so the next range load starts // clean. _applyLive(s) { if (!s || !this.metric) return; const v = this._extractLive(s); if (v == null) return; this._liveValue = v; this._liveAt = s.t || Date.now(); // Append-or-replace: if the latest point in `this.points` is already // tagged as live, replace; otherwise append. Either way we keep the // series length stable so the geometry doesn't jiggle each second. if (this.points.length && this.points[this.points.length - 1].__live) { this.points[this.points.length - 1] = { t: Math.floor(this._liveAt / 1000), [this.metric.key]: v, __live: true }; } else { this.points.push({ t: Math.floor(this._liveAt / 1000), [this.metric.key]: v, __live: true }); } this._renderChart(); this._renderStats(/*liveOnly=*/true); } // Pluck the metric's current value out of the SSE payload. _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 m = this.metric; const key = m.key; const vals = this.points.map(p => Number(p[key]) || 0); const fmt = m.fmt || ((v) => `${v.toFixed(1)}${m.unit || ''}`); 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 = this.el.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 ? this._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 meta = this.el.querySelector('.sys-detail-foot-meta'); const tier = this._tier || (this.rangeMin > 1440 ? '5m' : '1m'); 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 ${tier} · spans ${this._formatDuration(span)}` : `tier ${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 svg = this.el.querySelector('.sys-detail-svg'); const canvas = this.el.querySelector('.sys-detail-canvas'); 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); // Y-range: pin to opts.max when given (e.g. percentages); else 0..peak*1.15. 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); // Line + area path. 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`; // Gridlines + Y labels (5 horizontal ticks, including top + bottom). const yTicks = 5; const fmtY = m.fmt || ((v) => `${Math.round(v)}${m.unit || ''}`); 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)}`; } // X labels (~6 ticks across). 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)}`; } // Peak + min markers. const peakDot = (stats.peakIdx >= 0) ? `` : ''; const minDot = (stats.minIdx >= 0) ? `` : ''; // Now dot — pulses via CSS. const nowIdx = n - 1; const nowDot = ``; // Hover crosshair (positioned in _setHover). const crosshair = ` `; // Gradient id is stable so the fill keeps animating during live updates. const gradId = 'sys-detail-grad'; svg.innerHTML = ` ${grid} ${minDot} ${peakDot} ${nowDot} ${crosshair} ${yLabels} ${xLabels} `; // Cache geometry for hover. this._geo = { padL, padR, padT, padB, innerW, innerH, n, xAt, yAt, vals }; } _onHover(e) { if (!this._geo) return; const canvas = this.el.querySelector('.sys-detail-canvas'); 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)); } _setHover(idx, yCursor) { this.hoverIdx = idx; const svg = this.el.querySelector('.sys-detail-svg'); const tooltip = this.el.querySelector('.sys-detail-tooltip'); 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'); // Tooltip in canvas-pixel space (SVG fills the canvas at native size). const m = this.metric; const fmt = m.fmt || ((v) => `${v.toFixed(1)}${m.unit || ''}`); const t = this.points[idx]?.t; tooltip.innerHTML = `
${fmt(v)}
${this._fmtTimeFull(t)}
`; tooltip.hidden = false; const canvas = this.el.querySelector('.sys-detail-canvas'); const cw = canvas.clientWidth, ch = canvas.clientHeight; const tw = tooltip.offsetWidth || 140, th = tooltip.offsetHeight || 40; // Place to the right of the cursor unless near the right edge. 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) { // 7-day view: dd/MM HH:mm 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 ''; const d = new Date(unixSec * 1000); return d.toLocaleString(); } _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`; } _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.SystemDetail = SystemDetail; window.systemDetail = window.systemDetail || new SystemDetail();