From 86048b32d3c091545096162d005bdbda1654515b Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Fri, 29 May 2026 23:33:52 -0400 Subject: [PATCH] Plugin: common.php library, watchdog expansion, monitor + scheduler improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP architecture: - Extract common.php from monitor.php — shared system functions (vv_system_info, vv_memory_breakdown, vv_remote_hosts_stats, disk/GPU/UPS/network/docker/parity, etc.) now live in one place; monitor.php and watchdog.php both require common.php - Add unraid_api.php as explicit include (was implicit via config.php chain) - confform.php: add missing require_once config.php (implicit dep made explicit) - Delete orphaned pages/docs.php and pages/config.php (absorbed into scheduler) Watchdog page: - Add Storage watchdog card (growth + log strikes, baseline age, suppress ceilings) - Add Network watchdog card (NPM strikes, DDNS domain/container, NPM URL) - One host per row layout — all 5 watchdog cards equally spaced via inner grid - Watchdog now uses Unraid API for local system stats; remote nodes with API key but no SSH get system info from vv_remote_hosts_stats() with api_only flag - SSH bundle: /proc/meminfo passed as raw section instead of awk-parsed header fields — fixes RAM showing 0 on remote hosts where awk quoting was unreliable - vv_wd_local_system() rewritten as thin wrapper over vv_system_info() + vv_memory_breakdown() Monitor page: - Watchdog card: add stability strikes, storage watchdog strikes, network NPM status, live system stats (rootfs/log/tmp %, RAM free, load, CPU temp, zombies, NIC, sshd) - Row height: switch from max-height on cards to minmax(0, calc(...)) on grid track — all cards in a row now fill to the tallest card's height correctly (fixes Pools card being shorter than neighbours) Scheduler page: - Add Tools section above Custom Scripts — lists Tools/*.sh with run/dry-run/cron/log - vv_tools_scripts() function in scheduler.php include Tools: - Add docker_prune_images.sh — removes dangling Docker images; --dry-run and --status modes --- Plugin/unraid/css/varaverk.css | 79 ++- Plugin/unraid/include/common.php | 720 +++++++++++++++++++++++++++ Plugin/unraid/include/confform.php | 2 + Plugin/unraid/include/monitor.php | 676 +++++-------------------- Plugin/unraid/include/scheduler.php | 20 + Plugin/unraid/include/unraid_api.php | 143 ++++++ Plugin/unraid/include/watchdog.php | 188 +++++-- Plugin/unraid/pages/config.php | 83 --- Plugin/unraid/pages/docs.php | 44 -- Plugin/unraid/pages/monitor.php | 682 +++++++++++++++++++------ Plugin/unraid/pages/scheduler.php | 61 ++- Plugin/unraid/pages/watchdog.php | 177 ++++++- Tools/docker_prune_images.sh | 95 ++++ 13 files changed, 2072 insertions(+), 898 deletions(-) create mode 100644 Plugin/unraid/include/common.php create mode 100644 Plugin/unraid/include/unraid_api.php delete mode 100644 Plugin/unraid/pages/config.php delete mode 100644 Plugin/unraid/pages/docs.php create mode 100755 Tools/docker_prune_images.sh 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 @@ - - -
- - -
-

Plugin Settings

-
- - - - -
-
- - -
- - - - - -
- - -
- - -
- - -
-
- -

No configuration files found.

- - -
- - diff --git a/Plugin/unraid/pages/docs.php b/Plugin/unraid/pages/docs.php deleted file mode 100644 index 02d6cdb..0000000 --- a/Plugin/unraid/pages/docs.php +++ /dev/null @@ -1,44 +0,0 @@ - - -
- -
-

Documents

- -
- -
- -
- -
-

- Values shown in green are live from your conf files. - Orange means the variable was not found. -

- -

Select a document from the sidebar.

- -
- -
diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php index a9ccd3a..fd2d238 100644 --- a/Plugin/unraid/pages/monitor.php +++ b/Plugin/unraid/pages/monitor.php @@ -1,35 +1,48 @@ -
+ + +
-
+
Loading...
-

Power

+

+ + + + Power + + +

Loading...
-

CPU

+

CPU

Loading...
-

Memory

+

Memory

Loading...
-

Network

+

Network

Loading...
- +
-

Scripts

+

Scripts

Loading...
@@ -44,18 +57,21 @@
-

Containers and VMs

+

+ Containers & VMs + +

Loading...
-

GPU

+

GPU

Loading...
-

Transcode System

+

Transcode

Loading...
@@ -64,19 +80,24 @@
Loading...
- -
-

Parity

+ +
+

Watchdog

+
Loading...
+
+ +
+

Parity

Loading...
-

Pools

+

Pools

Loading...
-

Array

+

Array

Loading...
@@ -112,6 +133,9 @@ let vvThresholds = {util_warn:70,util_crit:90,hdd_warn:45,hdd_crit:55,ssd let vvNetRxHistory = []; let vvNetTxHistory = []; +let vvPoolsOpen = {}; +let vvPoolGroupOpen = {}; +let vvLastStorageDisks = []; // ── Canvas chart ────────────────────────────────────────────────────────────── @@ -236,7 +260,7 @@ function vvRenderCpu(cpu) { // Per-core vertical bars if (cores.length) { - html += `
`; + html += `
`; cores.forEach(c => { const usePct = c.usage_pct ?? 0; const hue = Math.round(120 * (1 - usePct / 100)); @@ -315,6 +339,23 @@ function vvRenderMemory(mem) { + vvMemRow('ZFS', mem.zfs_kb ?? 0, total, VV_MEM_COLORS.zfs) + vvMemRow('Docker', mem.docker_kb ?? 0, total, VV_MEM_COLORS.docker) + vvMemRow('Free', mem.free_kb ?? 0, total, VV_MEM_COLORS.free); + + // Swap — only shown when swap is configured and has some usage + const swapTotal = mem.swap_total_kb ?? 0; + const swapUsed = mem.swap_used_kb ?? 0; + if (swapTotal > 0) { + const swapPct = Math.round(swapUsed / swapTotal * 100); + const swapColor = swapPct >= 50 ? '#f44336' : swapPct >= 20 ? '#ff9800' : '#607d8b'; + html += `
+
+ Swap + ${vvFmtGib(swapUsed)} / ${vvFmtGib(swapTotal)} +
+
+
+
+
`; + } return html; } @@ -371,6 +412,50 @@ function vvScriptsFilterSet(type) { vvRenderScripts(); } +// ── Disk / storage helpers (module-level so vvRenderPools can call them) ───── + +function vvTempColor(tempC, transport) { + if (tempC === null) return '#444'; + const isSsd = transport === 'nvme' || transport === 'ssd'; + const warn = isSsd ? vvThresholds.ssd_warn : vvThresholds.hdd_warn; + const crit = isSsd ? vvThresholds.ssd_crit : vvThresholds.hdd_crit; + return tempC >= crit ? '#f44336' : tempC >= warn ? '#ff9800' : '#4caf50'; +} + +function vvFmt(v) { return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB'; } + +function vvDiskRow(disk) { + const tempC = disk.temp; + const tempColor = vvTempColor(tempC, disk.transport); + const tempStr = tempC !== null ? `${tempC}°` : '—'; + const isParity = disk.role === 'parity'; + const nameColor = isParity ? '#6a8faf' : '#aaa'; + const pct = disk.pct ?? 0; + const barColor = isParity ? '#1e3a5a' + : pct >= vvThresholds.util_crit ? '#f44336' + : pct >= vvThresholds.util_warn ? '#ff9800' + : '#4caf50'; + const barWidth = isParity ? '100' : pct; + const spinLabel = (!isParity && !disk.mounted) ? `` : ''; + const right = isParity + ? `${vvFmt(disk.size_gb)}` + : `${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}`; + return `
+
+ ${disk.name}${spinLabel} + ${right} + ${tempStr} +
+
+
+
+
`; +} + +function vvDiskCol(disks) { + return `
${disks.map(vvDiskRow).join('')}
`; +} + // ── Poll ────────────────────────────────────────────────────────────────────── function vvPollMonitor() { @@ -378,6 +463,16 @@ function vvPollMonitor() { .then(r => r.json()) .then(d => { + // ── API status banner ──────────────────────────────────────────────────── + const apiStatus = d._api_status ?? {}; + const banner = document.getElementById('vv-api-banner'); + if (banner) { + const hasFallbacks = apiStatus.fallbacks && apiStatus.fallbacks.length > 0; + banner.style.display = hasFallbacks ? '' : 'none'; + const listEl = document.getElementById('vv-api-fallback-list'); + if (listEl && hasFallbacks) listEl.textContent = '(' + apiStatus.fallbacks.join(', ') + ')'; + } + // ── Thresholds (from dynamix.cfg via backend) ──────────────────────────── if (d.thresholds) vvThresholds = d.thresholds; @@ -390,64 +485,56 @@ function vvPollMonitor() { const arrayColor = (sys.array_state === 'STARTED') ? '#4caf50' : '#f44336'; const ver = (sys.version || '').replace('version=','').replace(/"/g,''); + // Running container + VM counts for system card + const _runningCtrs = [...(d.docker_folders?.folders ?? []), {containers: d.docker_folders?.ungrouped ?? []}] + .flatMap(f => f.containers).filter(c => c.running).length; + const _runningVMs = (d.vms?.vms ?? []).filter(v => v.state === 'running').length; + const _threadInfo = sys.cpu_threads ? `${sys.cpu_cores}c / ${sys.cpu_threads}t` : ''; + document.getElementById('vv-system-body').innerHTML = - `
-
-
${sys.name}
-
${sys.comment}
+ `
+
+
${sys.name}
+
${sys.comment || ' '}
-
- - - +
+ + + + + + + + + + + + + + + + + + + + +
-
${timeStr}
-
${dateStr}, ${tz}
-
- Model ${sys.cpu_model} - Registration${sys.reg_type} - Uptime ${sys.uptime} - Array ${sys.array_state} - Version ${ver} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - `; +
${timeStr}
+
${dateStr} · ${tz}
+
+ Model ${sys.cpu_model}${_threadInfo ? ` (${_threadInfo})` : ''} + Array ${sys.array_state} + Uptime ${sys.uptime} + Running ${_runningCtrs} ctr${_runningCtrs !== 1 ? 's' : ''}${_runningVMs > 0 ? ` · ${_runningVMs} VM` : ''} + Version ${ver} +
`; // ── Partner ────────────────────────────────────────────────────────────── - const pt = d.partner ?? {}; - const ptHosts = pt.hosts ?? []; + const pt = d.partner ?? {}; + const ptHosts = pt.hosts ?? []; + const ptRemote = d.remote_hosts ?? {}; const ptStatus = pt.enabled ? `enabled · sync every ${pt.sync_min}min` : `disabled`; @@ -460,13 +547,52 @@ function vvPollMonitor() { h.is_me ? `US` : '', h.is_owner ? `OWNER` : '', ].join(''); - ptHtml += `
-
- ${h.id} - ${h.owner || h.hostname}${tags} -
${h.hostname}
+ + // Remote stats from Unraid API (only available for non-self hosts with an API key) + const rs = !h.is_me ? (ptRemote[h.id] ?? null) : null; + let statsHtml = ''; + if (rs && rs.available) { + const cpuAvail = rs.cpu_load > 0; + const ramAvail = rs.mem_used_pct > 0; + const cpuHue = cpuAvail ? Math.round(120 * (1 - rs.cpu_load / 100)) : 0; + const memHue = ramAvail ? Math.round(120 * (1 - rs.mem_used_pct / 100)) : 0; + const cpuStr = cpuAvail ? `${rs.cpu_load}%` : ``; + const ramStr = ramAvail ? `${rs.mem_used_pct}%` : ``; + const arrColor = rs.array_state === 'Started' || rs.array_state === 'STARTED' ? '#4caf50' : '#f44336'; + const uptimeStr = rs.uptime && rs.uptime !== '—' ? rs.uptime : '—'; + + // Version mismatch warning — scripts will refuse sync operations until versions match + const myVer = (sys.version || '').replace('version=','').replace(/"/g,'').trim(); + const remoteVer = (rs.version || '').trim(); + const verMismatch = myVer && remoteVer && myVer !== remoteVer; + const verWarn = verMismatch + ? `
+ ⚠ Version mismatch: local ${myVer} · remote ${remoteVer}
+ Script sync ops are gated until versions match +
` : ''; + + statsHtml = `
+ CPU${cpuStr} + RAM${ramStr} + Array + ${rs.array_state} + Uptime + ${uptimeStr} +
${verWarn}`; + } else if (rs && !rs.available) { + statsHtml = `
API unreachable
`; + } + + ptHtml += `
+
+
+ ${h.id} + ${h.owner || h.hostname}${tags} +
${h.hostname}
+
+ ● ${label}
- ● ${label} + ${statsHtml}
`; }); document.getElementById('vv-partner-body').innerHTML = ptHtml; @@ -564,10 +690,15 @@ function vvPollMonitor() { document.getElementById('vv-network-body').innerHTML = '

No network interface detected

'; } + // ── CPU title ──────────────────────────────────────────────────────────── + const _cpuTitleEl = document.getElementById('vv-cpu-title'); + if (_cpuTitleEl && sys.cpu_threads) _cpuTitleEl.textContent = `CPU · ${sys.cpu_cores}c/${sys.cpu_threads}t`; + // ── UPS / Power ───────────────────────────────────────────────────────── const ups = d.ups ?? {}; if (ups.available) { const onBatt = ups.status === 'ONBATT'; + const statCls = onBatt ? 'vv-banner-err' : ups.status === 'ONLINE' ? 'vv-banner-ok' : 'vv-banner-warn'; const statColor = onBatt ? '#f44336' : ups.status === 'ONLINE' ? '#4caf50' : '#ff9800'; const loadPct = ups.load_pct ?? 0; const loadHue = Math.round(120 * (1 - loadPct / 100)); @@ -579,34 +710,38 @@ function vvPollMonitor() { const lineV = ups.line_v != null ? ups.line_v + ' V' : '—'; const outV = ups.output_v != null ? ups.output_v + ' V' : '—'; const xfers = ups.num_xfers ?? 0; + const batIcon = bPct >= 80 ? '▰▰▰▰' : bPct >= 60 ? '▰▰▰▱' : bPct >= 40 ? '▰▰▱▱' : bPct >= 20 ? '▰▱▱▱' : '▱▱▱▱'; document.getElementById('vv-ups-body').innerHTML = - `
- ${ups.model} - ${ups.status} + `
+ ${ups.status}${onBatt ? ' — ON BATTERY' : ''} + ${ups.model}
-
-
- Load - ${loadPct.toFixed(1)}% · ${watts} +
+
+
Load
+
${loadPct.toFixed(0)}%
+
${watts}
-
-
+
+
Battery
+
${bPct.toFixed(0)}%
+
${timeLeft}
-
-
- Battery - ${bPct.toFixed(1)}% · ${timeLeft} +
+
+
-
-
+
+
-
- Line in ${lineV} - Output ${outV} - Transfers ${xfers} +
+ In ${lineV} + Out ${outV} + Xfers ${xfers} + Test ${ups.selftest || '—'}
`; } else { document.getElementById('vv-ups-body').innerHTML = '

No UPS detected

'; @@ -647,12 +782,16 @@ function vvPollMonitor() { } const exitColor = par.exit_label === 'Completed' ? '#4caf50' : par.exit_label === 'Aborted' ? '#ff9800' : '#f44336'; - const errColor = (par.errors ?? 0) > 0 ? '#f44336' : '#555'; + const errColor = (par.errors ?? 0) > 0 ? '#f44336' : '#444'; const speedStr = par.last_speed_mb ? ` · ${par.last_speed_mb} MB/s` : ''; const nextDate = vvFmtDate(par.next_ts); const dueIn = vvDueIn(par.next_ts); + const parBannerCls = !valid ? 'vv-banner-err' : (par.errors ?? 0) > 0 ? 'vv-banner-warn' : 'vv-banner-ok'; - let html = `
${validLabel}
`; + let html = `
+ ${valid ? '✓ Valid' : '✗ INVALID'} + ${(par.errors ?? 0) > 0 ? `${par.errors} error${par.errors !== 1 ? 's' : ''}` : ''} +
`; if (inProg) { const pct = par.resync_pct ?? 0; @@ -678,59 +817,170 @@ function vvPollMonitor() { document.getElementById('vv-parity-body').innerHTML = html; })(); - // ── Storage helpers ────────────────────────────────────────────────────── - function vvTempColor(tempC, transport) { - if (tempC === null) return '#444'; - const isSsd = transport === 'nvme' || transport === 'ssd'; - const warn = isSsd ? vvThresholds.ssd_warn : vvThresholds.hdd_warn; - const crit = isSsd ? vvThresholds.ssd_crit : vvThresholds.hdd_crit; - return tempC >= crit ? '#f44336' : tempC >= warn ? '#ff9800' : '#4caf50'; - } - function vvFmt(v) { return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB'; } + // ── Watchdog ───────────────────────────────────────────────────────────── + (function() { + const wd = d.watchdog ?? {}; + const el = document.getElementById('vv-watchdog-body'); + if (!el) return; - function vvDiskRow(disk) { - const tempC = disk.temp; - const tempColor = vvTempColor(tempC, disk.transport); - const tempStr = tempC !== null ? `${tempC}°` : '—'; - const isParity = disk.role === 'parity'; - const nameColor = isParity ? '#6a8faf' : '#aaa'; - const pct = disk.pct ?? 0; - const barColor = isParity ? '#1e3a5a' - : pct >= vvThresholds.util_crit ? '#f44336' - : pct >= vvThresholds.util_warn ? '#ff9800' - : '#4caf50'; - const barWidth = isParity ? '100' : pct; - const spinLabel = (!isParity && !disk.mounted) ? `` : ''; - const right = isParity - ? `${vvFmt(disk.size_gb)}` - : `${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}`; - return `
-
- ${disk.name}${spinLabel} - ${right} - ${tempStr} -
-
-
-
+ const healthy = wd.healthy ?? true; + const ctrStrikes = wd.ctr_strikes ?? {}; + const ctrNames = Object.keys(ctrStrikes); + const rwLevel = wd.rw_level ?? 0; + const rwPaused = wd.rw_paused ?? []; + const rwStopped = wd.rw_stopped ?? []; + const daemonHit = (wd.daemon_strikes ?? 0) > 0; + const oom = wd.oom_count ?? 0; + const reboots = wd.reboots_12h ?? 0; + const restartCount = wd.restart_count ?? 0; + const restarts = wd.restarts_24h ?? []; + + const stab = wd.stability ?? {}; + const stabStrikes = stab.strikes ?? {}; + const stabNames = Object.keys(stabStrikes); + + const storWd = wd.storage_wd ?? {}; + const growthStr = storWd.growth_strikes ?? {}; + const logStr = storWd.log_strikes ?? {}; + const storIssues = Object.keys(growthStr).length + Object.keys(logStr).length; + + const netWd = wd.network_wd ?? {}; + const npmStrikes = netWd.npm_strikes ?? 0; + + const issueCount = ctrNames.length + stabNames.length + storIssues + + (daemonHit ? 1 : 0) + (oom > 0 ? 1 : 0) + + (reboots > 0 ? 1 : 0) + (npmStrikes > 0 ? 1 : 0); + const bannerCls = healthy ? 'vv-banner-ok' : (reboots || oom || daemonHit ? 'vv-banner-err' : 'vv-banner-warn'); + const bannerTxt = healthy ? '✓ All clear' : `⚠ ${issueCount} issue${issueCount !== 1 ? 's' : ''}`; + + let html = `
${bannerTxt}`; + if (reboots > 0) html += `${reboots} reboot${reboots !== 1 ? 's' : ''}/12h`; + html += `
`; + + // ── Alerts (critical items) ────────────────────────────────────────── + if (rwLevel > 0) { + const rwColor = rwLevel >= 3 ? '#f44336' : rwLevel >= 2 ? '#ff9800' : '#fdd835'; + const rwLabel = ['', 'Soft', 'Medium', 'Hard'][rwLevel] ?? `L${rwLevel}`; + html += `
⚡ Resource mgr: ${rwLabel}`; + if (rwPaused.length) html += ` · ${rwPaused.length} paused`; + if (rwStopped.length) html += ` · ${rwStopped.length} stopped`; + html += `
`; + } + if (daemonHit) html += `
✗ Docker daemon strikes
`; + if (oom > 0) html += `
✗ OOM events: ${oom}
`; + + // ── System stats grid ──────────────────────────────────────────────── + function wdPct(v, warn, crit) { + return v >= crit ? '#f44336' : v >= warn ? '#ff9800' : '#4caf50'; + } + const ramFree = stab.ram_free_gb ?? 0; + const ramColor = ramFree < 6 ? '#f44336' : ramFree < 12 ? '#ff9800' : '#4caf50'; + const load = stab.load_1min ?? 0; + const loadColor = load > 6 ? '#f44336' : load > 3 ? '#ff9800' : '#4caf50'; + const nicOk = (stab.nic_state ?? '') === 'up'; + const sshdOk = stab.sshd_ok ?? true; + const zombies = stab.zombies ?? 0; + + let statsHtml = `
+ rootfs${stab.rootfs_pct??0}% + /var/log${stab.log_pct??0}% + /tmp${stab.tmp_pct??0}% + RAM free${ramFree}GB + Load${load} + ${stab.cpu_temp != null ? `CPU${stab.cpu_temp}°C` : ''} + Zombies${zombies} + ${stab.nic??'nic'}● ${stab.nic_state??'?'} + sshd${sshdOk?'● ok':'✗ down'} + NPM${npmStrikes>0?npmStrikes+'× strikes':'● ok'}
`; - } + html += statsHtml; - function vvDiskCol(disks) { - return `
${disks.map(vvDiskRow).join('')}
`; - } + // ── Stability strikes (system watchdog) ────────────────────────────── + if (stabNames.length) { + html += `
Stability strikes
`; + stabNames.forEach(k => { + html += `
+ ${k} + ${stabStrikes[k]}× +
`; + }); + } - // ── Pools — single column ───────────────────────────────────────────────── - const storageDisks = d.storage ?? []; - if (storageDisks.length) { - document.getElementById('vv-storage-body').innerHTML = storageDisks.map(vvDiskRow).join(''); - } else { - document.getElementById('vv-storage-body').innerHTML = '

No pools found

'; - } + // ── Storage watchdog strikes ───────────────────────────────────────── + if (storIssues > 0) { + html += `
Storage watchdog
`; + Object.entries(growthStr).forEach(([k, v]) => { + html += `
+ ↑ ${k} + ${v}× +
`; + }); + Object.entries(logStr).forEach(([k, v]) => { + const shortKey = k.length > 22 ? '…' + k.slice(-22) : k; + html += `
+ log ${shortKey} + ${v}× +
`; + }); + } - // ── Array disks — min 3 columns, balanced left-to-right, grows as needed ─── + // ── Container strikes ──────────────────────────────────────────────── + if (ctrNames.length) { + html += `
Container strikes
`; + ctrNames.forEach(name => { + html += `
+ ${name} + ${ctrStrikes[name]}× +
`; + }); + } + + // ── Recent restarts ────────────────────────────────────────────────── + if (restartCount > 0) { + const hasAbove = ctrNames.length || stabNames.length || storIssues; + html += `
Restarts 24h ${restartCount}
`; + const now = Math.floor(Date.now() / 1000); + restarts.forEach(r => { + const diff = now - r.ts; + const ago = diff < 3600 ? Math.floor(diff / 60) + 'm' : Math.floor(diff / 3600) + 'h'; + html += `
+ ${r.name} + ${ago} +
`; + }); + } + + el.innerHTML = html; + })(); + + + // ── Pools ───────────────────────────────────────────────────────────────── + vvLastStorageDisks = d.storage ?? []; + document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks); + + // ── Array disks — summary header + min 3 columns ───────────────────────── const arrayDisks = d.array_disks ?? []; if (arrayDisks.length) { + const dataDisks = arrayDisks.filter(d => d.role === 'data'); + const okDisks = dataDisks.filter(d => d.status === 'DISK_OK').length; + const errDisks = dataDisks.length - okDisks; + const temps = arrayDisks.map(d => d.temp).filter(t => t != null); + const maxTemp = temps.length ? Math.max(...temps) : null; + const maxTempColor = maxTemp == null ? '#444' : maxTemp >= (vvThresholds.hdd_crit ?? 55) ? '#f44336' : maxTemp >= (vvThresholds.hdd_warn ?? 45) ? '#ff9800' : '#4caf50'; + const totalUsedGb = dataDisks.reduce((s, d) => s + (d.used_gb ?? 0), 0); + const totalSizeGb = dataDisks.reduce((s, d) => s + (d.size_gb ?? 0), 0); + const arrPct = totalSizeGb > 0 ? Math.round(totalUsedGb / totalSizeGb * 100) : 0; + const arrPctColor = arrPct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : arrPct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50'; + + const titleEl = document.getElementById('vv-array-title'); + if (titleEl) titleEl.innerHTML = `Array + + ${okDisks}/${dataDisks.length} ok + ${errDisks > 0 ? `· ${errDisks} err` : ''} + · ${arrPct}% used + ${maxTemp != null ? `· max ${maxTemp}°` : ''} + `; + const numCols = Math.max(3, Math.ceil(arrayDisks.length / 6)); const perCol = Math.ceil(arrayDisks.length / numCols); const cols = []; @@ -1013,6 +1263,126 @@ vvPollStreams(); setInterval(vvPollStreams, 12000); setInterval(vvRenderStreams, 1000); +// ── Pools card ──────────────────────────────────────────────────────────────── + +function vvTogglePool(name) { + vvPoolsOpen[name] = !vvPoolsOpen[name]; + document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks); +} + +function vvTogglePoolGroup(groupName) { + vvPoolGroupOpen[groupName] = !vvPoolGroupOpen[groupName]; + document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks); +} + +function vvRenderPools(disks) { + if (!disks.length) return '

No pools found

'; + + // Group drives by pool name + const poolMap = {}; + disks.forEach(d => { + const n = d.name; + if (!poolMap[n]) poolMap[n] = []; + poolMap[n].push(d); + }); + + // Build super-groups: pools whose names share a common prefix + trailing digits + const superGroupMap = {}; // groupName -> [poolName, ...] + Object.keys(poolMap).forEach(poolName => { + const match = poolName.match(/^(.+?)(\d+)$/); + const gName = match ? match[1] : poolName; + if (!superGroupMap[gName]) superGroupMap[gName] = []; + superGroupMap[gName].push(poolName); + }); + + // Render a single pool row (with optional drive-expand toggle) + function renderPoolRow(poolName, drives) { + const isOpen = !!vvPoolsOpen[poolName]; + const multi = drives.length > 1; + const totalGb = drives.reduce((s, d) => s + (d.size_gb ?? 0), 0); + const usedGb = drives.reduce((s, d) => s + (d.used_gb ?? 0), 0); + const pct = totalGb > 0 ? Math.round(usedGb / totalGb * 100) : 0; + const pctColor = pct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : pct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50'; + const temps = drives.map(d => d.temp).filter(t => t != null); + const maxTemp = temps.length ? Math.max(...temps) : null; + const tempColor = maxTemp != null ? vvTempColor(maxTemp, drives[0].transport) : '#444'; + const allOk = drives.every(d => d.status === 'DISK_OK' || !d.status); + const statusColor = allOk ? '#aaa' : '#f44336'; + const sName = poolName.replace(/\\/g,'\\\\').replace(/'/g,"\\'"); + + let html = `
+
+ + ${multi ? `${isOpen ? '▾' : '▸'}` : ''} + ${poolName} + ${multi ? `${drives.length} drives` : ''} + + + ${maxTemp != null ? `${maxTemp}°` : ''} + ${vvFmt(usedGb)} / ${vvFmt(totalGb)} + +
+
+
+
+
`; + + if (multi && isOpen) { + html += `
`; + drives.forEach(d => { html += vvDiskRow({ ...d, name: d.device || d.name }); }); + html += `
`; + } + return html; + } + + let html = ''; + Object.entries(superGroupMap).forEach(([groupName, poolNames]) => { + if (poolNames.length === 1) { + // Single pool — render directly, no super-group header + html += renderPoolRow(poolNames[0], poolMap[poolNames[0]]); + } else { + // Super-group: aggregate header + expandable list of individual pools + const isGroupOpen = !!vvPoolGroupOpen[groupName]; + const sGroup = groupName.replace(/\\/g,'\\\\').replace(/'/g,"\\'"); + const allDrives = poolNames.flatMap(n => poolMap[n]); + const totalGb = allDrives.reduce((s, d) => s + (d.size_gb ?? 0), 0); + const usedGb = allDrives.reduce((s, d) => s + (d.used_gb ?? 0), 0); + const pct = totalGb > 0 ? Math.round(usedGb / totalGb * 100) : 0; + const pctColor = pct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : pct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50'; + const temps = allDrives.map(d => d.temp).filter(t => t != null); + const maxTemp = temps.length ? Math.max(...temps) : null; + const tempColor = maxTemp != null ? vvTempColor(maxTemp, allDrives[0].transport) : '#444'; + + html += `
+
+ + ${isGroupOpen ? '▾' : '▸'} + ${groupName} + ${poolNames.length} pools + + + ${maxTemp != null ? `${maxTemp}°` : ''} + ${vvFmt(usedGb)} / ${vvFmt(totalGb)} + +
+
+
+
+
`; + + if (isGroupOpen) { + html += `
`; + poolNames.forEach(poolName => { html += renderPoolRow(poolName, poolMap[poolName]); }); + html += `
`; + } + } + }); + + return html; +} + // ── System clock tick (updates time every 30s without a full poll) ──────────── function vvTickClock() { const el = document.getElementById('vv-system-body'); @@ -1061,6 +1431,13 @@ function vvRenderDockerFolders(data) { const el = document.getElementById('vv-docker-folders-body'); if (!el || !data) return; + // Update container count badge in header + const allCtrs = [...(data.folders ?? []).flatMap(f => f.containers), ...(data.ungrouped ?? [])]; + const totalCtrs = allCtrs.length; + const runCtrs = allCtrs.filter(c => c.running).length; + const countEl = document.getElementById('vv-docker-count'); + if (countEl && totalCtrs > 0) countEl.textContent = `${runCtrs}/${totalCtrs}`; + const osIcon = os => ({ windows:'🪟', macos:'🍎', bsd:'🦬' })[os] ?? '🐧'; const stateColor = s => ({ running:'#4caf50', paused:'#ff9800' })[s] ?? '#444'; const stateLabel = s => ({ running:'Running', paused:'Paused', 'shut off':'Off', crashed:'Crashed' })[s] ?? s; @@ -1167,17 +1544,18 @@ function vvRenderDockerFolders(data) { return; } - // 2-column balanced split — single column on narrow screens - if (window.innerWidth <= 640) { - html += `
${allFolders.map(renderFolder).join('')}
`; + // Column count: 3 big / 2 intermediate / 1 small + const _w = window.innerWidth; + const _cols = _w > 1400 ? 3 : _w > 640 ? 2 : 1; + + if (_cols === 1) { + html += `
${allFolders.map(renderFolder).join('')}
`; } else { - const half = Math.ceil(allFolders.length / 2); - const left = allFolders.slice(0, half); - const right = allFolders.slice(half); - html += `
-
${left.map(renderFolder).join('')}
-
${right.map(renderFolder).join('')}
-
`; + const perCol = Math.ceil(allFolders.length / _cols); + const colDivs = Array.from({length: _cols}, (_, i) => + `
${allFolders.slice(i * perCol, (i + 1) * perCol).map(renderFolder).join('')}
` + ).join(''); + html += `
${colDivs}
`; } el.innerHTML = html; diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index 8bb807f..139b26c 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -2,6 +2,7 @@ require_once dirname(__DIR__) . '/include/scheduler.php'; $tree = vv_job_tree(); $customs = vv_custom_scripts(); +$tools = vv_tools_scripts(); $_library = vv_script_library(); $_folders = vv_folders_load(); @@ -195,6 +196,54 @@ $runningScripts = array_unique($runningScripts);
+ +
+
+ Tools + tool + +
+ +
+ { + // Tools and custom scripts with their own cron + document.querySelectorAll('.vv-tools-card .vv-script, .vv-custom-card .vv-script').forEach(script => { const enabled = script.querySelector('.vv-enabled')?.checked; if (!enabled) return; const cronEl = script.querySelector('input.vv-cron'); diff --git a/Plugin/unraid/pages/watchdog.php b/Plugin/unraid/pages/watchdog.php index b5970bb..3f6d882 100644 --- a/Plugin/unraid/pages/watchdog.php +++ b/Plugin/unraid/pages/watchdog.php @@ -28,6 +28,17 @@ .vv-wd-ctr-name{ font-size:11px;color:#888; } .vv-wd-ctr-lim { font-size:11px;color:#555; } .vv-wd-pressure{ grid-column:1/-1;border-color:#3a2000;background:#1a1000; } + +/* One host per row — inner grid sizes all cards equally */ +.vv-wd-host-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; +} +@media (max-width: 900px) { + .vv-wd-host-row { grid-template-columns: 1fr; } +}
@@ -113,7 +124,7 @@ function _pressureCard(node) { function _systemCard(node, cfg) { const sys = node.system; if (!sys) { - return `
+ return `
${node.id} @@ -136,18 +147,24 @@ function _systemCard(node, cfg) { : sys.load1 > sys.cores * cfg.rw_load_soft ? '#ffb74d' : '#4caf50'; - const daemonDot = sys.daemon_ok ? '#4caf50' : '#ef5350'; + const apiOnly = sys.api_only === true; + const daemonDot = sys.daemon_ok === null ? '#555' : sys.daemon_ok ? '#4caf50' : '#ef5350'; + const daemonTxt = sys.daemon_ok === null ? '—' : sys.daemon_ok ? 'daemon ok' : 'daemon err'; const st = node.states || {}; const level = st.rw_level || 0; - const dotCol = level >= 3 ? '#ef5350' : level >= 2 ? '#ffb74d' : level >= 1 ? '#cddc39' : '#4caf50'; + const dotCol = level >= 3 ? '#ef5350' : level >= 2 ? '#ffb74d' : level >= 1 ? '#cddc39' + : apiOnly ? '#4a7a9b' // blue-grey: API-only, no watchdog state + : '#4caf50'; - return `
+ return `
${node.id} ${node.hostname} - ${_levelLabel(level)} + ${apiOnly ? 'API ONLY' : _levelLabel(level)}
System
${_row('RAM free', `${_fmtBytes(sys.mem_avail)} / ${_fmtBytes(sys.mem_total)}`)} @@ -157,19 +174,20 @@ function _systemCard(node, cfg) { ${cfg.rw_soft_gb}G soft · ${cfg.rw_hard_gb}G hard · ${cfg.sys_mem_gb}G reboot
- ${_row('Load avg', `${sys.load1.toFixed(2)} / ${sys.cores} cores`)} + ${!apiOnly ? `${_row('Load avg', `${sys.load1.toFixed(2)} / ${sys.cores} cores`)} ${_bar(loadPct, loadCol)} -
- ${_row('Uptime', _dur(sys.uptime))} - ${_row('Docker', `${sys.daemon_ok ? 'daemon ok' : 'daemon err'}`)} - ${sys.oom_count > 0 ? _row('OOM kills', `${sys.oom_count}`) : _row('OOM kills', '0')} +
` : ''} + ${_row('Uptime', sys.uptime ? _dur(sys.uptime) : '—')} + ${_row('Docker', `${daemonTxt}`)} + ${!apiOnly ? (sys.oom_count > 0 ? _row('OOM kills', `${sys.oom_count}`) : _row('OOM kills', '0')) : ''} + ${apiOnly ? `
Watchdog state unavailable — SSH not configured
` : ''}
`; } // ── Docker watchdog state card ──────────────────────────────────────────────── function _dockerCard(node, cfg) { const st = node.states; - if (!st) return `
Docker Watchdog
No data
`; + if (!st) return `
Docker Watchdog
No data
`; const strikes = Object.entries(st.ctr_strikes || {}); const skiplist = st.skiplist || []; @@ -212,7 +230,7 @@ function _dockerCard(node, cfg) { ).join(''); } - return `
+ return `
Docker Watchdog
${_pill(st.daemon_restart ? 'daemon restarted' : 'daemon ok', daemonCls)} @@ -232,7 +250,7 @@ function _dockerCard(node, cfg) { // ── Stability / reboot card ─────────────────────────────────────────────────── function _stabilityCard(node, cfg) { const st = node.states; - if (!st) return `
Stability
No data
`; + if (!st) return `
Stability
No data
`; const reboots = st.reboots || []; const sysStr = Object.entries(st.sys_strikes || {}); @@ -251,7 +269,7 @@ function _stabilityCard(node, cfg) { ? '
none (12h)
' : reboots.map(ts => `
${_relTime(ts)}
`).join(''); - return `
+ return `
Stability
${_pill(`${reboots.length} / ${cfg.reboot_limit} reboots`, rebootCls)} @@ -325,23 +343,138 @@ function _configCard(node) {
`; } +// ── Storage watchdog card ───────────────────────────────────────────────────── +function _storageCard(node, cfg) { + const st = node.states; + const nodeCfg = node.config || {}; + if (!st) return `
Storage Watchdog
No data
`; + + const storWd = st.storage_wd || {}; + const growthStr = Object.entries(storWd.growth_strikes || {}); + const logStr = Object.entries(storWd.log_strikes || {}); + const totalIssues = growthStr.length + logStr.length; + const allClear = totalIssues === 0; + + // Baseline info + const bCount = storWd.baseline_count ?? 0; + const bAge = storWd.baseline_age_sec; + let baselineNote = bCount > 0 + ? `${bCount} containers tracked` + : 'no baseline yet (builds after first cycle)'; + if (bAge != null && bCount > 0) { + const bAgeStr = bAge < 120 ? bAge + 's ago' : bAge < 3600 ? Math.floor(bAge/60) + 'm ago' : Math.floor(bAge/3600) + 'h ago'; + baselineNote += ` · updated ${bAgeStr}`; + } + + // Suppress ceilings configured for this host + const sizes = Object.entries(nodeCfg.appdata_sizes || {}); + const sizesHtml = sizes.length + ? sizes.map(([c, mb]) => _pill(`${c} <${Math.round(mb/1024)}GB`, '')).join('') + : ''; + + let growthHtml = ''; + if (growthStr.length === 0) { + growthHtml = '
no active strikes
'; + } else { + growthHtml = growthStr.map(([name, cnt]) => + `
+ ${name} + ${cnt} / ${cfg.stor_strike_lim} +
` + ).join(''); + } + + let logHtml = ''; + if (logStr.length === 0) { + logHtml = '
no active strikes
'; + } else { + logHtml = logStr.map(([key, cnt]) => { + const display = key.length > 36 ? '…' + key.slice(-36) : key; + return `
+ ${display} + ${cnt} / ${cfg.stor_strike_lim} +
`; + }).join(''); + } + + return `
+
Storage Watchdog
+
+ ${allClear ? _pill('all clear', 'ok') : _pill(totalIssues + ' active strike' + (totalIssues !== 1 ? 's' : ''), 'warn')} + ${_pill('growth >' + cfg.growth_gb + 'GB/cycle', '')} + ${_pill('log max ' + cfg.log_max_gb + 'GB', '')} + ${cfg.truncate_logs ? _pill('auto-truncate on', 'ok') : _pill('auto-truncate off', '')} +
+
${baselineNote}
+
+
+
Growth strikes
+ ${growthHtml} +
+
+
Log size strikes
+ ${logHtml} +
+
+ ${sizes.length ? `
Suppress ceilings (this host)
${sizesHtml}
` : ''} +
`; +} + +// ── Network watchdog card ───────────────────────────────────────────────────── +function _networkCard(node, cfg) { + const st = node.states; + const nodeCfg = node.config || {}; + if (!st) return `
Network Watchdog
No data
`; + + const netWd = st.network_wd || {}; + const npmStr = netWd.npm_strikes ?? 0; + const npmCls = npmStr >= cfg.npm_strike_lim ? 'err' : npmStr > 0 ? 'warn' : 'ok'; + const ddnsDomain = nodeCfg.ddns_domain || ''; + const ddnsCtr = nodeCfg.ddns_container || ''; + const npmUrl = nodeCfg.npm_url || ''; + + const ddnsHtml = ddnsDomain + ? `${_row('DDNS domain', `${ddnsDomain}`)} + ${ddnsCtr ? _row('DDNS container', `${ddnsCtr}`) : ''}` + : _row('DDNS', 'not configured for this host'); + + const npmHtml = npmUrl + ? `${_row('NPM URL', `${npmUrl}`)} + ${_row('NPM strikes', `${npmStr} / ${cfg.npm_strike_lim}`)}` + : _row('NPM check', 'not configured for this host'); + + return `
+
Network Watchdog
+
+ ${cfg.net_wd_enabled ? _pill('enabled', 'ok') : _pill('disabled', '')} + ${_pill('Tailscale check ' + (cfg.ts_check ? 'on' : 'off'), cfg.ts_check ? '' : '')} + ${npmUrl ? _pill('NPM ' + npmStr + '/' + cfg.npm_strike_lim + ' strikes', npmCls) : ''} +
+ ${ddnsHtml} +
+ ${npmHtml} +
`; +} + // ── Main render ─────────────────────────────────────────────────────────────── function _render(data) { const nodes = data.nodes || []; const cfg = data.cfg || {}; let html = ''; - // Pressure alerts (full width, per node) - for (const node of nodes) html += _pressureCard(node); - - // System + watchdog state rows per node + // Per-host: pressure alert (if active) then all 5 watchdog cards in one equal-spaced row for (const node of nodes) { - html += _systemCard(node, cfg); - html += _dockerCard(node, cfg); - html += _stabilityCard(node, cfg); + html += _pressureCard(node); + html += `
+ ${_systemCard(node, cfg)} + ${_dockerCard(node, cfg)} + ${_stabilityCard(node, cfg)} + ${_storageCard(node, cfg)} + ${_networkCard(node, cfg)} +
`; } - // Config inventory per node + // Config inventory — separate row, each node span 4 (2 nodes = full row) for (const node of nodes) html += _configCard(node); if (!html) html = '
No nodes configured.
'; diff --git a/Tools/docker_prune_images.sh b/Tools/docker_prune_images.sh new file mode 100755 index 0000000..7b2c2a6 --- /dev/null +++ b/Tools/docker_prune_images.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# ============================================================================================== +# ============================= Docker Prune Images ============================================ +# ============================================================================================== +# +# PURPOSE +# ───────────────────────────────────────────────────────────────────────────── +# Removes dangling Docker images — images no longer tagged or referenced by any +# container. These accumulate after container updates pull a new image version, +# leaving the old image behind with no tag. +# +# Safe to run at any time. Only removes images with no tag and no container +# reference — running containers are never affected. +# +# ============================================================================================== +# RUNTIME MODES +# ============================================================================================== +# +# docker_prune_images.sh +# Show dangling images and remove them. +# +# docker_prune_images.sh --dry-run +# Show what would be removed without removing anything. +# +# docker_prune_images.sh --status +# Show current dangling images and disk usage. No changes. +# +# ============================================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "$SCRIPT_DIR/../load_config.sh" + +parse_args "$@" + +# ============================================================================================== +# ━━━ Setup ━━━ +# ============================================================================================== +if [[ "$EUID" -ne 0 ]]; then + error "Must be run as root" + exit 1 +fi + +acquire_lock + +# ============================================================================================== +# ━━━ Status ━━━ +# ============================================================================================== +if [[ "$SHOW_STATUS" == true ]]; then + echo "" + echo "━━━━━ $ICON_SUMMARY DOCKER PRUNE IMAGES STATUS ━━━━━" + DANGLING=$(docker images -f "dangling=true" --format "{{.ID}}\t{{.Size}}\t{{.CreatedSince}}" 2>/dev/null) + if [[ -z "$DANGLING" ]]; then + echo "$ICON_DONE No dangling images." + else + COUNT=$(echo "$DANGLING" | wc -l) + echo "$ICON_CONTAINERS $COUNT dangling image(s):" + echo "$DANGLING" | while IFS=$'\t' read -r id size age; do + echo " $id $size created $age" + done + fi + echo "━━━━━━━━━━━━━━━━━━━━━━━" + exit 0 +fi + +# ============================================================================================== +# ━━━ Run ━━━ +# ============================================================================================== +echo "━━━ $ICON_CONTAINERS Docker Prune Images — $(date '+%Y-%m-%d %H:%M:%S') ━━━" + +DANGLING_IDS=$(docker images -f "dangling=true" -q 2>/dev/null) + +if [[ -z "$DANGLING_IDS" ]]; then + success "No dangling images — nothing to do" + exit 0 +fi + +COUNT=$(echo "$DANGLING_IDS" | wc -l) +log "Found $COUNT dangling image(s)" + +if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN — would remove $COUNT dangling image(s):" + docker images -f "dangling=true" --format " {{.ID}} {{.Size}} created {{.CreatedSince}}" 2>/dev/null + exit 0 +fi + +OUTPUT=$(docker image prune -f 2>&1) +echo "$OUTPUT" + +RECLAIMED=$(echo "$OUTPUT" | grep -E "^Total reclaimed" || echo "") +if [[ -n "$RECLAIMED" ]]; then + success "Done — $RECLAIMED" +else + success "Done — $COUNT image(s) removed" +fi