Add a second GPU card to the monitor page

The 3080 was invisible because the stats parser only ever described GPU 0.
This commit is contained in:
Gmer4Lfe
2026-08-01 20:37:58 -04:00
parent 77b24e9823
commit cdce877601
4 changed files with 96 additions and 31 deletions
+1
View File
@@ -27,6 +27,7 @@ $monitor = [
'mem' => vv_memory_breakdown(),
'net' => vv_network_stats(),
'gpu' => vv_gpu_stats(),
'gpus' => vv_gpu_stats_all(),
'gpu_procs' => vv_gpu_processes(),
'containers' => vv_docker_containers(),
'stopped' => vv_docker_stopped(),
+1
View File
@@ -25,6 +25,7 @@ echo json_encode([
'mem' => vv_memory_breakdown(),
'net' => vv_network_stats(),
'gpu' => vv_gpu_stats(),
'gpus' => vv_gpu_stats_all(),
'gpu_procs' => vv_gpu_processes(),
'containers' => vv_docker_containers(),
'stopped' => vv_docker_stopped(),
+39 -20
View File
@@ -95,35 +95,54 @@ function vv_docker_stopped(): array {
return $containers;
}
function vv_gpu_stats(): array {
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
if (!$out) return ['available' => false];
// Every installed GPU, one entry per card. Parsed line by line — nvidia-smi emits one row
// per GPU, so splitting the whole output on commas (as this once did) runs the rows together
// and only ever describes GPU 0.
function vv_gpu_stats_all(): array {
$out = shell_exec('nvidia-smi --query-gpu=index,uuid,name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
if (!$out) return [];
$parts = array_map('trim', explode(',', $out));
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
return [
'available' => true,
'name' => $parts[0] ?? '',
'memory_used' => (int)($parts[1] ?? 0),
'memory_total' => (int)($parts[2] ?? 0),
'utilization' => (int)($parts[3] ?? 0),
'temperature' => (int)($parts[4] ?? 0),
'power_w' => $power,
'enc_pct' => (int)($parts[6] ?? 0),
'dec_pct' => (int)($parts[7] ?? 0),
];
$gpus = [];
foreach (explode("\n", trim($out)) as $line) {
if (!$line) continue;
$p = array_map('trim', explode(',', $line));
if (count($p) < 10) continue;
$gpus[] = [
'available' => true,
'index' => (int)$p[0],
'uuid' => $p[1],
'name' => $p[2],
'memory_used' => (int)$p[3],
'memory_total' => (int)$p[4],
'utilization' => (int)$p[5],
'temperature' => (int)$p[6],
'power_w' => is_numeric($p[7]) ? round((float)$p[7], 1) : null,
'enc_pct' => (int)$p[8],
'dec_pct' => (int)$p[9],
];
}
return $gpus;
}
// Kept for the existing single-GPU consumers — same shape as before, always GPU 0.
function vv_gpu_stats(): array {
$gpus = vv_gpu_stats_all();
return $gpus[0] ?? ['available' => false];
}
// gpu_uuid is included so each process can be attributed to the card it is actually running
// on. Without it a two-GPU box shows every process under every card.
function vv_gpu_processes(): array {
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
$out = shell_exec('nvidia-smi --query-compute-apps=gpu_uuid,pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
$procs = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$parts = array_map('trim', explode(',', $line));
$procs[] = [
'pid' => $parts[0] ?? '',
'memory_mb' => $parts[1] ?? '',
'name' => $parts[2] ?? '',
'gpu_uuid' => $parts[0] ?? '',
'pid' => $parts[1] ?? '',
'memory_mb' => $parts[2] ?? '',
'name' => $parts[3] ?? '',
];
}
return $procs;
+55 -11
View File
@@ -104,7 +104,7 @@
<div id="vv-docker-folders-body">Loading...</div>
</div>
<!-- Row 3: Rsync | GPU | Transcode | Streams -->
<!-- Row 3: Rsync | GPU 0 | GPU 1 | Transcode | Streams -->
<div class="vv-card" id="vv-rsync-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
@@ -119,14 +119,25 @@
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="15" height="10" viewBox="0 0 16 10" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="0.8" y="0.8" width="14.4" height="7" rx="1.2"/><rect x="2.5" y="2.5" width="3.5" height="3.5" rx="0.5"/><line x1="8" y1="3" x2="13" y2="3"/><line x1="8" y1="5" x2="11" y2="5"/><rect x="3" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/><rect x="6.5" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/><rect x="10" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/></svg></span>
GPU
<span id="vv-gpu-label">GPU</span>
</span>
<a href="/" target="_blank" class="vv-card-cog" title="Dashboard">⚙</a>
</h3>
<div id="vv-gpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-transcode" style="grid-column:span 2;">
<div class="vv-card" id="vv-gpu1-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="15" height="10" viewBox="0 0 16 10" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="0.8" y="0.8" width="14.4" height="7" rx="1.2"/><rect x="2.5" y="2.5" width="3.5" height="3.5" rx="0.5"/><line x1="8" y1="3" x2="13" y2="3"/><line x1="8" y1="5" x2="11" y2="5"/><rect x="3" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/><rect x="6.5" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/><rect x="10" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/></svg></span>
<span id="vv-gpu1-label">GPU 1</span>
</span>
<a href="/" target="_blank" class="vv-card-cog" title="Dashboard">⚙</a>
</h3>
<div id="vv-gpu1-body">Loading...</div>
</div>
<div class="vv-card" id="vv-transcode" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="12" viewBox="0 0 14 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="0.5" y="1" width="4" height="10" rx="0.8"/><line x1="0.5" y1="3.5" x2="2.3" y2="3.5"/><line x1="0.5" y1="8.5" x2="2.3" y2="8.5"/><line x1="2.7" y1="3.5" x2="4.5" y2="3.5"/><line x1="2.7" y1="8.5" x2="4.5" y2="8.5"/><line x1="6" y1="6" x2="9.5" y2="6"/><polyline points="8.2,4.3 10.2,6 8.2,7.7"/><rect x="11" y="1" width="2.5" height="10" rx="0.5"/></svg></span>
@@ -1494,9 +1505,30 @@ function vvPollMonitor() {
})();
// ── GPU ─────────────────────────────────────────────────────────────────
const gpu = d.gpu ?? {};
const gpuProcs = d.gpu_procs ?? [];
if (gpu.available) {
// One card per installed GPU. Falls back to the legacy single-GPU payload so the
// page still renders against a cached response written before 'gpus' existed.
const gpuList = (d.gpus && d.gpus.length) ? d.gpus
: ((d.gpu && d.gpu.available) ? [d.gpu] : []);
const allProcs = d.gpu_procs ?? [];
const renderGpuCard = (gpu, bodyId, labelId, fallbackLabel) => {
const bodyEl = document.getElementById(bodyId);
const labelEl = document.getElementById(labelId);
if (!bodyEl) return;
if (!gpu || !gpu.available) {
if (labelEl) labelEl.textContent = fallbackLabel;
bodyEl.innerHTML = '<p style="color:#555;font-style:italic">No GPU detected</p>';
return;
}
// Attribute processes to this card by UUID. Older payloads have no gpu_uuid on the
// process rows — in that case only the first card claims them, rather than every
// card showing the same list.
const gpuProcs = allProcs.filter(p =>
p.gpu_uuid ? p.gpu_uuid === gpu.uuid : (gpu.index ?? 0) === 0
);
const vramPct = gpu.memory_total > 0 ? Math.round(gpu.memory_used / gpu.memory_total * 100) : 0;
const utilPct = gpu.utilization ?? 0;
const temp = gpu.temperature ?? 0;
@@ -1505,7 +1537,9 @@ function vvPollMonitor() {
const procCount = gpuProcs.length;
const procColor = procCount > 0 ? '#4caf50' : '#555';
// Process list — which apps are actually using the GPU (name + VRAM)
if (labelEl) labelEl.textContent = 'GPU ' + (gpu.index ?? 0);
// Process list — which apps are actually using this GPU (name + VRAM)
let gpuProcHtml = '';
if (procCount > 0) {
const rows = gpuProcs.map(p => {
@@ -1521,7 +1555,7 @@ function vvPollMonitor() {
</div>`;
}
document.getElementById('vv-gpu-body').innerHTML =
bodyEl.innerHTML =
// header row: name + process count pill
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="font-size:11px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${gpu.name}</div>
@@ -1538,9 +1572,19 @@ function vvPollMonitor() {
<span style="color:#666;margin-left:12px;">Power</span>
<span style="color:#aaa;font-weight:bold;">${powerStr}</span>
</div>` + gpuProcHtml;
} else {
document.getElementById('vv-gpu-body').innerHTML = '<p style="color:#555;font-style:italic">No GPU detected</p>';
}
};
renderGpuCard(gpuList[0], 'vv-gpu-body', 'vv-gpu-label', 'GPU');
renderGpuCard(gpuList[1], 'vv-gpu1-body', 'vv-gpu1-label', 'GPU 1');
// Row 3 is 8 columns wide: Rsync(1) + GPU0(1) + GPU1(1) + Transcode(1) + Streams(4).
// On a single-GPU host the second card is hidden and Transcode widens back to 2 so the
// row still fills exactly — otherwise it would leave a one-column hole.
const gpu1Card = document.getElementById('vv-gpu1-card');
const transcodeCard = document.getElementById('vv-transcode');
const twoGpus = gpuList.length > 1;
if (gpu1Card) gpu1Card.style.display = twoGpus ? '' : 'none';
if (transcodeCard) transcodeCard.style.gridColumn = twoGpus ? 'span 1' : 'span 2';
// ── Scripts ─────────────────────────────────────────────────────────────
vvLastScripts = d.scripts ?? {};