Plugin: common.php library, watchdog expansion, monitor + scheduler improvements

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
This commit is contained in:
Gmer4Lfe
2026-05-29 23:33:52 -04:00
parent 8f05f0d27c
commit 86048b32d3
13 changed files with 2072 additions and 898 deletions
+720
View File
@@ -0,0 +1,720 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/unraid_api.php';
// Common helpers shared across all Varaverk pages.
function vv_system_info(): array {
// ── Shared local reads (always needed regardless of API) ──────────────────
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api) {
$os = $api['info']['os'] ?? [];
$cpu = $api['info']['cpu'] ?? [];
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$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 ?: '—';
}
$load = sys_getloadavg();
return [
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'cpu_cores' => (int)($cpu['cores'] ?? 0),
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
'version' => trim($os['release'] ?? '') ?: $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('system_info');
$cpuModel = '';
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
}
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
$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,
];
}
+2
View File
@@ -1,4 +1,6 @@
<?php
require_once __DIR__ . '/config.php';
// confform.php — script→conf-section mapping, field parsing, and write-back.
// Map: script relative id → subsection names (must match # ━━━ Name ━━━ or # ── Name ── headers).
+137 -539
View File
@@ -1,296 +1,7 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/common.php';
// Monitor helpers — docker, GPU, resources, transcode sessions, fallback state.
function vv_system_info(): array {
// Identity from ident.cfg
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
// Registration from var.ini
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
// CPU model
$cpu = '';
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpu = trim($m[1]); break; }
}
// Uptime
$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 > 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 {
+20
View File
@@ -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();
+143
View File
@@ -0,0 +1,143 @@
<?php
// Unraid GraphQL API — single-request master fetch + per-function fallback tracking.
// All API-first functions call vv_api_data() then fall back to local reads on null.
//
// Confirmed schema (introspected 2026-05-29):
// InfoOs: hostname, uptime (String), release — no version/uptime-as-int
// InfoCpu: brand, threads, cores — no physicalCores/currentLoad
// InfoMemory: layout only — NO usage fields; memory usage stays as local read
// ArrayDisk: single list for all types (DATA/PARITY/CACHE); fields: name, device,
// type (ArrayDiskType enum), status (ArrayDiskStatus enum), size (BigInt),
// fsSize, fsFree, fsUsed (BigInt), temp, transport (String), rotational,
// isSpinning — no free/mounted
// VmDomain: name, state (VmState enum) — no memory/vcpus
require_once __DIR__ . '/config.php';
// ── Fallback tracking ─────────────────────────────────────────────────────────
function &_vv_api_fallbacks(): array { static $f = []; return $f; }
function vv_api_record_fallback(string $fn): void {
$f = &_vv_api_fallbacks();
$f[] = $fn;
}
function vv_api_get_status(): array {
$fallbacks = array_unique(_vv_api_fallbacks());
return [
'available' => empty($fallbacks),
'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.
+135 -53
View File
@@ -1,8 +1,8 @@
<?php
// Watchdog tab data helpers
require_once __DIR__ . '/common.php';
require_once __DIR__ . '/partnership.php';
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/partnership.php'; // vv_pt_ssh(), vv_pt_ts_peers()
// Watchdog tab data helpers
// ── Conf array parser (bash arrays) ──────────────────────────────────────────
@@ -83,40 +83,51 @@ function vv_wd_parse_reboot_log(string $text, int $windowHrs = 12): array {
// ── Local system snapshot ─────────────────────────────────────────────────────
function vv_wd_local_system(): array {
// RAM
$memRaw = file_exists('/proc/meminfo') ? file_get_contents('/proc/meminfo') : '';
$memTotal = 0; $memAvail = 0;
if (preg_match('/^MemTotal:\s+(\d+)/m', $memRaw, $m)) $memTotal = (int)$m[1] * 1024;
if (preg_match('/^MemAvailable:\s+(\d+)/m', $memRaw, $m)) $memAvail = (int)$m[1] * 1024;
// Load + cores
$loadRaw = file_exists('/proc/loadavg') ? file_get_contents('/proc/loadavg') : '0 0 0';
$loadParts = explode(' ', trim($loadRaw));
$load1 = (float)($loadParts[0] ?? 0);
$cores = (int)(trim(shell_exec('nproc 2>/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;