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:
msaldain
2026-07-15 10:40:20 -03:00
commit 42720a70bc
7 changed files with 626 additions and 0 deletions
+301
View File
@@ -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);
});
})();
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>GPU</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="error-banner" class="error-banner" hidden></div>
<div id="gpu-container" class="gpu-container"></div>
<section class="process-section">
<h2>Procesos usando la GPU</h2>
<table class="process-table">
<thead>
<tr>
<th>PID</th>
<th>Proceso</th>
<th>Memoria (MB)</th>
</tr>
</thead>
<tbody id="process-table-body">
<tr class="empty-row"><td colspan="3">Sin procesos activos</td></tr>
</tbody>
</table>
</section>
<!-- cockpit.js: ruta relativa estable servida por el propio Cockpit (pkg/base1/cockpit.js) -->
<script src="../base1/cockpit.js"></script>
<script src="gpu.js"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{
"requires": { "cockpit": "260" },
"menu": {
"index": {
"label": "GPU",
"order": 15,
"keywords": [
{ "matches": ["nvidia", "gpu", "cuda", "video", "graphics", "vram"] }
]
}
},
"content-security-policy": "img-src 'self' data:"
}
+164
View File
@@ -0,0 +1,164 @@
/* gpu-monitor: estilos autocontenidos, sin dependencia de PatternFly ni de
* assets internos de Cockpit (para no acoplarse a rutas que cambian entre
* versiones). */
:root {
--gpu-bg: #ffffff;
--gpu-fg: #1a1a1a;
--gpu-muted: #6a6a6a;
--gpu-card-bg: #f5f5f5;
--gpu-border: #dcdcdc;
--gpu-accent: #0066cc;
--gpu-error-bg: #fdecea;
--gpu-error-fg: #7a1f1a;
--gpu-error-border: #f2b8b5;
}
@media (prefers-color-scheme: dark) {
:root {
--gpu-bg: #1e1e1e;
--gpu-fg: #e8e8e8;
--gpu-muted: #a0a0a0;
--gpu-card-bg: #2a2a2a;
--gpu-border: #3d3d3d;
--gpu-accent: #4da3ff;
--gpu-error-bg: #3a1f1e;
--gpu-error-fg: #f5b8b3;
--gpu-error-border: #5c2b28;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 16px;
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--gpu-bg);
color: var(--gpu-fg);
}
h2 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 8px;
}
.error-banner {
background: var(--gpu-error-bg);
color: var(--gpu-error-fg);
border: 1px solid var(--gpu-error-border);
border-radius: 4px;
padding: 10px 14px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.gpu-container {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.gpu-card {
background: var(--gpu-card-bg);
border: 1px solid var(--gpu-border);
border-radius: 6px;
padding: 16px;
}
.gpu-title {
color: var(--gpu-accent);
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.tile {
background: var(--gpu-bg);
border: 1px solid var(--gpu-border);
border-radius: 4px;
padding: 8px 12px;
}
.tile-label {
font-size: 0.75rem;
color: var(--gpu-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tile-value {
font-size: 1.3rem;
font-weight: 600;
margin-top: 2px;
}
.charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.chart-box {
background: var(--gpu-bg);
border: 1px solid var(--gpu-border);
border-radius: 4px;
padding: 8px 12px;
}
.chart-title {
font-size: 0.75rem;
color: var(--gpu-muted);
margin-bottom: 4px;
}
.sparkline {
width: 100%;
height: 60px;
display: block;
}
.sparkline polyline {
fill: none;
stroke: var(--gpu-accent);
stroke-width: 2;
vector-effect: non-scaling-stroke;
}
.process-section {
margin-top: 8px;
}
.process-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.process-table th,
.process-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--gpu-border);
}
.process-table th {
color: var(--gpu-muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.empty-row td {
color: var(--gpu-muted);
font-style: italic;
}