Replaces the JSON history file behind /api/system/history with a fixed-size
binary ring buffer on disk and adds a second, downsampled tier so the chart
can now span seven days, not just twenty-four hours.
Two on-disk rings under frontend/data/system/:
metrics_ring_1m.bin 1440 pts @ 1 min ( 24 h)
metrics_ring_5m.bin 2016 pts @ 5 min ( 7 d)
Each point is 32 bytes (uint32 timestamp + 7 float32 metrics — cpu / mem /
swap / disk / load1 / net_rx / net_tx); files carry a 32-byte header with
magic, version, capacity, head, count, bucket seconds, and last bucket time
so they're self-describing and torn-write recoverable.
A persistent 1-minute ticker inside the backend (independent of whether
anyone's subscribed to /api/system/stream) composes points from /proc plus
the bash generator's latest snapshots and appends to the 1m ring; every
five minutes it averages the last five 1m points into the 5m ring. On
first run, the writer backfills the 1m ring from the legacy
metrics_history.json so first paint already has 24 h.
/api/system/history?range=N auto-selects the tier (≤1440 → 1m, else 5m),
keeps the existing { points, updated } shape, and additionally returns
`tier` for clients that care. Falls back to the legacy JSON on cold start.
Admin → System: 7d added to the range picker (now 1h / 6h / 24h / 7d),
swap + load1 promoted to their own trend cards, and every gauge / chart
card grows an Expand affordance that opens a fullscreen single-metric
deep-dive overlay:
- Big themed chart with grid, gradient area, peak/min/now markers, and
a live-pulsing "now" dot
- Hover crosshair + tooltip scrubs the series with formatted time +
value
- now / peak / avg / min stat strip with deltas
- Range picker (1h / 6h / 24h / 7d) re-fetches and re-themes per metric
- 1 Hz live SSE feed updates the overlay's now-stat in real time
- Escape / backdrop / close button all dismiss
- Per-metric accent colour (cpu=accent, mem=info, disk/swap=warning,
net_rx=success, net_tx=accent, load=accent) flows through gradient,
border, dot, and stats card
Zero new dependencies — hand-rolled SVG and pointer events throughout.
Signed-off-by: librelad <librelad@digitalangels.vip>
436 lines
20 KiB
JavaScript
436 lines
20 KiB
JavaScript
// 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 <body>; 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 = `
|
|
<div class="sys-detail-backdrop" data-sys-detail-close></div>
|
|
<div class="sys-detail-panel" role="document">
|
|
<header class="sys-detail-head">
|
|
<div class="sys-detail-title">
|
|
<div class="sys-detail-eyebrow">Admin · System · Live</div>
|
|
<h2 class="sys-detail-name"></h2>
|
|
<p class="sys-detail-sub"></p>
|
|
</div>
|
|
<div class="sys-detail-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" data-rng="${m}">${l}</button>`).join('')}
|
|
</div>
|
|
<button type="button" class="sys-detail-close" data-sys-detail-close aria-label="Close">
|
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<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-detail-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 to close · hover the chart to scrub · range tier auto-selects</span>
|
|
</footer>
|
|
</div>`;
|
|
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 += `<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>`;
|
|
}
|
|
// 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 += `<text x="${x.toFixed(0)}" y="${(padT + innerH + 22).toFixed(0)}" class="sys-detail-axis sys-detail-axis-x">${this._fmtTime(t)}</text>`;
|
|
}
|
|
// Peak + min markers.
|
|
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"/>` : '';
|
|
// Now dot — pulses via CSS.
|
|
const nowIdx = n - 1;
|
|
const nowDot = `<circle cx="${xAt(nowIdx).toFixed(1)}" cy="${yAt(vals[nowIdx]).toFixed(1)}" r="5" class="sys-detail-now"/>`;
|
|
// Hover crosshair (positioned in _setHover).
|
|
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"/>`;
|
|
// Gradient id is stable so the fill keeps animating during live updates.
|
|
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"/>
|
|
${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 = `
|
|
<div class="sys-detail-tip-v">${fmt(v)}</div>
|
|
<div class="sys-detail-tip-t">${this._fmtTimeFull(t)}</div>
|
|
`;
|
|
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();
|