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-31, Unraid 7.3). // array.parities / .disks / .caches are SEPARATE lists — .disks is DATA only. // ArrayParity/ArrayCache are no longer named types in 7.3 but the query structure is unchanged. // metrics.cpu.percentTotal and metrics.memory.* provide real-time utilisation. $gql = <<<'GQL' { info { os { hostname uptime release kernel } 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 ───────────────────────────────────────────────────────── // The API mixes three units and never says so. Measured against df and /var/local/emhttp/disks.ini // on 7.3.2, per field: // metrics.memory.* real bytes // disk size KiB — 1024-byte units, the raw device // disk fsSize/fsUsed kB — 1000-byte units, the filesystem // disks.ini fsSize * 1024 and API fsSize * 1000 agree to the digit, which is what pins it down. // // This replaced one helper that guessed the unit from magnitude — "> 100 billion means bytes, // else KB". It was wrong three ways at once. Every filesystem size read 2.4% high because kB got // a KiB divisor; any memory total under 100 GB fell down the KB branch, so HOST2's 64 GB would // have reported as 65536 GB; and a disk is only ever one of these units regardless of its size, // so magnitude was never evidence of anything. Convert by which field it came from, not how big // the number is. function _vv_api_bytes_to_gib(float $bytes): float { return round($bytes / (1024 ** 3), 1); } // Capacity is reported the way drives are sold and the way Unraid's own Main page reports it — // decimal GB. A 12 TB disk reads 12000 GB here, not 10914 GiB wearing a "GB" label. Memory stays // binary above, because 128 GB of RAM genuinely is 125.8 GiB and every tool on the box says so. function _vv_api_fs_gb(float $kb): float { return round($kb * 1000 / 1e9, 1); } // fsSize/fsUsed function _vv_api_dev_gb(float $kib): float { return round($kib * 1024 / 1e9, 1); } // size, disks.ini // 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. // $ini_used_kb: last-known fsUsed in KB from disks.ini — used when the disk is // spun down and the API returns fsUsed=0 because the filesystem is unmounted. function vv_api_disk_entry(array $d, string $role = '', int $ini_used_kb = 0): ?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_dev_gb($sizeRaw); $usedGb = 0.0; $pct = null; } else { // Data/cache disks: prefer fsSize/fsUsed; fall back to size if unmounted. The fallback // changes the unit as well as the source — fsSize is kB, size is KiB — so the two cannot // collapse into one variable and share a conversion the way they used to. $fsRaw = (float)($d['fsSize'] ?? 0); if ($fsRaw > 0.0) { $sizeGb = _vv_api_fs_gb($fsRaw); } else { $devRaw = (float)($d['size'] ?? 0); if ($devRaw <= 0) return null; $sizeGb = _vv_api_dev_gb($devRaw); } $usedRaw = (float)($d['fsUsed'] ?? 0); if ($usedRaw > 0.0) { $usedGb = _vv_api_fs_gb($usedRaw); } elseif (!($d['isSpinning'] ?? true) && $ini_used_kb > 0) { $usedGb = _vv_api_dev_gb((float)$ini_used_kb); // disks.ini is KiB, not kB } else { $usedGb = 0.0; } $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', ]; } // ── Node metrics extractor ──────────────────────────────────────────────────── // Parse CPU%, RAM, array storage, disk temps, and VM count from a raw API response. // Used by vv_remote_hosts_stats() and the local vv_api_data() path — one parser, no duplication. // GQL must include: metrics.cpu.percentTotal, metrics.memory.{total,used}, // array.{disks,caches,parities}.{fsSize,fsUsed,temp}, vms.domains. function vv_api_node_metrics(?array $d): array { if (!$d) return []; $cpu = (int)round((float)($d['metrics']['cpu']['percentTotal'] ?? 0)); $mem = $d['metrics']['memory'] ?? []; $ramUsed = isset($mem['used']) ? _vv_api_bytes_to_gib((float)$mem['used']) : null; $ramTot = isset($mem['total']) ? _vv_api_bytes_to_gib((float)$mem['total']) : null; $disks = $d['array']['disks'] ?? []; $caches = $d['array']['caches'] ?? []; $pars = $d['array']['parities'] ?? []; $usedGb = 0.0; $totGb = 0.0; foreach (array_merge($disks, $caches) as $dk) { $sz = (float)($dk['fsSize'] ?? 0); if ($sz <= 0) continue; $totGb += _vv_api_fs_gb($sz); $usedGb += _vv_api_fs_gb((float)($dk['fsUsed'] ?? 0)); } $temps = array_filter( array_merge(array_column($disks,'temp'), array_column($caches,'temp'), array_column($pars,'temp')), fn($t) => is_numeric($t) && $t > 0 ); return [ 'cpu_pct' => $cpu, 'ram_used_gb' => $ramUsed !== null ? round($ramUsed, 1) : null, 'ram_total_gb' => $ramTot !== null ? round($ramTot, 1) : null, 'array_used_tb' => $totGb > 0 ? round($usedGb / 1000, 1) : null, 'array_total_tb' => $totGb > 0 ? round($totGb / 1000, 1) : null, 'max_disk_temp' => $temps ? (int)max($temps) : null, 'vm_count' => count($d['vms']['domains'] ?? []), ]; } // ── Local host stats snapshot — same shape as one entry from vv_remote_hosts_stats() ───────── // Called locally and via SSH by remote_arr_cache_writer.sh on remote hosts. function vv_local_host_stats(): array { $host = vv_detect_host(); $myId = strtoupper($host); $vars = vv_conf_vars(); $name = $vars[$myId] ?? gethostname(); $api = vv_api_data(); $metrics = vv_api_node_metrics($api); if (!$api) { return ['available' => false, 'host_id' => $myId, 'hostname' => $name, 'no_api_key' => _vv_api_key_missing()]; } $os = $api['info']['os'] ?? []; $cpu = $api['info']['cpu'] ?? []; $mem = $api['metrics']['memory'] ?? []; $memPct = round((float)($mem['percentTotal'] ?? 0)); if ($memPct === 0) { $tot = (float)($mem['total'] ?? 0); $avail = (float)($mem['available'] ?? 0); $memPct = $tot > 0 ? (int)round(($tot - $avail) / $tot * 100) : 0; } $memTotalGb = isset($mem['total']) ? _vv_api_bytes_to_gib((float)$mem['total']) : 0; $uptimeRaw = $os['uptime'] ?? ''; if (is_numeric($uptimeRaw)) { $s = (int)$uptimeRaw; $uptime = vv_format_uptime($s); } else { $s = 0; $uptime = $uptimeRaw ?: '—'; } return array_merge([ 'available' => true, 'host_id' => $myId, 'hostname' => $os['hostname'] ?? $name, 'version' => $os['release'] ?? '', 'uptime' => $uptime, 'uptime_sec' => $s, 'cpu_load' => $metrics['cpu_pct'] ?? 0, 'cpu_threads' => (int)($cpu['threads'] ?? 0), 'mem_total_gb' => $memTotalGb, 'mem_used_pct' => $memPct, 'array_state' => $api['array']['state'] ?? 'UNKNOWN', ], $metrics); } // ── Confirmed schema (Unraid 7.3, introspected 2026-05-31) ─────────────────── // Adding a new host: add HOSTn="hostname" to master.conf and HOSTn_UNRAID_API_KEY // to hostn.conf, then push and pull as usual. 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.