From 42720a70bc532451d4be58708e6d5ff6ce9dedd4 Mon Sep 17 00:00:00 2001 From: msaldain Date: Wed, 15 Jul 2026 10:40:20 -0300 Subject: [PATCH] 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. --- .gitignore | 3 + LICENSE | 21 +++ README.md | 88 +++++++++++ gpu-monitor/gpu.js | 301 ++++++++++++++++++++++++++++++++++++++ gpu-monitor/index.html | 34 +++++ gpu-monitor/manifest.json | 15 ++ gpu-monitor/style.css | 164 +++++++++++++++++++++ 7 files changed, 626 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 gpu-monitor/gpu.js create mode 100644 gpu-monitor/index.html create mode 100644 gpu-monitor/manifest.json create mode 100644 gpu-monitor/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..263d9c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.swp +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..41c48eb --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e980f85 --- /dev/null +++ b/README.md @@ -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//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). diff --git a/gpu-monitor/gpu.js b/gpu-monitor/gpu.js new file mode 100644 index 0000000..f5c2444 --- /dev/null +++ b/gpu-monitor/gpu.js @@ -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); + }); +})(); diff --git a/gpu-monitor/index.html b/gpu-monitor/index.html new file mode 100644 index 0000000..ae8a37f --- /dev/null +++ b/gpu-monitor/index.html @@ -0,0 +1,34 @@ + + + + + GPU + + + + + + +
+ +
+

Procesos usando la GPU

+ + + + + + + + + + + +
PIDProcesoMemoria (MB)
Sin procesos activos
+
+ + + + + + diff --git a/gpu-monitor/manifest.json b/gpu-monitor/manifest.json new file mode 100644 index 0000000..7a346ad --- /dev/null +++ b/gpu-monitor/manifest.json @@ -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:" +} diff --git a/gpu-monitor/style.css b/gpu-monitor/style.css new file mode 100644 index 0000000..50d091d --- /dev/null +++ b/gpu-monitor/style.css @@ -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; +}