Initial commit: Cockpit GPU monitor plugin for NVIDIA
Native, self-contained Cockpit page (plain HTML/JS, no build step) that polls nvidia-smi via cockpit.spawn to show live GPU utilization, memory, temperature, power, fan speed, ~5min history sparklines, and the list of processes using the GPU. No background systemd service; data collection only happens while the page is open.
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* gpu-monitor: página de Cockpit que muestra el estado de GPUs NVIDIA en vivo.
|
||||
*
|
||||
* Toda la recolección de datos ocurre bajo demanda vía cockpit.spawn(nvidia-smi),
|
||||
* disparada por un bucle de polling en el propio navegador. No hay servicio de
|
||||
* fondo: si se cierra la pestaña, no queda nada corriendo.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var POLL_INTERVAL_MS = 2000;
|
||||
var HISTORY_LENGTH = 150; // ~5 minutos a 2s por muestra
|
||||
|
||||
var GPU_QUERY_FIELDS = [
|
||||
"index", "utilization.gpu", "memory.used", "memory.total",
|
||||
"temperature.gpu", "power.draw", "fan.speed"
|
||||
];
|
||||
var PROCESS_QUERY_FIELDS = ["pid", "process_name", "used_memory"];
|
||||
|
||||
// history[gpuIndex] = { util: number[], mem: number[] }
|
||||
var history = {};
|
||||
// cards[gpuIndex] = referencias a los elementos DOM de esa tarjeta
|
||||
var cards = {};
|
||||
|
||||
var pollTimer = null;
|
||||
var stopped = false;
|
||||
|
||||
// ---- Adquisición de datos (backend NVIDIA vía nvidia-smi) -------------
|
||||
//
|
||||
// fetchNvidiaMetrics()/fetchNvidiaProcesses() son el único punto de
|
||||
// contacto con nvidia-smi. Devuelven siempre la misma forma de datos
|
||||
// neutral, para que el día de mañana un backend AMD (rocm-smi) o Intel
|
||||
// (intel_gpu_top) pueda sustituirlas sin tocar el resto del archivo.
|
||||
|
||||
function parseCsvLine(line) {
|
||||
return line.split(",").map(function (field) { return field.trim(); });
|
||||
}
|
||||
|
||||
function parseNumberField(raw) {
|
||||
if (raw === undefined || raw === "" || raw.indexOf("N/A") !== -1) {
|
||||
return null;
|
||||
}
|
||||
var n = Number(raw);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function fetchNvidiaMetrics() {
|
||||
return cockpit.spawn(
|
||||
["nvidia-smi", "--query-gpu=" + GPU_QUERY_FIELDS.join(","), "--format=csv,noheader,nounits"],
|
||||
{ err: "message" }
|
||||
).then(function (output) {
|
||||
return output.trim().split("\n").filter(Boolean).map(function (line) {
|
||||
var cols = parseCsvLine(line);
|
||||
return {
|
||||
index: parseNumberField(cols[0]),
|
||||
util: parseNumberField(cols[1]),
|
||||
memUsed: parseNumberField(cols[2]),
|
||||
memTotal: parseNumberField(cols[3]),
|
||||
tempC: parseNumberField(cols[4]),
|
||||
powerW: parseNumberField(cols[5]),
|
||||
fanPct: parseNumberField(cols[6])
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fetchNvidiaProcesses() {
|
||||
return cockpit.spawn(
|
||||
["nvidia-smi", "--query-compute-apps=" + PROCESS_QUERY_FIELDS.join(","), "--format=csv,noheader,nounits"],
|
||||
{ err: "message" }
|
||||
).then(function (output) {
|
||||
var trimmed = output.trim();
|
||||
if (!trimmed) return [];
|
||||
return trimmed.split("\n").map(function (line) {
|
||||
var cols = parseCsvLine(line);
|
||||
return {
|
||||
pid: cols[0],
|
||||
name: cols[1],
|
||||
memUsedMb: parseNumberField(cols[2])
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Construcción de la UI ---------------------------------------------
|
||||
|
||||
function formatValue(value, unit, decimals) {
|
||||
if (value === null || value === undefined) return "N/D";
|
||||
return value.toFixed(decimals === undefined ? 0 : decimals) + unit;
|
||||
}
|
||||
|
||||
function buildGpuCard(gpuIndex) {
|
||||
var card = document.createElement("div");
|
||||
card.className = "gpu-card";
|
||||
|
||||
var title = document.createElement("h2");
|
||||
title.className = "gpu-title";
|
||||
title.textContent = "GPU " + gpuIndex;
|
||||
card.appendChild(title);
|
||||
|
||||
var tiles = document.createElement("div");
|
||||
tiles.className = "tiles";
|
||||
card.appendChild(tiles);
|
||||
|
||||
function makeTile(label) {
|
||||
var tile = document.createElement("div");
|
||||
tile.className = "tile";
|
||||
var tileLabel = document.createElement("div");
|
||||
tileLabel.className = "tile-label";
|
||||
tileLabel.textContent = label;
|
||||
var tileValue = document.createElement("div");
|
||||
tileValue.className = "tile-value";
|
||||
tileValue.textContent = "N/D";
|
||||
tile.appendChild(tileLabel);
|
||||
tile.appendChild(tileValue);
|
||||
tiles.appendChild(tile);
|
||||
return tileValue;
|
||||
}
|
||||
|
||||
var tileUtil = makeTile("Utilización");
|
||||
var tileMem = makeTile("Memoria");
|
||||
var tileTemp = makeTile("Temperatura");
|
||||
var tilePower = makeTile("Consumo");
|
||||
var tileFan = makeTile("Fan");
|
||||
|
||||
var charts = document.createElement("div");
|
||||
charts.className = "charts";
|
||||
card.appendChild(charts);
|
||||
|
||||
function makeChart(label) {
|
||||
var box = document.createElement("div");
|
||||
box.className = "chart-box";
|
||||
var chartTitle = document.createElement("div");
|
||||
chartTitle.className = "chart-title";
|
||||
chartTitle.textContent = label;
|
||||
box.appendChild(chartTitle);
|
||||
|
||||
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 300 60");
|
||||
svg.setAttribute("preserveAspectRatio", "none");
|
||||
svg.setAttribute("class", "sparkline");
|
||||
|
||||
var polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
||||
polyline.setAttribute("points", "");
|
||||
svg.appendChild(polyline);
|
||||
box.appendChild(svg);
|
||||
charts.appendChild(box);
|
||||
return polyline;
|
||||
}
|
||||
|
||||
var polylineUtil = makeChart("Utilización (5 min)");
|
||||
var polylineMem = makeChart("Memoria (5 min)");
|
||||
|
||||
cards[gpuIndex] = {
|
||||
root: card,
|
||||
tileUtil: tileUtil,
|
||||
tileMem: tileMem,
|
||||
tileTemp: tileTemp,
|
||||
tilePower: tilePower,
|
||||
tileFan: tileFan,
|
||||
polylineUtil: polylineUtil,
|
||||
polylineMem: polylineMem
|
||||
};
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function buildSparklinePoints(values, width, height, min, max) {
|
||||
if (values.length === 0) return "";
|
||||
var range = (max - min) || 1;
|
||||
var stepX = values.length > 1 ? width / (values.length - 1) : 0;
|
||||
return values.map(function (v, i) {
|
||||
var x = i * stepX;
|
||||
var clamped = Math.max(min, Math.min(max, v === null ? min : v));
|
||||
var y = height - ((clamped - min) / range) * height;
|
||||
return x.toFixed(1) + "," + y.toFixed(1);
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
function updateGpuCard(gpu) {
|
||||
if (!cards[gpu.index]) {
|
||||
var container = document.getElementById("gpu-container");
|
||||
container.appendChild(buildGpuCard(gpu.index));
|
||||
}
|
||||
var card = cards[gpu.index];
|
||||
|
||||
card.tileUtil.textContent = formatValue(gpu.util, "%");
|
||||
card.tileMem.textContent = (gpu.memUsed === null || gpu.memTotal === null)
|
||||
? "N/D"
|
||||
: Math.round(gpu.memUsed) + " / " + Math.round(gpu.memTotal) + " MB";
|
||||
card.tileTemp.textContent = formatValue(gpu.tempC, "°C");
|
||||
card.tilePower.textContent = formatValue(gpu.powerW, " W", 1);
|
||||
card.tileFan.textContent = formatValue(gpu.fanPct, "%");
|
||||
|
||||
if (!history[gpu.index]) {
|
||||
history[gpu.index] = { util: [], mem: [] };
|
||||
}
|
||||
var hist = history[gpu.index];
|
||||
hist.util.push(gpu.util === null ? 0 : gpu.util);
|
||||
hist.mem.push(gpu.memUsed === null ? 0 : gpu.memUsed);
|
||||
if (hist.util.length > HISTORY_LENGTH) hist.util.shift();
|
||||
if (hist.mem.length > HISTORY_LENGTH) hist.mem.shift();
|
||||
|
||||
card.polylineUtil.setAttribute("points", buildSparklinePoints(hist.util, 300, 60, 0, 100));
|
||||
card.polylineMem.setAttribute("points", buildSparklinePoints(hist.mem, 300, 60, 0, gpu.memTotal || 1));
|
||||
}
|
||||
|
||||
function updateProcessTable(processes) {
|
||||
var body = document.getElementById("process-table-body");
|
||||
body.textContent = "";
|
||||
|
||||
if (processes.length === 0) {
|
||||
var emptyRow = document.createElement("tr");
|
||||
emptyRow.className = "empty-row";
|
||||
var emptyCell = document.createElement("td");
|
||||
emptyCell.colSpan = 3;
|
||||
emptyCell.textContent = "Sin procesos activos";
|
||||
emptyRow.appendChild(emptyCell);
|
||||
body.appendChild(emptyRow);
|
||||
return;
|
||||
}
|
||||
|
||||
processes.forEach(function (proc) {
|
||||
var row = document.createElement("tr");
|
||||
|
||||
var pidCell = document.createElement("td");
|
||||
pidCell.textContent = proc.pid;
|
||||
row.appendChild(pidCell);
|
||||
|
||||
var nameCell = document.createElement("td");
|
||||
nameCell.textContent = proc.name;
|
||||
row.appendChild(nameCell);
|
||||
|
||||
var memCell = document.createElement("td");
|
||||
memCell.textContent = proc.memUsedMb === null ? "N/D" : Math.round(proc.memUsedMb);
|
||||
row.appendChild(memCell);
|
||||
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Manejo de errores --------------------------------------------------
|
||||
|
||||
function showError(err) {
|
||||
var banner = document.getElementById("error-banner");
|
||||
var message;
|
||||
if (err && err.problem === "not-found") {
|
||||
message = "nvidia-smi no encontrado. ¿Está instalado el driver NVIDIA en este servidor?";
|
||||
} else if (err && err.message) {
|
||||
message = "Error al ejecutar nvidia-smi: " + err.message;
|
||||
} else {
|
||||
message = "Error al ejecutar nvidia-smi: " + String(err);
|
||||
}
|
||||
banner.textContent = message;
|
||||
banner.hidden = false;
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
var banner = document.getElementById("error-banner");
|
||||
banner.hidden = true;
|
||||
banner.textContent = "";
|
||||
}
|
||||
|
||||
// ---- Bucle de polling ---------------------------------------------------
|
||||
//
|
||||
// setTimeout recursivo en vez de setInterval: la siguiente ejecución sólo
|
||||
// se programa cuando la anterior ya terminó (éxito o error), así que si
|
||||
// nvidia-smi tarda no se solapan invocaciones.
|
||||
|
||||
function poll() {
|
||||
if (stopped) return;
|
||||
|
||||
Promise.all([fetchNvidiaMetrics(), fetchNvidiaProcesses()])
|
||||
.then(function (results) {
|
||||
var gpus = results[0];
|
||||
var processes = results[1];
|
||||
gpus.forEach(updateGpuCard);
|
||||
updateProcessTable(processes);
|
||||
clearError();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showError(err);
|
||||
})
|
||||
.then(function () {
|
||||
pollTimer = window.setTimeout(poll, POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopped = true;
|
||||
if (pollTimer !== null) {
|
||||
window.clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
poll();
|
||||
window.addEventListener("beforeunload", stop);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user