empty($fallbacks), 'key_missing' => _vv_api_key_missing(), 'fallbacks' => $fallbacks, ]; } // ── Master fetch ────────────────────────────────────────────────────────────── // Single combined query — one HTTP round-trip, cached for the request lifetime. // Returns null if API is unreachable, key missing, or any query error occurs. function vv_api_data(): ?array { static $cache = null, $fetched = false; if ($fetched) return $cache; $fetched = true; $hostId = vv_detect_host(); $vars = vv_conf_vars(); $key = $vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? ''; if (!$key) { $m = &_vv_api_key_missing(); $m = true; $cache = null; return null; } // Fields verified against live schema introspection (2026-05-29). // array.parities / .disks / .caches are SEPARATE lists — .disks is DATA only. // metrics.cpu.percentTotal and metrics.memory.* provide real-time utilisation. $gql = <<<'GQL' { info { os { hostname uptime release } cpu { brand threads cores } } metrics { cpu { percentTotal } memory { percentTotal total used available swapTotal swapUsed } } array { state parities { name device type status size fsSize fsFree fsUsed temp transport rotational isSpinning } disks { name device type status size fsSize fsFree fsUsed temp transport rotational isSpinning } caches { name device type status size fsSize fsFree fsUsed temp transport rotational isSpinning } } vms { domains { name state } } } GQL; $cache = vv_unraid_api_query($hostId, $gql, 5, $key); return $cache; } // ── Disk data helpers ───────────────────────────────────────────────────────── // API size fields (BigInt) are in bytes on this schema. // Heuristic: if raw > 100 billion → bytes; else → KB (covers both possible encodings). function _vv_api_bytes_to_gb(float $raw): float { return $raw > 100_000_000_000 ? round($raw / (1024 ** 3), 1) : round($raw / (1024 ** 2), 1); } // Map ArrayDiskType enum → role string used by the rest of the plugin. // Unraid 6.9 used CACHE; 6.10+ renamed pools to POOL. FLASH is the USB boot drive (skip). // Anything unrecognised (not DATA/PARITY*/FLASH) is treated as a pool. function _vv_api_disk_role(string $type): string { $t = strtoupper($type); if (str_contains($t, 'PARITY')) return 'parity'; if ($t === 'DATA') return 'data'; if ($t === 'FLASH') return 'flash'; // USB boot — excluded from both views return 'cache'; // CACHE, POOL, or future variants } // Build a normalised disk entry from API data, matching the shape vv_disk_entry() produces. // $role may be overridden; if empty it is derived from the disk's type field. function vv_api_disk_entry(array $d, string $role = ''): ?array { if (!$role) $role = _vv_api_disk_role((string)($d['type'] ?? 'DATA')); if ($role === 'parity') { // Parity disks have no filesystem — use raw size only. $sizeRaw = (float)($d['size'] ?? 0); if ($sizeRaw <= 0) return null; $sizeGb = _vv_api_bytes_to_gb($sizeRaw); $usedGb = 0.0; $pct = null; } else { // Data/cache disks: prefer fsSize/fsUsed; fall back to size if unmounted. $sizeRaw = (float)($d['fsSize'] ?? $d['size'] ?? 0); if ($sizeRaw <= 0) return null; $usedRaw = (float)($d['fsUsed'] ?? 0); $sizeGb = _vv_api_bytes_to_gb($sizeRaw); $usedGb = _vv_api_bytes_to_gb($usedRaw); $pct = $sizeGb > 0 ? round($usedGb / $sizeGb * 100, 1) : null; } $temp = isset($d['temp']) && is_numeric($d['temp']) ? (int)$d['temp'] : null; $spinning = (bool)($d['isSpinning'] ?? true); $transport = $d['transport'] ?? (str_contains(strtolower($d['device'] ?? ''), 'nvme') ? 'nvme' : 'ata'); return [ 'name' => $d['name'] ?? '', 'device' => $d['device'] ?? '', 'role' => $role, 'size_gb' => $sizeGb, 'used_gb' => $usedGb, 'pct' => $pct, 'temp' => $temp, 'transport' => strtolower($transport), 'mounted' => $spinning, 'status' => $d['status'] ?? 'DISK_OK', ]; } // ── Confirmed schema (Unraid 7.2.5, introspected 2026-05-29) ───────────────── // Adding a new host: add HOSTn="hostname" to master.conf and HOSTn_UNRAID_API_KEY // to hostn.conf, then run Deployment/deploy.sh. No schema work needed. // // If a future Unraid version renames a field, the affected function falls back // to local reads and the api banner lists the fallback — fix by updating the GQL.