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
+3
View File
@@ -0,0 +1,3 @@
*.swp
.DS_Store
Thumbs.db
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mateo Saldain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+88
View File
@@ -0,0 +1,88 @@
# cockpit-nvidia-gpu
Plugin nativo de [Cockpit](https://cockpit-project.org/) que agrega una página
"GPU" al menú lateral, mostrando el estado en vivo de GPUs NVIDIA (uso,
memoria, temperatura, consumo, fan) y los procesos que las están usando.
## Por qué existe
Cockpit no trae un panel de GPU. Los plugins de terceros existentes para esto
(por ejemplo `cockpit-gpu` de binbinsh/gridheap-dev) son proyectos chicos, sin
auditar, con instaladores que compilan contra el código fuente completo de
Cockpit e instalan un servicio systemd persistente sin pedir confirmación
clara. Este plugin es la alternativa: HTML + JavaScript plano, sin build
step, sin dependencias externas, sin nada corriendo en segundo plano — sólo
se ejecuta `nvidia-smi` bajo demanda mientras la página está abierta.
## Requisitos
- Cockpit (probado con versión ≥ 260).
- Driver NVIDIA instalado, con `nvidia-smi` disponible en el `PATH` del
sistema.
## Instalación
Recomendado: symlink desde un checkout del repo, así un `git pull` actualiza
el plugin al instante sin recopiar nada.
```bash
git clone https://gitea.mateosaldain.uy/<usuario>/cockpit-nvidia-gpu.git /opt/cockpit-nvidia-gpu
sudo mkdir -p /usr/share/cockpit
sudo ln -s /opt/cockpit-nvidia-gpu/gpu-monitor /usr/share/cockpit/gpu-monitor
```
Recargá la pestaña de Cockpit en el navegador y debería aparecer la entrada
**GPU** en el menú, junto a **Overview**. Si no aparece (paquete cacheado),
como último recurso: `sudo systemctl restart cockpit.socket`.
### Instalación alternativa para desarrollo (sin root)
```bash
mkdir -p ~/.local/share/cockpit
ln -s /opt/cockpit-nvidia-gpu/gpu-monitor ~/.local/share/cockpit/gpu-monitor
```
## Desinstalación
```bash
sudo rm /usr/share/cockpit/gpu-monitor # borra sólo el symlink
rm -rf /opt/cockpit-nvidia-gpu # opcional: borra también el checkout
```
## Estructura
```
gpu-monitor/
├── manifest.json # declara la entrada de menú "GPU" en Cockpit
├── index.html # esqueleto de la página
├── gpu.js # toda la lógica: polling, parseo de nvidia-smi, render
└── style.css # estilos autocontenidos
```
No hay `package.json` ni paso de build: los archivos se copian o symlinkean
tal cual.
## Screenshot
_(pendiente)_
## Extensibilidad
- **Multi-GPU**: ya soportado. `nvidia-smi --query-gpu=...` devuelve una
línea CSV por GPU física, y `gpu.js` itera todas las líneas, creando una
tarjeta por índice de GPU. En un servidor con una sola GPU (como Arkax
Server) simplemente se renderiza una tarjeta.
- **AMD / Intel**: toda la obtención de datos pasa por `fetchNvidiaMetrics()`
en `gpu.js`, que devuelve una forma de datos neutral
(`{index, util, memUsed, memTotal, tempC, powerW, fanPct}`). Para agregar
soporte AMD (`rocm-smi`) o Intel (`intel_gpu_top`), alcanza con escribir
una función equivalente que devuelva la misma forma y elegir cuál usar
(por detección del binario disponible o config), sin tocar el resto de la
página.
- **Procesos multi-GPU**: la lista de procesos hoy es global (no discrimina
por GPU). Para separarla por GPU, agregar `gpu_uuid` a
`--query-compute-apps` y cruzarlo con el `uuid` de cada GPU.
## Licencia
MIT — ver [LICENSE](LICENSE).
+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;
}