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,3 @@
|
|||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
@@ -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:"
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user