mirror of
https://github.com/XuehaiPan/nvitop.git
synced 2026-05-21 06:45:24 -06:00
1360 lines
46 KiB
HTML
1360 lines
46 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>nvitop web</title>
|
|
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #0f1115;
|
|
--card: #181b22;
|
|
--border: #2a2f3a;
|
|
--accent: #4ade80;
|
|
--warn: #fbbf24;
|
|
--danger: #f87171;
|
|
--text: #e2e8f0;
|
|
--muted: #94a3b8;
|
|
}
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
margin: 0;
|
|
padding: 16px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
}
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
h1 {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
h1 small {
|
|
color: var(--muted);
|
|
font-weight: 400;
|
|
margin-left: 8px;
|
|
}
|
|
h2 {
|
|
margin: 0;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
}
|
|
button {
|
|
color: inherit;
|
|
font: inherit;
|
|
}
|
|
.header-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
justify-content: flex-end;
|
|
}
|
|
.endpoint-button {
|
|
background: #0b0d12;
|
|
border: 1px solid var(--border);
|
|
border-radius: 999px;
|
|
cursor: pointer;
|
|
padding: 4px 10px;
|
|
}
|
|
.endpoint-button:hover {
|
|
border-color: var(--muted);
|
|
}
|
|
.history-toolbar {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-bottom: 8px;
|
|
}
|
|
.chart-panel {
|
|
background: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
margin-bottom: 16px;
|
|
padding: 12px 16px 8px;
|
|
}
|
|
.chart-head {
|
|
margin-bottom: 8px;
|
|
}
|
|
.chart-status {
|
|
color: var(--muted);
|
|
display: inline-block;
|
|
font-size: 12px;
|
|
margin-top: 3px;
|
|
}
|
|
.history-range {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
justify-content: flex-end;
|
|
}
|
|
.history-range button {
|
|
background: #0b0d12;
|
|
border: 1px solid var(--border);
|
|
border-radius: 999px;
|
|
cursor: pointer;
|
|
min-width: 42px;
|
|
padding: 4px 9px;
|
|
}
|
|
.history-range button.active {
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
}
|
|
.host-chart {
|
|
height: 260px;
|
|
width: 100%;
|
|
}
|
|
.chart-grid {
|
|
display: grid;
|
|
gap: 16px;
|
|
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
margin-bottom: 16px;
|
|
}
|
|
.gpu-chart {
|
|
height: 260px;
|
|
width: 100%;
|
|
}
|
|
.chart-legend {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px 16px;
|
|
margin: 8px 0 6px;
|
|
color: var(--text);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
.legend-item {
|
|
background: transparent;
|
|
border: 0;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
font: inherit;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
padding: 0;
|
|
white-space: nowrap;
|
|
}
|
|
.legend-item.hidden {
|
|
color: var(--muted);
|
|
opacity: 0.55;
|
|
}
|
|
.legend-item:focus-visible {
|
|
border-radius: 2px;
|
|
outline: 1px solid var(--accent);
|
|
outline-offset: 2px;
|
|
}
|
|
.legend-swatch {
|
|
width: 28px;
|
|
height: 2px;
|
|
border-radius: 999px;
|
|
flex: 0 0 auto;
|
|
}
|
|
.cards {
|
|
display: grid;
|
|
gap: 16px;
|
|
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
margin-bottom: 16px;
|
|
}
|
|
.card {
|
|
background: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
}
|
|
.card-title {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
font-weight: 600;
|
|
}
|
|
.card-title .idx {
|
|
color: var(--accent);
|
|
margin-right: 6px;
|
|
}
|
|
.card-title .total {
|
|
color: var(--muted);
|
|
font-weight: 400;
|
|
font-size: 13px;
|
|
}
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: 70px minmax(0, 1fr) 118px;
|
|
gap: 8px;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
font-size: 13px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.row > label {
|
|
color: var(--muted);
|
|
}
|
|
.row > .value {
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}
|
|
.bar {
|
|
position: relative;
|
|
min-width: 0;
|
|
height: 10px;
|
|
background: #0b0d12;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
}
|
|
.bar-fill {
|
|
position: absolute;
|
|
inset: 0 auto 0 0;
|
|
width: 0%;
|
|
background: linear-gradient(90deg, var(--accent), #22d3ee);
|
|
transition: width 200ms linear;
|
|
}
|
|
.bar-fill.warn {
|
|
background: linear-gradient(90deg, var(--warn), #f97316);
|
|
}
|
|
.bar-fill.danger {
|
|
background: linear-gradient(90deg, var(--danger), #be123c);
|
|
}
|
|
.status {
|
|
margin-bottom: 16px;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
.badge {
|
|
background: #0b0d12;
|
|
border: 1px solid var(--border);
|
|
border-radius: 999px;
|
|
padding: 4px 10px;
|
|
}
|
|
.thermal-value {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 6px;
|
|
white-space: nowrap;
|
|
}
|
|
.stale {
|
|
color: var(--danger);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>nvitop web <small id="origin"></small></h1>
|
|
<div class="header-actions">
|
|
<button
|
|
class="endpoint-button"
|
|
type="button"
|
|
data-endpoint="metrics-origin"
|
|
title="Get metrics.json for the current page"
|
|
>
|
|
Get metrics
|
|
</button>
|
|
<span class="badge" id="updated">Updated: —</span>
|
|
</div>
|
|
</header>
|
|
<div class="status">
|
|
<span class="badge" id="host">Host: —</span>
|
|
<span class="badge" id="buffer">Buffer: —</span>
|
|
<span id="stale" class="stale" hidden></span>
|
|
</div>
|
|
<div class="history-toolbar">
|
|
<div class="history-range" id="history-range">
|
|
<button type="button" data-range="1m">1m</button>
|
|
<button type="button" data-range="5m">5m</button>
|
|
<button type="button" data-range="15m">15m</button>
|
|
<button class="active" type="button" data-range="30m">30m</button>
|
|
<button type="button" data-range="1h">1h</button>
|
|
<button type="button" data-range="3h">3h</button>
|
|
<button type="button" data-range="6h">6h</button>
|
|
<button type="button" data-range="12h">12h</button>
|
|
<button type="button" data-range="24h">24h</button>
|
|
</div>
|
|
</div>
|
|
<section class="chart-panel">
|
|
<div class="chart-head">
|
|
<div>
|
|
<h2>Host</h2>
|
|
<span class="chart-status" id="chart-status">—</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="chart-legend"
|
|
data-chart-legend="host-chart"
|
|
id="host-legend"
|
|
></div>
|
|
<div class="host-chart" id="host-chart"></div>
|
|
</section>
|
|
<div id="cards" class="cards"></div>
|
|
<div class="chart-grid" id="gpu-charts"></div>
|
|
<script>
|
|
(() => {
|
|
const KEY_PREFIX = "monitor/";
|
|
const POLL_MS = 1000;
|
|
const HISTORY_BACKFILL_GAP_SECONDS = Math.max(5, 3 * POLL_MS / 1000);
|
|
const MAX_HISTORY_SAMPLES = 3600;
|
|
const HISTORY_RANGES = {
|
|
"1m": 60,
|
|
"5m": 5 * 60,
|
|
"15m": 15 * 60,
|
|
"30m": 30 * 60,
|
|
"1h": 60 * 60,
|
|
"3h": 3 * 60 * 60,
|
|
"6h": 6 * 60 * 60,
|
|
"12h": 12 * 60 * 60,
|
|
"24h": 24 * 60 * 60,
|
|
};
|
|
const DEFAULT_HISTORY_RANGE = "30m";
|
|
const PLOT_LINE_WIDTH = 1.5;
|
|
const hasHistoryRange = (range) =>
|
|
Object.prototype.hasOwnProperty.call(HISTORY_RANGES, range);
|
|
const normalizeHistoryRange = (range) =>
|
|
hasHistoryRange(range) ? range : DEFAULT_HISTORY_RANGE;
|
|
const historyRangeFromUrl = () =>
|
|
normalizeHistoryRange(
|
|
new URLSearchParams(window.location.search).get("range"),
|
|
);
|
|
const replaceUrlHistoryRange = (range) => {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set("range", normalizeHistoryRange(range));
|
|
history.replaceState(null, "", url);
|
|
};
|
|
let timer = null;
|
|
let historyRange = historyRangeFromUrl();
|
|
let selectedHistoryRange = historyRange;
|
|
let historyWindowSeconds = HISTORY_RANGES[historyRange];
|
|
let chartSamples = [];
|
|
let chartXRange = null;
|
|
let currentDevices = [];
|
|
let currentHostname = location.hostname || "unknown";
|
|
let historyAbortController = null;
|
|
let historyRequestId = 0;
|
|
let historyRefreshPromise = null;
|
|
const chartRelayoutHandlersAttached = new Set();
|
|
const hiddenChartTraces = new Map();
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const setText = (id, text) => {
|
|
$(id).textContent = text;
|
|
};
|
|
const pageTitle = (hostname) => `nvitop web - ${hostname}`;
|
|
const metricsEndpoint = () =>
|
|
new URL("/metrics.json", window.location.href).toString();
|
|
|
|
const initEndpointButtons = () => {
|
|
const buttons = document.querySelectorAll("button[data-endpoint]");
|
|
for (const button of buttons) {
|
|
button.addEventListener("click", () => {
|
|
window.open(metricsEndpoint(), "_blank", "noopener");
|
|
});
|
|
}
|
|
};
|
|
|
|
const fmtPercent = (v) =>
|
|
Number.isFinite(v) ? v.toFixed(0) + "%" : "—";
|
|
const fmtPercentOne = (v) =>
|
|
Number.isFinite(v) ? v.toFixed(1) + "%" : "—";
|
|
const fmtC = (v) => (Number.isFinite(v) ? v.toFixed(0) + " °C" : "—");
|
|
const fmtW = (v) => (Number.isFinite(v) ? v.toFixed(1) + " W" : "—");
|
|
const fmtGibUsage = (value) => {
|
|
if (!Number.isFinite(value)) return "—";
|
|
if (Math.abs(value) >= 1024) return (value / 1024).toFixed(1) + "TiB";
|
|
if (Math.abs(value) < 1) return (value * 1024).toFixed(0) + "MiB";
|
|
return value.toFixed(1) + "GiB";
|
|
};
|
|
const fmtMibUsage = (value) => {
|
|
if (!Number.isFinite(value)) return "—";
|
|
if (Math.abs(value) >= 1024) return (value / 1024).toFixed(1) + "GiB";
|
|
return value.toFixed(0) + "MiB";
|
|
};
|
|
const fmtAge = (s) => {
|
|
if (!Number.isFinite(s) || s < 0) return "—";
|
|
if (s < 60) return s.toFixed(0) + "s";
|
|
if (s < 3600) return (s / 60).toFixed(0) + "m";
|
|
if (s < 86400) return (s / 3600).toFixed(1) + "h";
|
|
return (s / 86400).toFixed(1) + "d";
|
|
};
|
|
|
|
const deviceScope = (info) => `gpu:${info.index}`;
|
|
|
|
const pickLast = (metrics, scope, name) => {
|
|
const prefix = `${KEY_PREFIX}${scope}/${name} (`;
|
|
for (const key of Object.keys(metrics)) {
|
|
if (key.startsWith(prefix) && key.endsWith("/last"))
|
|
return metrics[key];
|
|
}
|
|
return NaN;
|
|
};
|
|
const pickHumanLast = (metricsHuman, scope, name) => {
|
|
const prefix = `${KEY_PREFIX}${scope}/${name} (`;
|
|
for (const key of Object.keys(metricsHuman)) {
|
|
if (key.startsWith(prefix) && key.endsWith("/last"))
|
|
return metricsHuman[key];
|
|
}
|
|
return "—";
|
|
};
|
|
const pickHostLast = (metrics, name) => {
|
|
const prefix = `${KEY_PREFIX}host/${name} (`;
|
|
for (const key of Object.keys(metrics)) {
|
|
if (key.startsWith(prefix) && key.endsWith("/last"))
|
|
return metrics[key];
|
|
}
|
|
return NaN;
|
|
};
|
|
const sampleDeviceMetric = (sample, scope, name) =>
|
|
pickLast(sample.metrics || {}, scope, name);
|
|
|
|
const barClass = (pct) =>
|
|
pct >= 90
|
|
? "bar-fill danger"
|
|
: pct >= 70
|
|
? "bar-fill warn"
|
|
: "bar-fill";
|
|
const plotHoverLabel = {
|
|
bgcolor: "#181b22",
|
|
bordercolor: "#2a2f3a",
|
|
font: { color: "#e2e8f0", size: 12 },
|
|
};
|
|
|
|
const historySeconds = () =>
|
|
historyWindowSeconds || HISTORY_RANGES[DEFAULT_HISTORY_RANGE];
|
|
const historyBucketSeconds = (seconds) =>
|
|
Math.max(1, Math.ceil(seconds / MAX_HISTORY_SAMPLES));
|
|
|
|
const trimChartSamples = (nowEpoch = Date.now() / 1000) => {
|
|
const minEpoch = nowEpoch - historySeconds();
|
|
chartSamples = chartSamples.filter(
|
|
(sample) => sample.epoch >= minEpoch,
|
|
);
|
|
};
|
|
|
|
const hasHistoryBackfillGap = (epoch) => {
|
|
const last = chartSamples[chartSamples.length - 1];
|
|
return (
|
|
last !== undefined &&
|
|
epoch > last.epoch + HISTORY_BACKFILL_GAP_SECONDS
|
|
);
|
|
};
|
|
|
|
const chartSampleCount = () =>
|
|
chartSamples.length === 1
|
|
? "1 sample"
|
|
: `${chartSamples.length.toLocaleString()} samples`;
|
|
|
|
const epochFromRangeValue = (value) => {
|
|
if (value instanceof Date) return value.getTime() / 1000;
|
|
if (typeof value === "number") {
|
|
return value > 1e12 ? value / 1000 : value;
|
|
}
|
|
const parsed = Date.parse(value);
|
|
if (Number.isFinite(parsed)) return parsed / 1000;
|
|
const numeric = Number(value);
|
|
if (Number.isFinite(numeric))
|
|
return numeric > 1e12 ? numeric / 1000 : numeric;
|
|
return NaN;
|
|
};
|
|
|
|
const visibleChartSamples = () => {
|
|
if (chartXRange === null) return chartSamples;
|
|
const start = epochFromRangeValue(chartXRange[0]);
|
|
const end = epochFromRangeValue(chartXRange[1]);
|
|
if (!Number.isFinite(start) || !Number.isFinite(end))
|
|
return chartSamples;
|
|
const minEpoch = Math.min(start, end);
|
|
const maxEpoch = Math.max(start, end);
|
|
return chartSamples.filter(
|
|
(sample) => sample.epoch >= minEpoch && sample.epoch <= maxEpoch,
|
|
);
|
|
};
|
|
|
|
const latestVisiblePercent = (name) => {
|
|
const samples = visibleChartSamples();
|
|
for (let i = samples.length - 1; i >= 0; i -= 1) {
|
|
const value = pickHostLast(samples[i].metrics, name);
|
|
if (Number.isFinite(value)) return value;
|
|
}
|
|
return NaN;
|
|
};
|
|
|
|
const latestVisibleHostMemoryUsage = () => {
|
|
const samples = visibleChartSamples();
|
|
for (let i = samples.length - 1; i >= 0; i -= 1) {
|
|
const value = pickHostLast(samples[i].metrics, "memory_used");
|
|
if (Number.isFinite(value)) return value;
|
|
}
|
|
return NaN;
|
|
};
|
|
|
|
const latestVisibleDeviceMetric = (scope, name) => {
|
|
const samples = visibleChartSamples();
|
|
for (let i = samples.length - 1; i >= 0; i -= 1) {
|
|
const value = sampleDeviceMetric(samples[i], scope, name);
|
|
if (Number.isFinite(value)) return value;
|
|
}
|
|
return NaN;
|
|
};
|
|
|
|
const samplePowerPercent = (sample, scope) => {
|
|
const power = sampleDeviceMetric(sample, scope, "power_usage");
|
|
const powerLimit = sampleDeviceMetric(sample, scope, "power_limit");
|
|
if (
|
|
Number.isFinite(power) &&
|
|
Number.isFinite(powerLimit) &&
|
|
powerLimit > 0
|
|
) {
|
|
return (power / powerLimit) * 100;
|
|
}
|
|
return NaN;
|
|
};
|
|
|
|
const latestVisibleGpuMemoryUsage = (scope) =>
|
|
latestVisibleDeviceMetric(scope, "memory_used");
|
|
|
|
const latestVisibleGpuPowerUsage = (scope) =>
|
|
latestVisibleDeviceMetric(scope, "power_usage");
|
|
|
|
const tracePercentName = (label, value) =>
|
|
Number.isFinite(value)
|
|
? `${label} ${fmtPercentOne(value)}`
|
|
: `${label} —`;
|
|
const traceUsageName = (label, value) =>
|
|
`${label} ${fmtGibUsage(value)}`;
|
|
const traceMemoryName = (label, usage, pct) =>
|
|
`${label} ${fmtMibUsage(usage)} (${fmtPercentOne(pct)})`;
|
|
const tracePowerName = (label, watts) => `${label} ${fmtW(watts)}`;
|
|
|
|
const isTraceHidden = (chartId, key) =>
|
|
hiddenChartTraces.get(chartId)?.has(key) || false;
|
|
|
|
const setTraceHidden = (chartId, key, hidden) => {
|
|
const hiddenTraces = hiddenChartTraces.get(chartId) || new Set();
|
|
if (hidden) {
|
|
hiddenTraces.add(key);
|
|
hiddenChartTraces.set(chartId, hiddenTraces);
|
|
} else {
|
|
hiddenTraces.delete(key);
|
|
if (hiddenTraces.size === 0) {
|
|
hiddenChartTraces.delete(chartId);
|
|
} else {
|
|
hiddenChartTraces.set(chartId, hiddenTraces);
|
|
}
|
|
}
|
|
};
|
|
|
|
const syncLegendItemState = (item, hidden) => {
|
|
item.classList.toggle("hidden", hidden);
|
|
item.setAttribute("aria-pressed", String(!hidden));
|
|
item.title = `${hidden ? "Show" : "Hide"} ${item.dataset.traceLabel}`;
|
|
};
|
|
|
|
const toggleChartTrace = (chartId, key, traceIndex) => {
|
|
const hidden = !isTraceHidden(chartId, key);
|
|
setTraceHidden(chartId, key, hidden);
|
|
|
|
const legendItem = document.querySelector(
|
|
`[data-chart-legend="${chartId}"] [data-trace="${key}"]`,
|
|
);
|
|
if (legendItem) syncLegendItemState(legendItem, hidden);
|
|
|
|
if (window.Plotly && $(chartId)) {
|
|
Plotly.restyle(
|
|
chartId,
|
|
{ visible: hidden ? "legendonly" : true },
|
|
[traceIndex],
|
|
);
|
|
}
|
|
};
|
|
|
|
const setChartLegend = (legend, chartId, items) => {
|
|
legend.replaceChildren(
|
|
...items.map((item, traceIndex) => {
|
|
const entry = document.createElement("button");
|
|
entry.className = "legend-item";
|
|
entry.type = "button";
|
|
entry.dataset.trace = item.key;
|
|
entry.dataset.traceLabel = item.name;
|
|
const swatch = document.createElement("span");
|
|
swatch.className = "legend-swatch";
|
|
swatch.style.background = item.color;
|
|
const label = document.createElement("span");
|
|
label.textContent = item.label;
|
|
entry.append(swatch, label);
|
|
syncLegendItemState(entry, isTraceHidden(chartId, item.key));
|
|
entry.addEventListener("click", () => {
|
|
toggleChartTrace(chartId, item.key, traceIndex);
|
|
});
|
|
return entry;
|
|
}),
|
|
);
|
|
};
|
|
|
|
const setActiveHistoryRange = (activeButton) => {
|
|
const rangeEl = $("history-range");
|
|
for (const child of rangeEl.querySelectorAll("button[data-range]")) {
|
|
child.classList.toggle("active", child === activeButton);
|
|
}
|
|
};
|
|
|
|
const historyButton = (range) =>
|
|
$("history-range").querySelector(`[data-range="${range}"]`);
|
|
|
|
const xRangeFromRelayout = (event) => {
|
|
if (!event) return null;
|
|
if (
|
|
Array.isArray(event["xaxis.range"]) &&
|
|
event["xaxis.range"].length === 2
|
|
) {
|
|
return event["xaxis.range"];
|
|
}
|
|
if (
|
|
event["xaxis.range[0]"] !== undefined &&
|
|
event["xaxis.range[1]"] !== undefined
|
|
) {
|
|
return [event["xaxis.range[0]"], event["xaxis.range[1]"]];
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const xRangesEqual = (left, right) =>
|
|
left !== null &&
|
|
right !== null &&
|
|
left.length === 2 &&
|
|
right.length === 2 &&
|
|
String(left[0]) === String(right[0]) &&
|
|
String(left[1]) === String(right[1]);
|
|
|
|
const handleChartRelayout = (event) => {
|
|
const range = xRangeFromRelayout(event);
|
|
if (range !== null) {
|
|
const changed =
|
|
historyRange !== null || !xRangesEqual(chartXRange, range);
|
|
historyRange = null;
|
|
chartXRange = range;
|
|
setActiveHistoryRange(null);
|
|
if (changed) renderAllCharts();
|
|
} else if (event && event["xaxis.autorange"]) {
|
|
const restoredRange = selectedHistoryRange || DEFAULT_HISTORY_RANGE;
|
|
const changed =
|
|
historyRange !== restoredRange || chartXRange !== null;
|
|
historyRange = selectedHistoryRange || DEFAULT_HISTORY_RANGE;
|
|
chartXRange = null;
|
|
setActiveHistoryRange(historyButton(historyRange));
|
|
if (changed) renderAllCharts();
|
|
}
|
|
};
|
|
|
|
const attachChartRelayoutHandler = (chartId) => {
|
|
const chart = $(chartId);
|
|
if (
|
|
chartRelayoutHandlersAttached.has(chartId) ||
|
|
typeof chart.on !== "function"
|
|
) {
|
|
return;
|
|
}
|
|
chart.on("plotly_relayout", handleChartRelayout);
|
|
chartRelayoutHandlersAttached.add(chartId);
|
|
};
|
|
|
|
const renderHostChart = () => {
|
|
if (!window.Plotly) {
|
|
setText("chart-status", "Plotly unavailable");
|
|
return;
|
|
}
|
|
|
|
trimChartSamples();
|
|
const x = chartSamples.map((sample) => new Date(sample.epoch * 1000));
|
|
const cpu = chartSamples.map((sample) => {
|
|
const value = pickHostLast(sample.metrics, "cpu_percent");
|
|
return Number.isFinite(value) ? value : null;
|
|
});
|
|
const memory = chartSamples.map((sample) => {
|
|
const value = pickHostLast(sample.metrics, "memory_percent");
|
|
return Number.isFinite(value) ? value : null;
|
|
});
|
|
const memoryUsage = chartSamples.map((sample) =>
|
|
fmtGibUsage(pickHostLast(sample.metrics, "memory_used")),
|
|
);
|
|
const xaxis = {
|
|
gridcolor: "#2a2f3a",
|
|
linecolor: "#2a2f3a",
|
|
tickformat: "%H:%M:%S",
|
|
tickfont: { color: "#94a3b8" },
|
|
zeroline: false,
|
|
};
|
|
if (chartXRange !== null) {
|
|
xaxis.range = chartXRange;
|
|
}
|
|
const legendItems = [
|
|
{
|
|
color: "#4ade80",
|
|
key: "cpu",
|
|
label: tracePercentName(
|
|
"CPU",
|
|
latestVisiblePercent("cpu_percent"),
|
|
),
|
|
name: "CPU",
|
|
},
|
|
{
|
|
color: "#38bdf8",
|
|
key: "memory",
|
|
label: traceUsageName(
|
|
"Host Memory",
|
|
latestVisibleHostMemoryUsage(),
|
|
),
|
|
name: "Host Memory",
|
|
},
|
|
];
|
|
setChartLegend($("host-legend"), "host-chart", legendItems);
|
|
const traceVisible = (key) =>
|
|
isTraceHidden("host-chart", key) ? "legendonly" : true;
|
|
|
|
const layout = {
|
|
autosize: true,
|
|
font: { color: "#e2e8f0", size: 12 },
|
|
hoverlabel: plotHoverLabel,
|
|
hovermode: "x unified",
|
|
showlegend: false,
|
|
margin: { b: 34, l: 42, r: 16, t: 8 },
|
|
paper_bgcolor: "rgba(0,0,0,0)",
|
|
plot_bgcolor: "#0b0d12",
|
|
uirevision: historyRange || "custom",
|
|
xaxis,
|
|
yaxis: {
|
|
gridcolor: "#2a2f3a",
|
|
linecolor: "#2a2f3a",
|
|
range: [0, 100],
|
|
ticksuffix: "%",
|
|
tickfont: { color: "#94a3b8" },
|
|
zeroline: false,
|
|
},
|
|
};
|
|
const traces = [
|
|
{
|
|
hoverlabel: plotHoverLabel,
|
|
hovertemplate: "CPU %{y:.1f}%<extra></extra>",
|
|
line: { color: "#4ade80", width: PLOT_LINE_WIDTH },
|
|
mode: "lines",
|
|
name: "CPU",
|
|
type: "scatter",
|
|
visible: traceVisible("cpu"),
|
|
x,
|
|
y: cpu,
|
|
},
|
|
{
|
|
hoverlabel: plotHoverLabel,
|
|
hovertemplate: "Host Memory %{text}<extra></extra>",
|
|
line: { color: "#38bdf8", width: PLOT_LINE_WIDTH },
|
|
mode: "lines",
|
|
name: "Host Memory",
|
|
text: memoryUsage,
|
|
type: "scatter",
|
|
visible: traceVisible("memory"),
|
|
x,
|
|
y: memory,
|
|
},
|
|
];
|
|
const rendered = Plotly.react("host-chart", traces, layout, {
|
|
displayModeBar: false,
|
|
responsive: true,
|
|
});
|
|
if (rendered && typeof rendered.then === "function") {
|
|
rendered.then(() => attachChartRelayoutHandler("host-chart"));
|
|
} else {
|
|
attachChartRelayoutHandler("host-chart");
|
|
}
|
|
setText("chart-status", chartSampleCount());
|
|
};
|
|
|
|
const gpuChartId = (info) => `gpu-chart-${info.index}`;
|
|
const gpuTitleParts = (info) => ({
|
|
index: `#${info.index}`,
|
|
name: info.name,
|
|
});
|
|
const gpuTitle = (info) => {
|
|
const parts = gpuTitleParts(info);
|
|
return `${parts.index} ${parts.name}`;
|
|
};
|
|
|
|
const buildGpuChartPanel = (info) => {
|
|
const panel = document.createElement("section");
|
|
panel.className = "chart-panel";
|
|
panel.dataset.chartId = gpuChartId(info);
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "chart-head";
|
|
const title = document.createElement("h2");
|
|
title.textContent = gpuTitle(info);
|
|
head.appendChild(title);
|
|
panel.appendChild(head);
|
|
|
|
const legend = document.createElement("div");
|
|
legend.className = "chart-legend";
|
|
legend.dataset.chartLegend = gpuChartId(info);
|
|
panel.appendChild(legend);
|
|
|
|
const chart = document.createElement("div");
|
|
chart.className = "gpu-chart";
|
|
chart.id = gpuChartId(info);
|
|
panel.appendChild(chart);
|
|
return panel;
|
|
};
|
|
|
|
const syncGpuChartPanels = (devices) => {
|
|
const chartsEl = $("gpu-charts");
|
|
const nextIds = devices.map(gpuChartId);
|
|
const currentIds = Array.from(chartsEl.children).map(
|
|
(child) => child.dataset.chartId || "",
|
|
);
|
|
if (
|
|
nextIds.length !== currentIds.length ||
|
|
nextIds.some((id, index) => id !== currentIds[index])
|
|
) {
|
|
for (const id of currentIds) {
|
|
chartRelayoutHandlersAttached.delete(id);
|
|
if (!nextIds.includes(id)) hiddenChartTraces.delete(id);
|
|
}
|
|
chartsEl.replaceChildren(...devices.map(buildGpuChartPanel));
|
|
}
|
|
};
|
|
|
|
const renderGpuCharts = () => {
|
|
if (!window.Plotly) return;
|
|
|
|
const x = chartSamples.map((sample) => new Date(sample.epoch * 1000));
|
|
for (const info of currentDevices) {
|
|
const chartId = gpuChartId(info);
|
|
if (!$(chartId)) continue;
|
|
const scope = deviceScope(info);
|
|
const colors = {
|
|
gpu: "#4ade80",
|
|
membw: "#fbbf24",
|
|
memory: "#38bdf8",
|
|
power: "#f87171",
|
|
};
|
|
const gpu = chartSamples.map((sample) => {
|
|
const value = sampleDeviceMetric(
|
|
sample,
|
|
scope,
|
|
"gpu_utilization",
|
|
);
|
|
return Number.isFinite(value) ? value : null;
|
|
});
|
|
const membw = chartSamples.map((sample) => {
|
|
const value = sampleDeviceMetric(
|
|
sample,
|
|
scope,
|
|
"memory_utilization",
|
|
);
|
|
return Number.isFinite(value) ? value : null;
|
|
});
|
|
const memory = chartSamples.map((sample) => {
|
|
const value = sampleDeviceMetric(sample, scope, "memory_percent");
|
|
return Number.isFinite(value) ? value : null;
|
|
});
|
|
const memoryUsage = chartSamples.map((sample) =>
|
|
fmtMibUsage(sampleDeviceMetric(sample, scope, "memory_used")),
|
|
);
|
|
const power = chartSamples.map((sample) => {
|
|
const value = samplePowerPercent(sample, scope);
|
|
return Number.isFinite(value) ? value : null;
|
|
});
|
|
const powerUsage = chartSamples.map((sample) =>
|
|
fmtW(sampleDeviceMetric(sample, scope, "power_usage")),
|
|
);
|
|
const xaxis = {
|
|
gridcolor: "#2a2f3a",
|
|
linecolor: "#2a2f3a",
|
|
tickformat: "%H:%M:%S",
|
|
tickfont: { color: "#94a3b8" },
|
|
zeroline: false,
|
|
};
|
|
if (chartXRange !== null) {
|
|
xaxis.range = chartXRange;
|
|
}
|
|
const legendItems = [
|
|
{
|
|
color: colors.gpu,
|
|
key: "gpu",
|
|
label: tracePercentName(
|
|
"GPU",
|
|
latestVisibleDeviceMetric(scope, "gpu_utilization"),
|
|
),
|
|
name: "GPU",
|
|
},
|
|
{
|
|
color: colors.membw,
|
|
key: "membw",
|
|
label: tracePercentName(
|
|
"GMBW",
|
|
latestVisibleDeviceMetric(scope, "memory_utilization"),
|
|
),
|
|
name: "GMBW",
|
|
},
|
|
{
|
|
color: colors.memory,
|
|
key: "memory",
|
|
label: traceMemoryName(
|
|
"GMEM",
|
|
latestVisibleGpuMemoryUsage(scope),
|
|
latestVisibleDeviceMetric(scope, "memory_percent"),
|
|
),
|
|
name: "GMEM",
|
|
},
|
|
{
|
|
color: colors.power,
|
|
key: "power",
|
|
label: tracePowerName(
|
|
"Power",
|
|
latestVisibleGpuPowerUsage(scope),
|
|
),
|
|
name: "Power",
|
|
},
|
|
];
|
|
const legend = document.querySelector(
|
|
`[data-chart-legend="${chartId}"]`,
|
|
);
|
|
if (legend) setChartLegend(legend, chartId, legendItems);
|
|
const traceVisible = (key) =>
|
|
isTraceHidden(chartId, key) ? "legendonly" : true;
|
|
|
|
const layout = {
|
|
autosize: true,
|
|
font: { color: "#e2e8f0", size: 12 },
|
|
hoverlabel: plotHoverLabel,
|
|
hovermode: "x unified",
|
|
showlegend: false,
|
|
margin: { b: 34, l: 42, r: 16, t: 8 },
|
|
paper_bgcolor: "rgba(0,0,0,0)",
|
|
plot_bgcolor: "#0b0d12",
|
|
uirevision: historyRange || "custom",
|
|
xaxis,
|
|
yaxis: {
|
|
gridcolor: "#2a2f3a",
|
|
linecolor: "#2a2f3a",
|
|
range: [0, 100],
|
|
ticksuffix: "%",
|
|
tickfont: { color: "#94a3b8" },
|
|
zeroline: false,
|
|
},
|
|
};
|
|
const traces = [
|
|
{
|
|
hoverlabel: plotHoverLabel,
|
|
hovertemplate: "GPU %{y:.1f}%<extra></extra>",
|
|
line: { color: colors.gpu, width: PLOT_LINE_WIDTH },
|
|
mode: "lines",
|
|
name: "GPU",
|
|
type: "scatter",
|
|
visible: traceVisible("gpu"),
|
|
x,
|
|
y: gpu,
|
|
},
|
|
{
|
|
hoverlabel: plotHoverLabel,
|
|
hovertemplate: "GMBW %{y:.1f}%<extra></extra>",
|
|
line: { color: colors.membw, width: PLOT_LINE_WIDTH },
|
|
mode: "lines",
|
|
name: "GMBW",
|
|
type: "scatter",
|
|
visible: traceVisible("membw"),
|
|
x,
|
|
y: membw,
|
|
},
|
|
{
|
|
hoverlabel: plotHoverLabel,
|
|
hovertemplate: "GMEM %{text}<extra></extra>",
|
|
line: { color: colors.memory, width: PLOT_LINE_WIDTH },
|
|
mode: "lines",
|
|
name: "GMEM",
|
|
text: memoryUsage,
|
|
type: "scatter",
|
|
visible: traceVisible("memory"),
|
|
x,
|
|
y: memory,
|
|
},
|
|
{
|
|
hoverlabel: plotHoverLabel,
|
|
hovertemplate: "Power %{text}<extra></extra>",
|
|
line: { color: colors.power, width: PLOT_LINE_WIDTH },
|
|
mode: "lines",
|
|
name: "Power",
|
|
text: powerUsage,
|
|
type: "scatter",
|
|
visible: traceVisible("power"),
|
|
x,
|
|
y: power,
|
|
},
|
|
];
|
|
const rendered = Plotly.react(chartId, traces, layout, {
|
|
displayModeBar: false,
|
|
responsive: true,
|
|
});
|
|
if (rendered && typeof rendered.then === "function") {
|
|
rendered.then(() => attachChartRelayoutHandler(chartId));
|
|
} else {
|
|
attachChartRelayoutHandler(chartId);
|
|
}
|
|
}
|
|
};
|
|
|
|
const renderAllCharts = () => {
|
|
renderHostChart();
|
|
renderGpuCharts();
|
|
};
|
|
|
|
const appendChartSample = (epoch, metrics) => {
|
|
if (!Number.isFinite(epoch) || epoch <= 0) return;
|
|
if (hasHistoryBackfillGap(epoch)) {
|
|
requestHistoryRefresh();
|
|
return;
|
|
}
|
|
const sample = { epoch, metrics: metrics || {} };
|
|
const last = chartSamples[chartSamples.length - 1];
|
|
if (last && epoch <= last.epoch) {
|
|
if (epoch === last.epoch)
|
|
chartSamples[chartSamples.length - 1] = sample;
|
|
} else {
|
|
chartSamples.push(sample);
|
|
}
|
|
trimChartSamples(epoch);
|
|
renderAllCharts();
|
|
};
|
|
|
|
const requestHistoryRefresh = ({ force = false } = {}) => {
|
|
if (historyRefreshPromise !== null && !force) {
|
|
return historyRefreshPromise;
|
|
}
|
|
const promise = syncHistory();
|
|
historyRefreshPromise = promise;
|
|
promise.finally(() => {
|
|
if (historyRefreshPromise === promise) historyRefreshPromise = null;
|
|
});
|
|
return promise;
|
|
};
|
|
|
|
async function syncHistory() {
|
|
const requestId = (historyRequestId += 1);
|
|
const seconds = historySeconds();
|
|
const bucketSeconds = historyBucketSeconds(seconds);
|
|
const since = Math.max(
|
|
0,
|
|
Math.floor((Date.now() / 1000 - seconds) / bucketSeconds) *
|
|
bucketSeconds,
|
|
);
|
|
if (historyAbortController !== null) {
|
|
historyAbortController.abort();
|
|
}
|
|
historyAbortController = new AbortController();
|
|
const params = new URLSearchParams({
|
|
bucket_seconds: String(bucketSeconds),
|
|
max_samples: String(MAX_HISTORY_SAMPLES),
|
|
since: since.toFixed(3),
|
|
});
|
|
let payload;
|
|
try {
|
|
const resp = await fetch("/history.json?" + params.toString(), {
|
|
cache: "no-store",
|
|
signal: historyAbortController.signal,
|
|
});
|
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
|
payload = await resp.json();
|
|
} catch (err) {
|
|
if (err.name === "AbortError") return;
|
|
setText("chart-status", "history failed: " + err.message);
|
|
return;
|
|
}
|
|
if (requestId !== historyRequestId) return;
|
|
|
|
chartSamples = (payload.samples || [])
|
|
.map((sample) => ({
|
|
epoch: Number(sample.epoch),
|
|
metrics: sample.metrics || {},
|
|
}))
|
|
.filter(
|
|
(sample) => Number.isFinite(sample.epoch) && sample.epoch > 0,
|
|
);
|
|
trimChartSamples();
|
|
renderAllCharts();
|
|
}
|
|
|
|
const applyHistoryRange = (
|
|
range,
|
|
{ sync = false, updateUrl = false } = {},
|
|
) => {
|
|
historyRange = normalizeHistoryRange(range);
|
|
selectedHistoryRange = historyRange;
|
|
historyWindowSeconds = HISTORY_RANGES[historyRange];
|
|
chartXRange = null;
|
|
setActiveHistoryRange(historyButton(historyRange));
|
|
if (updateUrl) replaceUrlHistoryRange(historyRange);
|
|
if (sync) requestHistoryRefresh({ force: true });
|
|
};
|
|
|
|
const initHistoryRange = () => {
|
|
const rangeEl = $("history-range");
|
|
applyHistoryRange(historyRange);
|
|
rangeEl.addEventListener("click", (event) => {
|
|
const button = event.target.closest("button[data-range]");
|
|
if (!button) return;
|
|
applyHistoryRange(button.dataset.range, {
|
|
sync: true,
|
|
updateUrl: true,
|
|
});
|
|
});
|
|
};
|
|
|
|
const makeRow = (label, key, withBar) => {
|
|
const row = document.createElement("div");
|
|
row.className = "row";
|
|
const lab = document.createElement("label");
|
|
lab.textContent = label;
|
|
row.appendChild(lab);
|
|
if (withBar) {
|
|
const bar = document.createElement("div");
|
|
bar.className = "bar";
|
|
const fill = document.createElement("div");
|
|
fill.className = "bar-fill";
|
|
fill.dataset.bar = key;
|
|
bar.appendChild(fill);
|
|
row.appendChild(bar);
|
|
} else {
|
|
row.appendChild(document.createElement("span"));
|
|
}
|
|
const val = document.createElement("span");
|
|
val.className = "value";
|
|
val.dataset.val = key;
|
|
val.textContent = "—";
|
|
row.appendChild(val);
|
|
return row;
|
|
};
|
|
|
|
const makeThermalRow = () => {
|
|
const row = document.createElement("div");
|
|
row.className = "row";
|
|
const lab = document.createElement("label");
|
|
lab.textContent = "Temp / Fan";
|
|
row.appendChild(lab);
|
|
row.appendChild(document.createElement("span"));
|
|
const val = document.createElement("span");
|
|
val.className = "value thermal-value";
|
|
const temp = document.createElement("span");
|
|
temp.dataset.val = "temp";
|
|
temp.textContent = "—";
|
|
const sep = document.createElement("span");
|
|
sep.textContent = "·";
|
|
const fan = document.createElement("span");
|
|
fan.dataset.val = "fan";
|
|
fan.textContent = "—";
|
|
val.append(temp, sep, fan);
|
|
row.appendChild(val);
|
|
return row;
|
|
};
|
|
|
|
const buildCard = (info) => {
|
|
const card = document.createElement("div");
|
|
card.className = "card";
|
|
card.dataset.scope = deviceScope(info);
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "card-title";
|
|
|
|
const titleLeft = document.createElement("span");
|
|
const titleParts = gpuTitleParts(info);
|
|
const titleIdx = document.createElement("span");
|
|
titleIdx.className = "idx";
|
|
titleIdx.textContent = titleParts.index;
|
|
titleLeft.appendChild(titleIdx);
|
|
titleLeft.appendChild(document.createTextNode(titleParts.name));
|
|
|
|
const titleRight = document.createElement("span");
|
|
titleRight.className = "total";
|
|
titleRight.textContent = info.memory_total_human || "—";
|
|
|
|
title.appendChild(titleLeft);
|
|
title.appendChild(titleRight);
|
|
card.appendChild(title);
|
|
|
|
card.appendChild(makeRow("GPU", "util", true));
|
|
card.appendChild(makeRow("GMBW", "membw", true));
|
|
card.appendChild(makeRow("GMEM", "mem", true));
|
|
card.appendChild(makeThermalRow());
|
|
card.appendChild(makeRow("Power", "power", true));
|
|
return card;
|
|
};
|
|
|
|
const updateCard = (card, info, metrics, metricsHuman) => {
|
|
const scope = deviceScope(info);
|
|
const util = pickLast(metrics, scope, "gpu_utilization");
|
|
const memBw = pickLast(metrics, scope, "memory_utilization");
|
|
const memUsedHuman = pickHumanLast(
|
|
metricsHuman,
|
|
scope,
|
|
"memory_used",
|
|
);
|
|
const memPct = pickLast(metrics, scope, "memory_percent");
|
|
const temp = pickLast(metrics, scope, "temperature");
|
|
const fan = pickLast(metrics, scope, "fan_speed");
|
|
const power = pickLast(metrics, scope, "power_usage");
|
|
const powerLimit = pickLast(metrics, scope, "power_limit");
|
|
const powerPct =
|
|
Number.isFinite(power) &&
|
|
Number.isFinite(powerLimit) &&
|
|
powerLimit > 0
|
|
? (power / powerLimit) * 100
|
|
: NaN;
|
|
|
|
const setBar = (name, pct) => {
|
|
const bar = card.querySelector(`[data-bar="${name}"]`);
|
|
if (!bar) return;
|
|
const clamped = Number.isFinite(pct)
|
|
? Math.max(0, Math.min(100, pct))
|
|
: 0;
|
|
bar.style.width = clamped + "%";
|
|
bar.className = barClass(pct);
|
|
};
|
|
setBar("util", util);
|
|
setBar("membw", memBw);
|
|
setBar("mem", memPct);
|
|
setBar("power", powerPct);
|
|
|
|
card.querySelector('[data-val="util"]').textContent =
|
|
fmtPercent(util);
|
|
card.querySelector('[data-val="membw"]').textContent =
|
|
fmtPercent(memBw);
|
|
card.querySelector('[data-val="mem"]').textContent =
|
|
`${memUsedHuman} (${fmtPercentOne(memPct)})`;
|
|
const tempEl = card.querySelector('[data-val="temp"]');
|
|
tempEl.textContent = fmtC(temp);
|
|
// Temperature is degrees Celsius, not a fraction — no bar, but keep the warn/danger color
|
|
// cue on the numeric value (>=70 warn, >=90 danger).
|
|
tempEl.style.color = !Number.isFinite(temp)
|
|
? ""
|
|
: temp >= 90
|
|
? "var(--danger)"
|
|
: temp >= 70
|
|
? "var(--warn)"
|
|
: "";
|
|
card.querySelector('[data-val="fan"]').textContent = fmtPercent(fan);
|
|
card.querySelector('[data-val="power"]').textContent = fmtW(power);
|
|
};
|
|
|
|
async function tick() {
|
|
let payload;
|
|
try {
|
|
const resp = await fetch("/metrics.json", { cache: "no-store" });
|
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
|
payload = await resp.json();
|
|
} catch (err) {
|
|
$("stale").hidden = false;
|
|
$("stale").textContent = "fetch failed: " + err.message;
|
|
return;
|
|
}
|
|
|
|
currentHostname = payload.hostname || location.hostname || "unknown";
|
|
document.title = pageTitle(currentHostname);
|
|
setText("origin", `${currentHostname}`);
|
|
|
|
const cardsEl = $("cards");
|
|
const devices = payload.devices || [];
|
|
currentDevices = devices;
|
|
syncGpuChartPanels(devices);
|
|
if (cardsEl.children.length !== devices.length) {
|
|
cardsEl.replaceChildren(...devices.map(buildCard));
|
|
}
|
|
devices.forEach((info, i) =>
|
|
updateCard(
|
|
cardsEl.children[i],
|
|
info,
|
|
payload.metrics || {},
|
|
payload.metrics_human || {},
|
|
),
|
|
);
|
|
|
|
const m = payload.metrics || {};
|
|
const hostMemoryUsed = pickHumanLast(
|
|
payload.metrics_human || {},
|
|
"host",
|
|
"memory_used",
|
|
);
|
|
setText(
|
|
"host",
|
|
`Host: CPU ${fmtPercentOne(pickHostLast(m, "cpu_percent"))}` +
|
|
` · Memory ${hostMemoryUsed} (${fmtPercentOne(pickHostLast(m, "memory_percent"))})` +
|
|
` · Swap ${fmtPercentOne(pickHostLast(m, "swap_percent"))}`,
|
|
);
|
|
|
|
const b = payload.buffer || {};
|
|
const age =
|
|
b.oldest_epoch && b.count > 0
|
|
? payload.server_time - b.oldest_epoch
|
|
: 0;
|
|
setText(
|
|
"buffer",
|
|
`Buffer: ${b.count}/${b.max_count} samples` +
|
|
` · retention ${b.retention_human}` +
|
|
` · oldest ${fmtAge(age)} ago`,
|
|
);
|
|
|
|
const stale = payload.stale_seconds || 0;
|
|
const staleEl = $("stale");
|
|
if (stale > 2 * (payload.interval || 1.0)) {
|
|
staleEl.hidden = false;
|
|
staleEl.textContent = "stale " + fmtAge(stale);
|
|
} else {
|
|
staleEl.hidden = true;
|
|
}
|
|
|
|
appendChartSample(payload.sample_time || payload.server_time || 0, m);
|
|
|
|
const t = payload.server_time || 0;
|
|
setText(
|
|
"updated",
|
|
"Updated: " + (t ? new Date(t * 1000).toLocaleTimeString() : "—"),
|
|
);
|
|
}
|
|
|
|
const refreshAfterForeground = () => {
|
|
if (document.visibilityState === "hidden") return;
|
|
startPolling();
|
|
requestHistoryRefresh();
|
|
tick();
|
|
};
|
|
|
|
const startPolling = () => {
|
|
if (timer === null) timer = setInterval(tick, POLL_MS);
|
|
};
|
|
|
|
const stopPolling = () => {
|
|
if (timer === null) return;
|
|
clearInterval(timer);
|
|
timer = null;
|
|
};
|
|
|
|
initEndpointButtons();
|
|
initHistoryRange();
|
|
requestHistoryRefresh({ force: true });
|
|
tick();
|
|
startPolling();
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "visible") refreshAfterForeground();
|
|
});
|
|
window.addEventListener("focus", refreshAfterForeground);
|
|
window.addEventListener("pageshow", refreshAfterForeground);
|
|
window.addEventListener("pagehide", stopPolling);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|