diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index af88d8f..4dcad05 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -15,7 +15,26 @@ .vv-wide { flex: 100%; } .vv-card h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; color: #888; letter-spacing: 0.05em; white-space: normal; - overflow: hidden; min-width: 0; } + overflow: hidden; min-width: 0; + display: flex; align-items: center; justify-content: space-between; } + +/* Cog icon — links to related Unraid page. Hidden until card hovered. */ +.vv-card-cog { color: #2a2a2a; font-size: 13px; line-height: 1; text-decoration: none; + padding: 1px 3px; border-radius: 3px; flex-shrink: 0; + transition: color 0.15s, background 0.15s; + font-style: normal; font-weight: normal; text-transform: none; + letter-spacing: 0; cursor: pointer; } +.vv-card:hover .vv-card-cog { color: #4a4a4a; } +.vv-card-cog:hover { color: #aaa !important; background: #333; } + +/* Status banner — coloured strip at top of card body */ +.vv-banner { border-radius: 4px; padding: 5px 10px; margin-bottom: 10px; + display: flex; align-items: center; justify-content: space-between; + font-size: 12px; font-weight: 600; } +.vv-banner-ok { background: #061306; border: 1px solid #1a401a; color: #4caf50; } +.vv-banner-warn { background: #130e00; border: 1px solid #3d2e00; color: #ff9800; } +.vv-banner-err { background: #140404; border: 1px solid #3d1010; color: #f44336; } +.vv-banner-off { background: #0d0d0d; border: 1px solid #252525; color: #555; } /* System card — no h3, no top padding waste */ #vv-system { padding-top: 14px; } @@ -118,7 +137,60 @@ .vv-nb-settings { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 5px 10px; background: #101010; border-bottom: 1px solid #1a1a1a; } +/* ── Monitor: locked row height + scrollable cards ────────────────────────── */ + +/* Cards on the monitor grid are flex columns — h3 pins, body scrolls */ +#vv-monitor .vv-card { + display: flex; + flex-direction: column; + overflow: hidden; +} +#vv-monitor .vv-card h3 { flex-shrink: 0; } +#vv-monitor .vv-card > div { + flex: 1; + min-height: 0; + overflow-y: auto; + -ms-overflow-style: none; + scrollbar-width: none; +} +#vv-monitor .vv-card > div::-webkit-scrollbar { display: none; } + +/* Dynamic row heights capped per screen tier — rows size to content, never exceed the cap. + minmax(0, Xpx): track is content-driven but capped; align-items:stretch makes all cards + in a row fill the track, so short cards (Pools, Watchdog) match tall ones (Array). + Breakpoints are viewport height (after browser chrome), not screen height. */ + +/* ~720p (viewport ≤ 700px) */ +@media (min-width: 481px) and (max-height: 700px) { + #vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 160px) / 4)); } +} +/* ~1080p (viewport 701–1100px) */ +@media (min-width: 481px) and (min-height: 701px) and (max-height: 1100px) { + #vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 240px) / 4)); } +} +/* ~1440p (viewport 1101–1450px) — calibrated on 15" 1440p display */ +@media (min-width: 481px) and (min-height: 1101px) and (max-height: 1450px) { + #vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 335px) / 4)); } +} +/* ~4K (viewport > 1450px) */ +@media (min-width: 481px) and (min-height: 1451px) { + #vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 500px) / 4)); } +} + +/* Mobile: natural heights, let page scroll */ +@media (max-width: 480px) { + #vv-monitor { grid-auto-rows: auto !important; } + #vv-monitor .vv-card { overflow: visible !important; } + #vv-monitor .vv-card > div { overflow-y: visible; min-height: auto; } +} + /* Monitor responsive — 4-column grid at medium width */ +@media (max-width: 1400px) { + /* CPU core bars — reduce gap/min-width at intermediate widths before cores get clipped */ + .vv-cpu-cores { gap: 2px !important; } + .vv-cpu-core { min-width: 7px !important; } +} + @media (max-width: 1024px) { #vv-monitor { grid-template-columns: repeat(4, 1fr) !important; } #vv-docker { grid-column: span 4 !important; } @@ -166,8 +238,8 @@ .vv-snap-media { font-size: 11px; } /* CPU core bars — shrink gap and min-width so many cores don't overflow */ - .vv-cpu-cores { gap: 1px !important; } - .vv-cpu-core { min-width: 4px !important; } + .vv-cpu-cores { gap: 2px !important; } + .vv-cpu-core { min-width: 6px !important; } /* Monitor single-column — explicit placement cards need override too */ #vv-monitor { grid-template-columns: 1fr !important; } @@ -663,6 +735,7 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; } .vv-df-vm-meta { font-size: 10px; color: #555; } .vv-df-cols { display: flex; gap: 10px; align-items: flex-start; } .vv-df-col { flex: 1; min-width: 0; } +#vv-docker-folders-body { overflow-x: hidden; } .vv-df-folder { border-bottom: 1px solid #1a1a1a; } .vv-df-folder:last-child { border-bottom: none; } .vv-df-folder-hdr { display: flex; align-items: center; gap: 6px; padding: 5px 4px; diff --git a/Plugin/unraid/include/common.php b/Plugin/unraid/include/common.php new file mode 100644 index 0000000..814b809 --- /dev/null +++ b/Plugin/unraid/include/common.php @@ -0,0 +1,720 @@ + $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]; + $days = intdiv($uptimeSec, 86400); + $hours = intdiv($uptimeSec % 86400, 3600); + $mins = intdiv($uptimeSec % 3600, 60); + $uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m"; + + $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 = '/tmp/vv_cpu_stat.json'; + $prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : []; + file_put_contents($stateFile, json_encode($raw)); + + $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 = '/tmp/vv_net_stat.json'; + $now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)]; + $prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : []; + file_put_contents($stateFile, json_encode($now)); + + $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 + $extIpCache = '/tmp/vv_ext_ip.cache'; + $extIp = ''; + if (file_exists($extIpCache) && (time() - filemtime($extIpCache)) < 300) { + $extIp = trim(file_get_contents($extIpCache) ?: ''); + } 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; + file_put_contents($extIpCache, $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]; + } + + $isValid = ($var['mdNumInvalid'] ?? '0') === '0'; + $exitCode = (int)($var['sbSyncExit'] ?? 0); + $errors = (int)($var['sbSyncErrs'] ?? 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, + '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_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; + $key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? ''; + if (!$key) continue; + + $cacheFile = "/tmp/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 available } } array { state } }'; + $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'] ?? []; + $metrics = $data['metrics'] ?? []; + $mCpu = $metrics['cpu'] ?? []; + $mMem = $metrics['memory'] ?? []; + $arr = $data['array'] ?? []; + + $cpuLoad = round((float)($mCpu['percentTotal'] ?? 0), 1); + $memPct = round((float)($mMem['percentTotal'] ?? 0)); + // Also compute from raw bytes as cross-check when percentTotal is missing + 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']) ? round((float)$mMem['total'] / (1024 ** 3), 1) : 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 ?: '—'; + } + + $entry = [ + 'available' => true, + 'host_id' => $id, + 'hostname' => $os['hostname'] ?? $vars[$id], + 'version' => $os['release'] ?? '', + 'uptime' => $uptime, + 'uptime_sec' => $uptimeSec, + 'cpu_load' => $cpuLoad, + 'cpu_threads' => (int)($cpu['threads'] ?? 0), + 'mem_total_gb' => $memTotalGb, + 'mem_used_pct' => $memPct, + 'array_state' => $arr['state'] ?? 'UNKNOWN', + ]; + 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 { + $stateFile = '/tmp/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 session dirs in both known locations + $ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp'; + $ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []); + + // 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'); + + 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, + 'ramdisk' => $rd, + 'ssd' => $ssd, + ]; +} diff --git a/Plugin/unraid/include/confform.php b/Plugin/unraid/include/confform.php index 063fbf2..4a4d5f2 100644 --- a/Plugin/unraid/include/confform.php +++ b/Plugin/unraid/include/confform.php @@ -1,4 +1,6 @@ 0 ? "{$days}d " : '') - . ($hours > 0 ? "{$hours}h " : '') - . "{$mins}m"; - - // Array state - $arrayState = $var['mdState'] ?? 'UNKNOWN'; - - return [ - 'name' => $ident['NAME'] ?? gethostname(), - 'comment' => $ident['COMMENT'] ?? '', - 'timezone' => $ident['timeZone'] ?? 'UTC', - 'cpu_model' => $ident['SYS_MODEL'] ?? $cpu, - 'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''), - 'reg_to' => $var['regTo'] ?? '', - 'uptime' => $uptime, - 'array_state' => $arrayState, - 'version' => trim(@file_get_contents('/etc/unraid-version') ?: ''), - ]; -} - -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 = '/tmp/vv_cpu_stat.json'; - $prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : []; - file_put_contents($stateFile, json_encode($raw)); - - $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]; - - return [ - 'total_kb' => $totalKb, - 'system_kb' => $systemKb, - 'vm_kb' => $vmKb, - 'zfs_kb' => $arcKb, - 'docker_kb' => $dockerKb, - 'free_kb' => $freeKb, - '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 = '/tmp/vv_net_stat.json'; - $now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)]; - $prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : []; - file_put_contents($stateFile, json_encode($now)); - - $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 - $extIpCache = '/tmp/vv_ext_ip.cache'; - $extIp = ''; - if (file_exists($extIpCache) && (time() - filemtime($extIpCache)) < 300) { - $extIp = trim(file_get_contents($extIpCache) ?: ''); - } 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; - file_put_contents($extIpCache, $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, - ]; -} +// Monitor-page-specific helpers — partner state, fallback state, watchdog summary, scripts status. function vv_partner_state(): array { $vars = vv_conf_vars(); @@ -352,16 +63,6 @@ function vv_fallback_state(): array { ]; } -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_fallback_active(): array { $vars = vv_conf_vars(); $myName = trim(shell_exec('hostname -s') ?: ''); @@ -409,247 +110,144 @@ function vv_fallback_active(): array { return $result; } -function vv_transcode_sessions(): array { - $stateFile = '/tmp/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 session dirs in both known locations - $ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp'; - $ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []); - - // 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'); - - 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, - 'ramdisk' => $rd, - 'ssd' => $ssd, - ]; -} - -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, - '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]); +function vv_watchdog_summary(): array { + $parseKv = function(string $raw): array { + $out = []; + foreach (explode("\n", $raw) as $line) { + $line = trim($line); + if (str_contains($line, ':')) { [$k, $v] = explode(':', $line, 2); $out[trim($k)] = trim($v); } + elseif (str_contains($line, '=')) { [$k, $v] = explode('=', $line, 2); $out[trim($k)] = trim($v, '"\''); } } - } - 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]; - } - - $isValid = ($var['mdNumInvalid'] ?? '0') === '0'; - $exitCode = (int)($var['sbSyncExit'] ?? 0); - $errors = (int)($var['sbSyncErrs'] ?? 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, - '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 { - $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 { - $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_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 $out; }; - 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, - ]; -} -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)); + $dock = $parseKv(@file_get_contents('/tmp/container_watchdog_state.db') ?: ''); + $rw = $parseKv(@file_get_contents('/tmp/resource_watchdog_state.db') ?: ''); + + $ctrStrikes = []; + foreach ($dock as $k => $v) { + if ($k !== 'daemon_strikes' && $k !== 'daemon_restarted_flag' && (int)$v > 0) + $ctrStrikes[$k] = (int)$v; + } + + // Recent restarts (24 h) + $restartLog = '/mnt/user/appdata/unraid_scripts/data/container_restart_history.db'; + $restartRaw = @file_get_contents($restartLog) ?: ''; + $cutoff = time() - 86400; + $restarts = []; + foreach (explode("\n", trim($restartRaw)) as $line) { + if (!$line || !str_contains($line, '|')) continue; + [$name, $ts] = explode('|', $line, 2); + if ((int)$ts >= $cutoff) $restarts[] = ['name' => trim($name), 'ts' => (int)$ts]; + } + usort($restarts, fn($a, $b) => $b['ts'] - $a['ts']); + + // Reboots (12 h) + $rebootRaw = @file_get_contents('/boot/config/system_watchdog_reboots.db') ?: ''; + $rbootCutoff = time() - 43200; + $reboots = 0; + foreach (explode("\n", trim($rebootRaw)) as $line) { + if ((int)trim($line) >= $rbootCutoff) $reboots++; + } + + $rwLevel = (int)($rw['rm_action_level'] ?? 0); + $daemonStrikes = (int)($dock['daemon_strikes'] ?? 0); + $oomCount = (int)trim(@file_get_contents('/tmp/system_watchdog_oom.db') ?: '0'); + + // ── Stability watchdog strikes (/tmp/system_watchdog_state.db) ─────────── + $stabRaw = @file_get_contents('/tmp/system_watchdog_state.db') ?: ''; + $stabStrikes = []; + foreach (explode("\n", $stabRaw) as $line) { + $line = trim($line); + if (!$line || !str_contains($line, ':')) continue; + [$k, $v] = explode(':', $line, 2); + $count = (int)trim($v); + if ($count > 0) $stabStrikes[trim($k)] = $count; + } + + // ── Storage watchdog strikes (/tmp/storage_watchdog_state.db) ──────────── + $storRaw = @file_get_contents('/tmp/storage_watchdog_state.db') ?: ''; + $growthStrikes = []; $logStrikes = []; + foreach (explode("\n", $storRaw) as $line) { + $line = trim($line); + if (!$line || !str_contains($line, ':')) continue; + [$k, $v] = explode(':', $line, 2); + $count = (int)trim($v); + if ($count <= 0) continue; + $key = trim($k); + if (str_starts_with($key, 'appdata_growth_')) + $growthStrikes[substr($key, strlen('appdata_growth_'))] = $count; + elseif (str_starts_with($key, 'appdata_log_')) + $logStrikes[substr($key, strlen('appdata_log_'))] = $count; + } + + // ── Network watchdog NPM strikes (/tmp/network_watchdog_state.db) ──────── + $netRaw = @file_get_contents('/tmp/network_watchdog_state.db') ?: ''; + $npmStrikes = 0; + foreach (explode("\n", $netRaw) as $line) { + $line = trim($line); + if (str_starts_with($line, 'npm:')) $npmStrikes = (int)trim(substr($line, 4)); + } + + // ── Stability live stats ────────────────────────────────────────────────── + $dfPct = function(string $path): int { + $out = shell_exec("df " . escapeshellarg($path) . " --output=pcent 2>/dev/null | tail -1") ?: ''; + return (int)trim(str_replace('%', '', $out)); + }; + + $memRaw = @file_get_contents('/proc/meminfo') ?: ''; + $memAvail = 0; + if (preg_match('/^MemAvailable:\s+(\d+)/m', $memRaw, $m)) $memAvail = (int)$m[1]; + + $loadRaw = @file_get_contents('/proc/loadavg') ?: '0'; + $load1 = (float)explode(' ', trim($loadRaw))[0]; + + $cpuTemp = null; + $sensorsOut = shell_exec("sensors 2>/dev/null | grep -E 'Core 0|Package id 0|Tdie|Tctl|CPU Temp' | grep -oE '[0-9]+\\.[0-9]+' | sort -n | tail -1") ?: ''; + if ($sensorsOut && is_numeric(trim($sensorsOut))) $cpuTemp = (int)round((float)trim($sensorsOut)); + + $zombies = (int)trim(shell_exec("ps -eo stat 2>/dev/null | grep -c '^Z'") ?: '0'); + + $nic = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: 'eth0') ?: 'eth0'; + $nicState = trim(@file_get_contents("/sys/class/net/$nic/operstate") ?: 'unknown'); + $sshdOk = (int)trim(shell_exec('pgrep -c sshd 2>/dev/null') ?: '0') > 0; + + $healthy = empty($ctrStrikes) && empty($stabStrikes) && empty($growthStrikes) && empty($logStrikes) + && $rwLevel === 0 && $daemonStrikes === 0 && $oomCount === 0 && $reboots === 0 + && $npmStrikes === 0 && $nicState === 'up' && $sshdOk; + + return [ + 'healthy' => $healthy, + 'ctr_strikes' => $ctrStrikes, + 'rw_level' => $rwLevel, + 'rw_paused' => array_values(array_filter(explode(',', $rw['rm_paused_containers'] ?? ''))), + 'rw_stopped' => array_values(array_filter(explode(',', $rw['rm_stopped_containers'] ?? ''))), + 'daemon_strikes' => $daemonStrikes, + 'oom_count' => $oomCount, + 'reboots_12h' => $reboots, + 'restarts_24h' => array_slice($restarts, 0, 6), + 'restart_count' => count($restarts), + 'stability' => [ + 'rootfs_pct' => $dfPct('/'), + 'log_pct' => $dfPct('/var/log'), + 'tmp_pct' => $dfPct('/tmp'), + 'ram_free_gb' => round($memAvail / 1048576, 1), + 'load_1min' => $load1, + 'cpu_temp' => $cpuTemp, + 'zombies' => $zombies, + 'nic' => $nic, + 'nic_state' => $nicState, + 'sshd_ok' => $sshdOk, + 'strikes' => $stabStrikes, + ], + 'storage_wd' => [ + 'growth_strikes' => $growthStrikes, + 'log_strikes' => $logStrikes, + ], + 'network_wd' => [ + 'npm_strikes' => $npmStrikes, + ], + ]; } function vv_scripts_status(): array { diff --git a/Plugin/unraid/include/scheduler.php b/Plugin/unraid/include/scheduler.php index c46682e..a324118 100644 --- a/Plugin/unraid/include/scheduler.php +++ b/Plugin/unraid/include/scheduler.php @@ -230,6 +230,26 @@ function vv_script_description(string $path): string { return $first; } +function vv_tools_scripts(): array { + $dir = SCRIPTS_DIR . '/Tools'; + $schedule = vv_schedule_load(); + $scripts = []; + foreach (glob("$dir/*.sh") ?: [] as $path) { + $rel = 'Tools/' . basename($path); + $entry = $schedule[$rel] ?? []; + $scripts[] = [ + 'id' => $rel, + 'label' => vv_pretty_label(basename($path, '.sh')), + 'desc' => vv_script_description($path), + 'enabled' => (bool)($entry['enabled'] ?? false), + 'cron' => $entry['cron'] ?? '', + 'log_enabled' => (bool)($entry['log_enabled'] ?? false), + ]; + } + usort($scripts, fn($a, $b) => strcmp($a['label'], $b['label'])); + return $scripts; +} + function vv_custom_scripts(): array { $dir = SCRIPTS_DIR . '/Custom'; $schedule = vv_schedule_load(); diff --git a/Plugin/unraid/include/unraid_api.php b/Plugin/unraid/include/unraid_api.php new file mode 100644 index 0000000..43efa11 --- /dev/null +++ b/Plugin/unraid/include/unraid_api.php @@ -0,0 +1,143 @@ + empty($fallbacks), + '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) { $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. diff --git a/Plugin/unraid/include/watchdog.php b/Plugin/unraid/include/watchdog.php index f6ddad6..20a9602 100644 --- a/Plugin/unraid/include/watchdog.php +++ b/Plugin/unraid/include/watchdog.php @@ -1,8 +1,8 @@ /dev/null') ?: '1')); - - // Uptime - $uptimeRaw = file_exists('/proc/uptime') ? file_get_contents('/proc/uptime') : '0'; - $uptime = (int)explode(' ', $uptimeRaw)[0]; - - // Docker daemon alive + $sys = vv_system_info(); + $mem = vv_memory_breakdown(); + $loadRaw = @file_get_contents('/proc/loadavg') ?: '0'; $daemonOk = (trim(shell_exec('docker info >/dev/null 2>&1; echo $?') ?: '1') === '0'); - - // OOM count (from stability watchdog OOM file — just the prev cycle count) - $oomFile = '/tmp/system_watchdog_oom.db'; - $oomCount = file_exists($oomFile) ? (int)trim(file_get_contents($oomFile)) : 0; - + $oomCount = (int)trim(@file_get_contents('/tmp/system_watchdog_oom.db') ?: '0'); + $cores = (int)($sys['cpu_cores'] ?: (int)(trim(shell_exec('nproc 2>/dev/null') ?: '1'))); return [ - 'mem_total' => $memTotal, - 'mem_avail' => $memAvail, - 'load1' => $load1, + 'mem_total' => (int)($mem['total_kb'] * 1024), + 'mem_avail' => (int)($mem['free_kb'] * 1024), + 'load1' => (float)explode(' ', trim($loadRaw))[0], 'cores' => $cores, - 'uptime' => $uptime, + 'uptime' => $sys['uptime_sec'] ?? 0, 'daemon_ok' => $daemonOk, 'oom_count' => $oomCount, ]; } +// ── Storage / network state parsers ────────────────────────────────────────── + +function vv_wd_parse_storage_state(string $raw): array { + $growth = []; $log = []; + foreach (explode("\n", $raw) as $line) { + $line = trim($line); + if (!$line || !str_contains($line, ':')) continue; + [$k, $v] = explode(':', $line, 2); + $count = (int)trim($v); + if ($count <= 0) continue; + $key = trim($k); + if (str_starts_with($key, 'appdata_growth_')) + $growth[substr($key, strlen('appdata_growth_'))] = $count; + elseif (str_starts_with($key, 'appdata_log_')) + $log[substr($key, strlen('appdata_log_'))] = $count; + } + return ['growth_strikes' => $growth, 'log_strikes' => $log]; +} + +function vv_wd_parse_network_state(string $raw): array { + $npm = 0; + foreach (explode("\n", $raw) as $line) { + $line = trim($line); + if (str_starts_with($line, 'npm:')) $npm = (int)trim(substr($line, 4)); + } + return ['npm_strikes' => $npm]; +} + // ── Local state files ───────────────────────────────────────────────────────── function vv_wd_local_states(string $restartLogPath): array { @@ -126,6 +137,8 @@ function vv_wd_local_states(string $restartLogPath): array { $sysRaw = @file_get_contents('/tmp/system_watchdog_state.db') ?: ''; $rebootRaw = @file_get_contents('/boot/config/system_watchdog_reboots.db')?: ''; $restartRaw= @file_get_contents($restartLogPath) ?: ''; + $storRaw = @file_get_contents('/tmp/storage_watchdog_state.db') ?: ''; + $netWdRaw = @file_get_contents('/tmp/network_watchdog_state.db') ?: ''; $rw = vv_wd_parse_kv($rwRaw); $dock = vv_wd_parse_kv($dockRaw); @@ -145,6 +158,11 @@ function vv_wd_local_states(string $restartLogPath): array { $sysStrikes[$k] = (int)$v; } + // Growth baseline info (container count + age in seconds) + $growthFile = '/tmp/watchdog_appdata_growth.db'; + $baselineCount = file_exists($growthFile) ? max(0, count(file($growthFile)) - 0) : 0; + $baselineAgeSec = file_exists($growthFile) ? time() - (int)filemtime($growthFile) : null; + return [ 'rw_level' => (int)($rw['rm_action_level'] ?? 0), 'rw_recover' => (int)($rw['rm_recover_cycles'] ?? 0), @@ -158,40 +176,54 @@ function vv_wd_local_states(string $restartLogPath): array { 'sys_strikes' => $sysStrikes, 'reboots' => vv_wd_parse_reboot_log($rebootRaw), 'restarts' => vv_wd_parse_restart_log($restartRaw), + 'storage_wd' => vv_wd_parse_storage_state($storRaw) + [ + 'baseline_count' => $baselineCount, + 'baseline_age_sec' => $baselineAgeSec, + ], + 'network_wd' => vv_wd_parse_network_state($netWdRaw), ]; } // ── Remote data via SSH ─────────────────────────────────────────────────────── function vv_wd_remote_data(string $ip, string $sshKey, string $restartLogPath): array { - // Bundle into one SSH call - $cmd = "printf 'UPTIME:%s\nLOAD:%s\nCORES:%s\nMEMTOTAL:%s\nMEMAVAIL:%s\nDAEMON:%s\nOOM:%s\n---RW---\n%s\n---DOCK---\n%s\n---SKIP---\n%s\n---SYS---\n%s\n---REBOOT---\n%s\n---RESTART---\n%s\n' " + // Bundle into one SSH call. + // /proc/meminfo is passed as a raw section (not awk-parsed) to avoid quoting + // fragility — escapeshellarg() single-quotes the whole command so awk \$2 + // inside double-quotes is unreliable across Unraid builds. + $cmd = "printf 'UPTIME:%s\nLOAD:%s\nCORES:%s\nDAEMON:%s\nOOM:%s\nBASELINECOUNT:%s\nBASELINEAGE:%s\n---MEMINFO---\n%s\n---RW---\n%s\n---DOCK---\n%s\n---SKIP---\n%s\n---SYS---\n%s\n---REBOOT---\n%s\n---RESTART---\n%s\n---STORAGE---\n%s\n---NETWORK---\n%s\n' " . '"$(cat /proc/uptime|cut -d\" \" -f1)" ' . '"$(cat /proc/loadavg|cut -d\" \" -f1)" ' . '"$(nproc)" ' - . '"$(grep -m1 MemTotal /proc/meminfo|awk \"{print \\\$2}\")" ' - . '"$(grep -m1 MemAvailable /proc/meminfo|awk \"{print \\\$2}\")" ' . '"$(docker info >/dev/null 2>&1 && echo ok || echo err)" ' . '"$(cat /tmp/system_watchdog_oom.db 2>/dev/null||echo 0)" ' + . '"$(wc -l < /tmp/watchdog_appdata_growth.db 2>/dev/null||echo 0)" ' + . '"$(stat -c %Y /tmp/watchdog_appdata_growth.db 2>/dev/null||echo 0)" ' + . '"$(cat /proc/meminfo 2>/dev/null)" ' . '"$(cat /tmp/resource_watchdog_state.db 2>/dev/null)" ' . '"$(cat /tmp/container_watchdog_state.db 2>/dev/null)" ' . '"$(cat /boot/config/system_watchdog_failed.db 2>/dev/null)" ' . '"$(cat /tmp/system_watchdog_state.db 2>/dev/null)" ' . '"$(cat /boot/config/system_watchdog_reboots.db 2>/dev/null)" ' - . '"$(cat ' . escapeshellarg($restartLogPath) . ' 2>/dev/null)"'; + . '"$(cat ' . escapeshellarg($restartLogPath) . ' 2>/dev/null)" ' + . '"$(cat /tmp/storage_watchdog_state.db 2>/dev/null)" ' + . '"$(cat /tmp/network_watchdog_state.db 2>/dev/null)"'; $out = vv_pt_ssh($ip, $sshKey, $cmd, 8); if (!$out) return null; // Parse sections - $sections = preg_split('/^---\w+---$/m', $out); - $header = $sections[0] ?? ''; - $rwRaw = $sections[1] ?? ''; - $dockRaw = $sections[2] ?? ''; - $skipRaw = $sections[3] ?? ''; - $sysRaw = $sections[4] ?? ''; - $rebootRaw= $sections[5] ?? ''; - $restartRaw=$sections[6] ?? ''; + $sections = preg_split('/^---\w+---$/m', $out); + $header = $sections[0] ?? ''; + $memInfoRaw = $sections[1] ?? ''; + $rwRaw = $sections[2] ?? ''; + $dockRaw = $sections[3] ?? ''; + $skipRaw = $sections[4] ?? ''; + $sysRaw = $sections[5] ?? ''; + $rebootRaw = $sections[6] ?? ''; + $restartRaw = $sections[7] ?? ''; + $storRaw = $sections[8] ?? ''; + $netWdRaw = $sections[9] ?? ''; // Parse header lines $hdr = []; @@ -199,6 +231,11 @@ function vv_wd_remote_data(string $ip, string $sshKey, string $restartLogPath): if (preg_match('/^(\w+):(.*)$/', trim($line), $m)) $hdr[$m[1]] = trim($m[2]); } + // Parse /proc/meminfo section — no awk, no quoting issues + $memTotal = 0; $memAvail = 0; + if (preg_match('/^MemTotal:\s+(\d+)/m', $memInfoRaw, $m)) $memTotal = (int)$m[1] * 1024; + if (preg_match('/^MemAvailable:\s+(\d+)/m', $memInfoRaw, $m)) $memAvail = (int)$m[1] * 1024; + $rw = vv_wd_parse_kv($rwRaw); $dock = vv_wd_parse_kv($dockRaw); $sys = vv_wd_parse_kv($sysRaw); @@ -213,8 +250,9 @@ function vv_wd_remote_data(string $ip, string $sshKey, string $restartLogPath): if (!str_contains($k, '=') && (int)$v > 0) $sysStrikes[$k] = (int)$v; } - $memTotal = (int)($hdr['MEMTOTAL'] ?? 0) * 1024; - $memAvail = (int)($hdr['MEMAVAIL'] ?? 0) * 1024; + $baselineCount = (int)($hdr['BASELINECOUNT'] ?? 0); + $baselineTs = (int)($hdr['BASELINEAGE'] ?? 0); + $baselineAgeSec = $baselineTs > 0 ? time() - $baselineTs : null; return [ 'system' => [ @@ -239,6 +277,11 @@ function vv_wd_remote_data(string $ip, string $sshKey, string $restartLogPath): 'sys_strikes' => $sysStrikes, 'reboots' => vv_wd_parse_reboot_log($rebootRaw), 'restarts' => vv_wd_parse_restart_log($restartRaw), + 'storage_wd' => vv_wd_parse_storage_state($storRaw) + [ + 'baseline_count' => $baselineCount, + 'baseline_age_sec' => $baselineAgeSec, + ], + 'network_wd' => vv_wd_parse_network_state($netWdRaw), ], ]; } @@ -248,13 +291,19 @@ function vv_wd_remote_data(string $ip, string $sshKey, string $restartLogPath): function vv_wd_node_config(string $slot, string $raw, string $masterRaw): array { $id = strtoupper($slot); return [ - 'monitored' => vv_wd_bash_assoc($raw, "{$id}_WATCHDOG_CONTAINERS"), - 'urls' => vv_wd_bash_assoc($raw, "{$id}_WATCHDOG_CONTAINER_URLS"), - 'required' => vv_wd_bash_array($raw, "{$id}_WATCHDOG_REQUIRED_CONTAINERS"), - 'ignore' => vv_wd_bash_array($raw, "{$id}_WATCHDOG_SCAN_IGNORE"), - 'pause_list' => vv_wd_bash_array($raw, "{$id}_RW_PAUSE_CONTAINERS"), - 'stop_list' => vv_wd_bash_array($raw, "{$id}_RW_STOP_CONTAINERS"), - 'critical' => vv_wd_bash_array($masterRaw, "RW_CRITICAL_CONTAINERS"), + 'monitored' => vv_wd_bash_assoc($raw, "{$id}_WATCHDOG_CONTAINERS"), + 'urls' => vv_wd_bash_assoc($raw, "{$id}_WATCHDOG_CONTAINER_URLS"), + 'required' => vv_wd_bash_array($raw, "{$id}_WATCHDOG_REQUIRED_CONTAINERS"), + 'ignore' => vv_wd_bash_array($raw, "{$id}_WATCHDOG_SCAN_IGNORE"), + 'pause_list' => vv_wd_bash_array($raw, "{$id}_RW_PAUSE_CONTAINERS"), + 'stop_list' => vv_wd_bash_array($raw, "{$id}_RW_STOP_CONTAINERS"), + 'critical' => vv_wd_bash_array($masterRaw, "RW_CRITICAL_CONTAINERS"), + // Network watchdog — host-specific + 'ddns_domain' => vv_wd_scalar($raw, "{$id}_NETWORK_WATCHDOG_DDNS_DOMAIN"), + 'ddns_container' => vv_wd_scalar($raw, "{$id}_NETWORK_WATCHDOG_DDNS_CONTAINER"), + 'npm_url' => vv_wd_scalar($raw, "{$id}_NETWORK_WATCHDOG_NPM_URL"), + // Storage watchdog — host-specific appdata suppress ceilings + 'appdata_sizes' => vv_wd_bash_assoc($raw, "{$id}_WATCHDOG_APPDATA_SIZES"), ]; } @@ -283,8 +332,17 @@ function vv_wd_all(): array { 'soft_mem_pct' => (int)(vv_wd_scalar($masterRaw, 'SOFT_MEM_THRESHOLD') ?: 80), 'soft_cpu_pct' => (int)(vv_wd_scalar($masterRaw, 'SOFT_CPU_THRESHOLD') ?: 80), 'hard_cpu_pct' => (int)(vv_wd_scalar($masterRaw, 'HARD_CPU_THRESHOLD') ?: 85), - 'cpu_fail_lim' => (int)(vv_wd_scalar($masterRaw, 'CPU_FAIL_LIMIT') ?: 2), - 'resp_fail_lim' => (int)(vv_wd_scalar($masterRaw, 'RESP_FAIL_LIMIT') ?: 2), + 'cpu_fail_lim' => (int)(vv_wd_scalar($masterRaw, 'CPU_FAIL_LIMIT') ?: 2), + 'resp_fail_lim' => (int)(vv_wd_scalar($masterRaw, 'RESP_FAIL_LIMIT') ?: 2), + // Storage watchdog + 'growth_gb' => (float)(vv_wd_scalar($masterRaw, 'WATCHDOG_APPDATA_GROWTH_GB') ?: 2), + 'log_max_gb' => (float)(vv_wd_scalar($masterRaw, 'WATCHDOG_APPDATA_LOG_MAX_GB') ?: 2), + 'stor_strike_lim'=> (int)(vv_wd_scalar($masterRaw, 'WATCHDOG_APPDATA_STRIKE_LIMIT') ?: 3), + 'truncate_logs' => vv_wd_scalar($masterRaw, 'WATCHDOG_APPDATA_TRUNCATE_LOGS') === 'true', + // Network watchdog + 'net_wd_enabled' => vv_wd_scalar($masterRaw, 'NETWORK_WATCHDOG_ENABLED') !== 'false', + 'npm_strike_lim' => (int)(vv_wd_scalar($masterRaw, 'NETWORK_WATCHDOG_NPM_STRIKE_LIMIT') ?: 2), + 'ts_check' => vv_wd_scalar($masterRaw, 'NETWORK_WATCHDOG_CHECK_TAILSCALE') !== 'false', ]; // SSH key from current host conf @@ -309,6 +367,8 @@ function vv_wd_all(): array { $ip = $ts['ip'] ?? null; $raw = vv_read_conf_raw($slot . '.conf'); + $remoteApiKey = vv_wd_scalar($raw, strtoupper($slot) . '_UNRAID_API_KEY'); + if ($isMe) { $system = vv_wd_local_system(); $states = vv_wd_local_states($restartLog); @@ -316,6 +376,28 @@ function vv_wd_all(): array { $remote = vv_wd_remote_data($ip, $mySshKey, $restartLog); $system = $remote['system'] ?? null; $states = $remote['states'] ?? null; + } elseif ($remoteApiKey) { + $remoteStats = vv_remote_hosts_stats(); + $rs = $remoteStats[strtoupper($slot)] ?? null; + if ($rs && ($rs['available'] ?? false)) { + $totalGb = (float)($rs['mem_total_gb'] ?? 0); + $usedPct = (float)($rs['mem_used_pct'] ?? 0) / 100; + $totalB = (int)($totalGb * 1073741824); + $availB = (int)($totalB * (1 - $usedPct)); + $system = [ + 'mem_total' => $totalB, + 'mem_avail' => $availB, + 'load1' => 0.0, + 'cores' => (int)($rs['cpu_threads'] ?? 0), + 'uptime' => (int)($rs['uptime_sec'] ?? 0), + 'daemon_ok' => null, + 'oom_count' => 0, + 'api_only' => true, + ]; + } else { + $system = null; + } + $states = null; } else { $system = null; $states = null; diff --git a/Plugin/unraid/pages/config.php b/Plugin/unraid/pages/config.php deleted file mode 100644 index 57a3e3a..0000000 --- a/Plugin/unraid/pages/config.php +++ /dev/null @@ -1,83 +0,0 @@ - - -
No configuration files found.
- - -
- Values shown in green are live from your conf files.
- Orange means the variable was not found.
-
Select a document from the sidebar.
- -