diff --git a/Plugin/unraid/include/common.php b/Plugin/unraid/include/common.php index dc794f2..495cc68 100644 --- a/Plugin/unraid/include/common.php +++ b/Plugin/unraid/include/common.php @@ -196,9 +196,114 @@ function vv_gpu_stats_all(): array { 'power_w' => is_numeric($p[7]) ? round((float)$p[7], 1) : null, 'enc_pct' => (int)$p[8], 'dec_pct' => (int)$p[9], + 'vendor' => 'nvidia', ]; } - return $gpus; + // Integrated graphics after the discrete cards, numbered on from them. A box with both shows + // both; a box with only an iGPU stops reporting that it has no GPU at all. + return array_merge($gpus, vv_igpu_stats_all(count($gpus))); +} + +// Intel integrated graphics, which nvidia-smi cannot see and which therefore rendered as "no GPU +// detected" on any box without a discrete card — HOST2 has UHD Graphics 730 and showed nothing. +// +// An iGPU is not a small discrete card and the difference is not cosmetic. It has no VRAM (it +// shares system memory), no temperature sensor of its own (it is inside the CPU package, which +// vv_cpu_temp() already reports), and no encode/decode split — Intel publishes per-engine busy +// figures instead: Render/3D, Blitter, Video, VideoEnhance. Those fields are returned null rather +// than zero, because 0 MB of VRAM and 0°C are claims, and "this part does not have one" is not. +// +// Utilisation comes from rc6, the idle residency percentage: busy is what is left of it. That is +// the whole-GPU number; the engine breakdown is carried alongside because on a media server the +// Video engine is the one worth watching — it is what a hardware transcode actually uses. +// +// Sampled, not read: intel_gpu_top measures over an interval, so this costs roughly the sample +// window. Memoised per request and bounded by timeout, because the cache writer calls it once a +// minute and nothing should be able to hang that. +function vv_igpu_stats_all(int $startIndex = 0): array { + static $cache = null; + if ($cache !== null) return $cache; + $cache = []; + + foreach ((array)@glob('/sys/class/drm/card[0-9]*') as $card) { + if (trim((string)@file_get_contents("$card/device/vendor")) !== '0x8086') continue; + + $addr = basename((string)@readlink("$card/device")); + if ($addr === '') continue; + + // Marketing name where lspci publishes one: "Alder Lake-S GT1 [UHD Graphics 730]" is + // better known as UHD Graphics 730, and the bracketed half is the half people recognise. + $desc = trim((string)shell_exec('lspci -s ' . escapeshellarg($addr) . ' 2>/dev/null')); + $name = 'Intel integrated graphics'; + if (preg_match('/\[([^\]]+)\]/', $desc, $m)) $name = 'Intel ' . trim($m[1]); + elseif (preg_match('/controller:\s*(.+?)(?:\s*\(rev|$)/', $desc, $m)) $name = trim($m[1]); + + $entry = [ + 'available' => true, + 'vendor' => 'intel', + 'index' => $startIndex + count($cache), + // Prefixed so a discrete card's processes can never be attributed to this one on a + // box that has both. + 'uuid' => 'intel:' . $addr, + 'name' => $name, + 'memory_used' => null, // shared system memory — there is no separate pool + 'memory_total' => null, + 'utilization' => null, + 'temperature' => null, // no die sensor of its own; it is in the CPU package + 'power_w' => null, + 'package_w' => null, + 'enc_pct' => null, // Intel reports engines, not an encode/decode split + 'dec_pct' => null, + 'engines' => [], + ]; + + // The card still renders without this — name and "no sample" beats no card at all, which + // is what the box showed before. + $sample = vv_igpu_sample(); + if ($sample !== null) { + $rc6 = $sample['rc6']['value'] ?? null; + if (is_numeric($rc6)) $entry['utilization'] = max(0, min(100, (int)round(100 - (float)$rc6))); + + $gpuW = $sample['power']['GPU'] ?? null; + $pkgW = $sample['power']['Package'] ?? null; + // Alder Lake reports 0.00 for the GPU rail. Reported as null rather than as a + // confident zero watts, with package power carried separately and labelled as such. + if (is_numeric($gpuW) && (float)$gpuW > 0) $entry['power_w'] = round((float)$gpuW, 1); + if (is_numeric($pkgW)) $entry['package_w'] = round((float)$pkgW, 1); + + foreach ((array)($sample['engines'] ?? []) as $engine => $vals) { + if (!is_array($vals) || !isset($vals['busy'])) continue; + $entry['engines'][$engine] = round((float)$vals['busy'], 1); + } + } + + $cache[] = $entry; + } + return $cache; +} + +// One sample from intel_gpu_top, decoded, or null. +// +// -J streams an unterminated JSON array: a bare "[" and then one object per interval, forever. +// Neither half is valid JSON on its own, so the first object is cut out by hand — from its opening +// brace to the first closing brace in column 1 — and decoded alone. +// +// timeout, and a short window: this runs inside the once-a-minute cache write and a sampler that +// never returns would take the whole payload with it. +function vv_igpu_sample(): ?array { + static $sample = null; + static $tried = false; + if ($tried) return $sample; + $tried = true; + + if (trim((string)shell_exec('command -v intel_gpu_top 2>/dev/null')) === '') return null; + + $raw = shell_exec('timeout 4 intel_gpu_top -J -s 600 2>/dev/null | sed -n "/^{/,/^}/p" | sed "/^}/q"'); + if (!$raw) return null; + + $decoded = json_decode(trim($raw), true); + $sample = is_array($decoded) ? $decoded : null; + return $sample; } // Kept for the existing single-GPU consumers — same shape as before, always GPU 0. diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php index 7fca5fa..f8600c0 100644 --- a/Plugin/unraid/pages/monitor.php +++ b/Plugin/unraid/pages/monitor.php @@ -1789,11 +1789,23 @@ function vvPollMonitor(live) { p.gpu_uuid ? p.gpu_uuid === gpu.uuid : (gpu.index ?? 0) === 0 ); + const isIntel = gpu.vendor === 'intel'; 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; const tempColor = temp >= 85 ? '#f44336' : temp >= 70 ? '#ff9800' : '#4caf50'; - const powerStr = gpu.power_w != null ? gpu.power_w + ' W' : '—'; + // Package power when the GPU rail reads nothing, which is what Alder Lake reports. Labelled + // "Pkg" in that case rather than passed off as the GPU's own draw. + const powerStr = gpu.power_w != null ? gpu.power_w + ' W' + : (isIntel && gpu.package_w != null) ? gpu.package_w + ' W' : '—'; + + // Video first — it is the engine a hardware transcode uses, and this is a media server. + const engineOrder = ['Video', 'VideoEnhance', 'Render/3D', 'Blitter']; + const engines = gpu.engines ?? {}; + const engineMeters = engineOrder + .filter(n => engines[n] != null) + .map(n => vvMeter(n === 'VideoEnhance' ? 'VideoEnh' : n, engines[n], `${engines[n]}%`)) + .join(''); const procCount = gpuProcs.length; const procColor = procCount > 0 ? '#4caf50' : '#555'; @@ -1822,14 +1834,23 @@ function vvPollMonitor(live) {