The API mixes units per field — memory in bytes, disk size in KiB, fsSize and fsUsed in kB — and one helper was guessing which from the magnitude of the number, which also read any memory total under 100 GB as KB.
318 lines
15 KiB
PHP
318 lines
15 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Unraid GraphQL API client. Issues one combined query per request for OS, CPU, memory,
|
|
// disks and array state, and records which callers had to fall back when the API is
|
|
// unavailable.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// One round trip per request, not one per metric.
|
|
// vv_api_data() runs a single combined query and caches it for the lifetime of the
|
|
// request. A page reading eight metrics still makes one API call.
|
|
//
|
|
// Fallback is expected, and it is tracked.
|
|
// The unraid-api registry is ephemeral — a key that worked yesterday can be gone.
|
|
// Every function that had to use a local path records itself via
|
|
// vv_api_record_fallback(), so vv_api_get_status() can report precisely which data is
|
|
// degraded rather than a single unhelpful "API down".
|
|
//
|
|
// The API is an optimisation, never a dependency.
|
|
// Every value it provides has a local path in common.php. Losing the API costs detail
|
|
// and precision, not availability.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// Absent or invalid key degrades silently to local reads.
|
|
// No exception, no error banner — the caller gets its value from /proc or sysfs and the
|
|
// fallback is recorded for the status endpoint.
|
|
//
|
|
// Request-lifetime cache only.
|
|
// Nothing is persisted to disk here, so a stale API response cannot outlive the page
|
|
// that fetched it.
|
|
//
|
|
// Read-only. Queries state; issues no mutations against unraid-api.
|
|
//
|
|
// EXPORTS
|
|
// vv_api_data() the combined query result, cached per request
|
|
// vv_api_get_status() availability plus the list of functions that fell back
|
|
// vv_api_record_fallback() called by consumers when they use a local path instead
|
|
// vv_api_node_metrics() per-node metric summary
|
|
// vv_api_disk_entry() normalised disk record
|
|
// vv_local_host_stats() local summary in the same shape as a remote node
|
|
//
|
|
// CONFIGURATION
|
|
// HOST*_UNRAID_API_KEY written every array start and every 15 min by
|
|
// Plugin/unraid/System_Essentials/unraid_api_key_renew.sh
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// Unraid GraphQL API — single-request master fetch + per-function fallback tracking.
|
|
// All API-first functions call vv_api_data() then fall back to local reads on null.
|
|
//
|
|
// Confirmed schema (introspected 2026-05-31, Unraid 7.3):
|
|
// InfoOs: hostname, uptime (String), release, kernel (NEW 7.3), arch (NEW 7.3)
|
|
// — also new but unused: fqdn, build, codename, platform, distro, serial, uefi
|
|
// — no version/uptime-as-int
|
|
// InfoCpu: brand, threads, cores — also new but unused: manufacturer, model, speed,
|
|
// speedmin, speedmax, processors, socket, topology, packages
|
|
// InfoMemory: layout only — NO usage fields; memory usage via metrics.memory
|
|
// ArrayDisk: used for parities/disks/caches (ArrayParity/ArrayCache named types removed in 7.3)
|
|
// fields: name, device, type (ArrayDiskType enum), status (ArrayDiskStatus enum),
|
|
// size (BigInt), fsSize, fsFree, fsUsed (BigInt), temp, transport (String),
|
|
// rotational, isSpinning, fsType (String) — also new: numReads, numWrites,
|
|
// numErrors (BigInt), idx, warning, critical, color, exportable, comment, format
|
|
// MemoryUtilization: percentTotal, total, used, available, swapTotal, swapUsed —
|
|
// also new: free, active, buffcache, swapFree, percentSwapTotal
|
|
// VmDomain: name, state (VmState enum) — no memory/vcpus
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
|
|
// ── Fallback tracking ─────────────────────────────────────────────────────────
|
|
|
|
function &_vv_api_fallbacks(): array { static $f = []; return $f; }
|
|
function &_vv_api_key_missing(): bool { static $m = false; return $m; }
|
|
|
|
function vv_api_record_fallback(string $fn): void {
|
|
$f = &_vv_api_fallbacks();
|
|
$f[] = $fn;
|
|
}
|
|
|
|
function vv_api_get_status(): array {
|
|
$fallbacks = array_unique(_vv_api_fallbacks());
|
|
return [
|
|
'available' => 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.
|