/* * 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); }); })();