Files
Varaverk/Plugin/unraid/include/common.php
T
Gmer4Lfe 08be751d23 Show active HLS segment count in transcode card
Live TV and Direct Stream sessions never appear in the transcoding
session list but do write segments to the ramdisk. Card now shows
the active segment count so the ramdisk usage is explained.
2026-06-14 11:31:51 -04:00

824 lines
33 KiB
PHP

<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/unraid_api.php';
// Common helpers shared across all Varaverk pages.
function vv_system_info(): array {
// ── Shared local reads (always needed regardless of API) ──────────────────
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api) {
$os = $api['info']['os'] ?? [];
$cpu = $api['info']['cpu'] ?? [];
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$uptime = vv_format_uptime($uptimeSec);
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$load = sys_getloadavg();
return [
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'cpu_cores' => (int)($cpu['cores'] ?? 0),
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
'version' => trim($os['release'] ?? '') ?: $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('system_info');
$cpuModel = '';
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
}
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
$uptime = vv_format_uptime($uptimeSec);
$load = sys_getloadavg();
return [
'name' => $ident['NAME'] ?? gethostname(),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
'cpu_threads' => 0,
'cpu_cores' => 0,
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => $var['mdState'] ?? 'UNKNOWN',
'version' => $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
function vv_docker_containers(): array {
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_docker_stopped(): array {
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
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];
$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),
];
}
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');
$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] ?? '',
];
}
return $procs;
}
function vv_system_resources(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
$mem[$m[1]] = (int)$m[2];
}
return [
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
'cache' => vv_df('/mnt/cache'),
];
}
function vv_cpu_per_core(): array {
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
$raw = [];
foreach (file('/proc/stat') ?: [] as $line) {
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
}
$stateFile = VV_CACHE_DIR . '/vv_cpu_stat.json';
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
// Atomic write — concurrent fast/slow polls read a consistent snapshot
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($raw));
rename($tmp, $stateFile);
$usage = function(array $c, ?array $p): int {
if (!$p) return 0;
$dt = array_sum($c) - array_sum($p);
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
};
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
$cores = [];
foreach ($raw as $cpu => $c) {
if ($cpu === 'cpu') continue;
$num = (int)substr($cpu, 3);
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
$cores[] = [
'core' => $num,
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
];
}
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
return ['overall' => $overall, 'cores' => $cores];
}
function vv_memory_breakdown(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
}
$totalKb = $mem['MemTotal'] ?? 0;
// ZFS ARC
$arcKb = 0;
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
}
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
$dockerKb = 0;
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
foreach (explode("\n", trim($dsOut)) as $line) {
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
$val = (float)$m[1];
$dockerKb += match($m[2]) {
'GiB' => (int)($val * 1048576),
'MiB' => (int)($val * 1024),
'KiB' => (int)$val,
default => (int)($val / 1024),
};
}
// VM (QEMU/KVM RSS)
$vmKb = 0;
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
}
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
// Top processes by RSS — group same-named procs, take top 5
$grouped = [];
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
foreach (explode("\n", trim($psOut)) as $line) {
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
}
arsort($grouped);
$topProcs = [];
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
$topProcs[] = ['name' => $name, 'kb' => $kb];
// Swap — from API metrics when available, else /proc/meminfo
$swapTotalKb = 0; $swapUsedKb = 0;
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
if (!empty($apiMem['swapTotal'])) {
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
} else {
$swapTotalKb = $mem['SwapTotal'] ?? 0;
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
}
return [
'total_kb' => $totalKb,
'system_kb' => $systemKb,
'vm_kb' => $vmKb,
'zfs_kb' => $arcKb,
'docker_kb' => $dockerKb,
'free_kb' => $freeKb,
'swap_total_kb' => $swapTotalKb,
'swap_used_kb' => $swapUsedKb,
'top_procs' => $topProcs,
];
}
function vv_df(string $path): array {
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
if (!$out) return ['available' => false, 'path' => $path];
$parts = preg_split('/\s+/', trim($out));
return [
'available' => true,
'path' => $path,
'size_mb' => (int)$parts[0],
'used_mb' => (int)$parts[1],
'free_mb' => (int)$parts[2],
];
}
function vv_network_stats(): array {
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
if (!$iface) {
$best = ''; $bestBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
}
$iface = $best;
}
if (!$iface) return ['available' => false];
$rxBytes = $txBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
$parts = preg_split('/\s+/', trim($m[1]));
$rxBytes = (int)($parts[0] ?? 0);
$txBytes = (int)($parts[8] ?? 0);
break;
}
$stateFile = VV_CACHE_DIR . '/vv_net_stat.json';
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($now));
rename($tmp, $stateFile);
$rxRate = $txRate = 0;
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
}
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
// Local IP — use primary iface
$localIp = trim(shell_exec(
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
) ?: '');
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
$extIp = '';
$extData = vv_cache_read('ext_ip', 300);
if ($extData) {
$extIp = $extData['ip'] ?? '';
} else {
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
$extIp = $fetched;
vv_cache_write('ext_ip', ['ip' => $extIp]);
}
}
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
return [
'available' => true,
'iface' => $iface,
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
'rx_bps' => $rxRate,
'tx_bps' => $txRate,
'local_ip' => $localIp,
'ext_ip' => $extIp,
'ts_ip' => $tsIp,
];
}
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
$name = $d['name'] ?? $key;
$isParity = $role === 'parity';
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
// Parity has no filesystem — use raw size only
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
if ($size_kb <= 0) return null;
$tempRaw = trim($d['temp'] ?? '');
return [
'name' => $name,
'device' => $d['device'] ?? $key,
'role' => $role,
'size_gb' => round($size_kb / 1048576, 1),
'used_gb' => round($used_kb / 1048576, 1),
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
'transport' => $d['transport'] ?? 'ata',
'mounted' => $mounted,
'status' => $d['status'] ?? '',
];
}
function vv_ups_stats(): array {
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
if (!$raw) return ['available' => false];
$fields = [];
foreach (explode("\n", $raw) as $line) {
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
$fields[trim($m[1])] = trim($m[2]);
}
}
if (empty($fields)) return ['available' => false];
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
$loadPct = $parse_num('LOADPCT');
$nomPower = $parse_num('NOMPOWER');
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
return [
'available' => true,
'model' => $fields['MODEL'] ?? '',
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
'line_v' => $parse_num('LINEV'),
'output_v' => $parse_num('OUTPUTV'),
'load_pct' => $loadPct,
'nom_power' => $nomPower,
'watts' => $watts,
'bcharge' => $parse_num('BCHARGE'),
'timeleft' => $parse_num('TIMELEFT'),
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
'on_batt_s' => $parse_num('CUMONBATT'),
'selftest' => $fields['SELFTEST'] ?? '',
];
}
function vv_parity_status(): array {
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$numDisabled = (int)($var['mdNumDisabled'] ?? 0);
$numMissing = (int)($var['mdNumMissing'] ?? 0);
$exitCode = (int)($var['sbSyncExit'] ?? 0);
$errors = (int)($var['sbSyncErrs'] ?? 0);
// Emulated (DISK_DSBL) disks are protected by parity and don't make parity invalid.
// True invalidity: sync errors on the last check, or unprotectable missing slots.
$isValid = $errors === 0 && $numMissing === 0;
$inProgress = ($var['mdResync'] ?? '0') !== '0';
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
// Last check from log
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
$logFile = '/boot/config/parity-checks.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if ($lines) {
$p = explode('|', trim(end($lines)));
$lastDate = trim($p[0] ?? '');
$lastDuration = (int)($p[1] ?? 0);
$lastSpeed = (int)($p[2] ?? 0);
$lastExit = (int)($p[3] ?? 0);
$lastErrors = (int)($p[4] ?? 0);
}
}
// Parse last date string to timestamp
$lastTs = $lastDate ? strtotime($lastDate) : null;
// Next scheduled check from cron
$nextTs = null;
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
foreach (@file($cronFile) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (!str_contains($line, 'mdcmd')) continue;
$p = preg_split('/\s+/', $line);
// cron: min hour dom month dow command...
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
$next = new DateTime('now');
$next->setTime((int)$p[1], (int)$p[0], 0);
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
$nextTs = $next->getTimestamp();
}
break;
}
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
return [
'valid' => $isValid,
'num_disabled' => $numDisabled,
'num_missing' => $numMissing,
'in_progress' => $inProgress,
'resync_pct' => $resyncPct,
'exit_code' => $exitCode,
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
'errors' => $lastErrors,
'last_date' => $lastDate,
'last_ts' => $lastTs,
'last_duration' => $lastDuration,
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
'next_ts' => $nextTs,
];
}
function vv_storage_pools(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && isset($api['array']['caches'])) {
$out = [];
foreach ($api['array']['caches'] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $out[] = $entry;
}
if (!empty($out)) {
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('storage_pools');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$out = [];
foreach ($ini as $key => $d) {
if (($d['type'] ?? '') !== 'Cache') continue;
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
$entry = vv_disk_entry($d, $key);
if ($entry) $out[] = $entry;
}
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
function vv_array_disks(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
$parity = [];
$data = [];
foreach ($api['array']['parities'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'parity');
if ($entry) $parity[] = $entry;
}
foreach ($api['array']['disks'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $data[] = $entry;
}
if (!empty($parity) || !empty($data)) {
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('array_disks');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$parity = [];
$data = [];
foreach ($ini as $key => $d) {
$type = $d['type'] ?? '';
if ($type === 'Parity') {
$entry = vv_disk_entry($d, $key, 'parity');
if ($entry) $parity[] = $entry;
} elseif ($type === 'Data') {
$entry = vv_disk_entry($d, $key, 'data');
if ($entry) $data[] = $entry;
}
}
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
function vv_disk_io_rates(): array {
$snapFile = VV_CACHE_DIR . '/vv_diskio_snap.json';
$now = microtime(true);
// Read current whole-disk stats from /proc/diskstats
$current = [];
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
$p = preg_split('/\s+/', trim($line));
if (count($p) < 14) continue;
$dev = $p[2];
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
}
// Load previous snapshot
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
$prevTime = (float)($snap['t'] ?? $now);
$prev = $snap['d'] ?? [];
// Save current snapshot
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
$dt = max(0.5, $now - $prevTime);
$out = [];
foreach ($current as $dev => [$rs, $ws]) {
$entry = [
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
];
if (isset($prev[$dev])) {
[$prs, $pws] = $prev[$dev];
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
if ($r > 0.01) $entry['r'] = round($r, 1);
if ($w > 0.01) $entry['w'] = round($w, 1);
}
$out[$dev] = $entry;
}
return $out;
}
function vv_disk_thresholds(): array {
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
$get = function(string $key) use ($cfg): ?int {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
? (int)$m[1] : null;
};
return [
'util_warn' => $get('warning') ?? 70,
'util_crit' => $get('critical') ?? 90,
'hdd_warn' => $get('hot') ?? 45,
'hdd_crit' => $get('max') ?? 55,
'ssd_warn' => $get('hotssd') ?? 60,
'ssd_crit' => $get('maxssd') ?? 70,
];
}
// Fetch a lightweight snapshot from each remote host that has an API key configured.
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
function vv_remote_hosts_stats(): array {
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
$vars = vv_conf_vars();
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
$raw = file_get_contents($f) ?: '';
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
}
}
$myHost = vv_detect_host();
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
sort($hostIds);
$results = [];
foreach ($hostIds as $id) {
if (strtolower($id) === strtolower($myHost)) continue;
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
if (file_exists($bgCache)) {
$cached = json_decode(file_get_contents($bgCache), true);
if ($cached) {
$cached['cache_age'] = time() - (int)filemtime($bgCache);
$results[$id] = $cached;
continue;
}
}
// No background cache yet — fall back to live call (uses 30s inline cache).
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
if (!$key) {
$results[$id] = ['available' => false, 'no_api_key' => true,
'host_id' => $id, 'hostname' => $vars[$id]];
continue;
}
$cacheFile = VV_CACHE_DIR . "/vv_remote_{$id}.json";
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
$cached = json_decode(file_get_contents($cacheFile), true);
if ($cached) { $results[$id] = $cached; continue; }
}
$gql = '{
info { os { hostname uptime release } cpu { brand threads cores } }
metrics { cpu { percentTotal } memory { percentTotal total used available } }
array {
state
disks { fsSize fsUsed temp }
caches { fsSize fsUsed temp }
parities { temp }
}
vms { domains { name } }
}';
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
if (!$data) {
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
continue;
}
$os = $data['info']['os'] ?? [];
$cpu = $data['info']['cpu'] ?? [];
$mMem = $data['metrics']['memory'] ?? [];
$memPct = round((float)($mMem['percentTotal'] ?? 0));
if ($memPct === 0) {
$totalBytes = (float)($mMem['total'] ?? 0);
$availBytes = (float)($mMem['available'] ?? 0);
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
}
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$days = intdiv($uptimeSec, 86400);
$hours = intdiv($uptimeSec % 86400, 3600);
$mins = intdiv($uptimeSec % 3600, 60);
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$nodeMetrics = vv_api_node_metrics($data);
$entry = array_merge([
'available' => true,
'host_id' => $id,
'hostname' => $os['hostname'] ?? $vars[$id],
'version' => $os['release'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'mem_total_gb' => $memTotalGb,
'mem_used_pct' => $memPct,
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
], $nodeMetrics);
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
}
return $results;
}
function vv_log_tail(string $path, int $lines): string {
$fp = @fopen($path, 'r');
if (!$fp) return '';
fseek($fp, 0, SEEK_END);
$size = ftell($fp);
if ($size <= 0) { fclose($fp); return ''; }
$chunk = min($size, 4096);
fseek($fp, -$chunk, SEEK_END);
$data = fread($fp, $chunk);
fclose($fp);
$all = explode("\n", $data ?: '');
return implode("\n", array_slice($all, -$lines));
}
function vv_parse_bash_array(string $raw, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
$items = [];
foreach (explode("\n", $m[1]) as $line) {
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
if ($line !== '') $items[] = $line;
}
return $items;
}
function vv_transcode_sessions(): array {
$v = vv_conf_vars();
$stateDir = rtrim($v['STATE_DIR'] ?? STATE_DIR, '/');
$stateFile = "$stateDir/transcode_state.db";
if (!file_exists($stateFile)) return ['available' => false];
$raw = [];
foreach (file($stateFile) ?: [] as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v);
}
$target = $raw['current_target'] ?? '';
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
$isRamdisk = str_contains($target, 'ramdisk');
// Count active sessions: subdirs (legacy transcode) + unique hex prefixes (Live TV / Direct Stream HLS)
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
$maxAge = (int)(vv_conf_vars()['TRANSCODE_MAX_AGE'] ?? 20);
$activeFiles = 0;
$cutoff = time() - $maxAge * 60;
$flatPrefixes = [];
if (is_dir($ramdiskPath)) {
foreach (new DirectoryIterator($ramdiskPath) as $f) {
if (!$f->isFile()) continue;
if (preg_match('/^([0-9a-f]{16,})/', $f->getFilename(), $m)) {
$flatPrefixes[$m[1]] = true;
}
if ($f->getMTime() >= $cutoff) $activeFiles++;
}
}
$ramSessions += count($flatPrefixes);
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
$ssdPath = '';
$ssdSessions = 0;
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
$parts = explode('/', rtrim($p, '/'));
array_pop($parts);
$mount = implode('/', $parts) ?: '/';
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
$ssdPath = $p;
break;
}
$ssd = ['available' => false];
if ($ssdPath) {
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
$parts = explode('/', rtrim($ssdPath, '/'));
array_pop($parts);
$ssdMount = implode('/', $parts) ?: '/';
$ssd = vv_df($ssdMount);
}
// Ramdisk disk usage
$rd = vv_df('/mnt/ramdisk_transcodes');
// Last cleanup values from transcode management log
$lastRdFreed = null;
$lastSsdFreed = null;
$logFile = LOG_DIR . '/Orchestrators/transcode_management.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
foreach (array_reverse($lines) as $line) {
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
$lastRdFreed = $m[1];
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
$lastSsdFreed = $m[1];
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
}
}
return [
'available' => true,
'current_target' => $target,
'is_ramdisk' => $isRamdisk,
'flip_count_hour' => $flipCount,
'last_flip_time' => $lastFlip,
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
'ram_sessions' => $ramSessions,
'ssd_sessions' => $ssdSessions,
'active_files' => $activeFiles,
'ramdisk' => $rd,
'ssd' => $ssd,
'last_rd_freed' => $lastRdFreed,
'last_ssd_freed' => $lastSsdFreed,
];
}