A finding nobody is told about is a finding nobody has, and the card added earlier only shows them to someone who opens the tab. Only needs_operator is announced — an open finding may still be repaired by the next pass — one notification for all of them, and each is announced once and stays quiet until the fault changes or gets worse. vv_notify() hands the message to common.sh's notify() rather than reimplementing the channels, and calls detect_hosts() explicitly because load_config.sh deliberately does not: without it the Unraid notification arrives and Discord silently never does. It also reports false when no channel is switched on at all, since notify() exits 0 either way and a caller believing that would mark a finding as told and never mention it again. Notification text is folded to ASCII. Unraid's notifier dropped an em dash outright and left the double space behind, which was found by sending one and reading what arrived.
997 lines
43 KiB
PHP
997 lines
43 KiB
PHP
<?php
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// PURPOSE
|
||
// The system-metrics library. Everything the monitor page shows about this machine —
|
||
// CPU per core, memory breakdown, GPUs, disks and pools, network, UPS, parity, VMs,
|
||
// containers, transcodes — plus a roll-up of the same for remote nodes.
|
||
//
|
||
// DESIGN PRINCIPLES
|
||
// Prefer the Unraid API, fall back to reading the system directly.
|
||
// vv_api_data() is tried first; when it is unavailable each metric has a local path
|
||
// (/proc, /sys, emhttp ini files, shell tools). The API going away degrades detail,
|
||
// never the page.
|
||
//
|
||
// Remote stats read every host*.conf, not just this host's.
|
||
// A partner's API key lives in the partner's own conf. vv_remote_hosts_stats() globs
|
||
// CONF_DIR for all host*.conf and merges what it finds, because sparse checkout means
|
||
// the partner's file arrives through the conf cache rather than from git.
|
||
//
|
||
// Background cache first, live call second.
|
||
// Remote payloads written by remote_arr_cache_writer.sh (every 2h) are used when
|
||
// present; otherwise a live call runs behind a 30s inline cache. The expensive path is
|
||
// the exception, not the default.
|
||
//
|
||
// Reports raw numbers, applies no policy.
|
||
// Thresholds, alerting and remediation belong to the watchdogs. This file answers
|
||
// "what is the value" and nothing else.
|
||
//
|
||
// OPERATIONAL SAFEGUARDS
|
||
// Every read degrades to empty, never fatal.
|
||
// Filesystem reads use @ with a ?: fallback and every shell_exec redirects stderr.
|
||
// A missing GPU, absent UPS, or unreadable sysfs node yields [] and the corresponding
|
||
// card simply does not render. One missing subsystem cannot blank the whole page.
|
||
//
|
||
// Absent tooling is a normal outcome.
|
||
// No nvidia-smi means no GPU section — not an error. The page is built to be correct
|
||
// on hardware that lacks any given subsystem.
|
||
//
|
||
// Missing state files return an explicit unavailable flag.
|
||
// vv_transcode_sessions() returns ['available' => false] when transcode_state.db does
|
||
// not exist, so the caller can distinguish "not running" from "zero sessions".
|
||
//
|
||
// External IP lookups are cached and time-boxed.
|
||
// curl runs with --max-time and the result is cached 300s, so a slow or unreachable
|
||
// endpoint cannot stall a page render.
|
||
//
|
||
// Read-only throughout. Nothing here starts, stops, or reconfigures anything.
|
||
//
|
||
// EXPORTS
|
||
// System vv_system_info(), vv_system_resources(), vv_cpu_per_core(), vv_memory_breakdown()
|
||
// Storage vv_df(), vv_storage_pools(), vv_array_disks(), vv_disk_io_rates(),
|
||
// vv_disk_thresholds(), vv_disk_entry(), vv_parity_status()
|
||
// Hardware vv_gpu_stats(), vv_gpu_stats_all(), vv_gpu_processes(), vv_ups_stats()
|
||
// Containers vv_docker_containers(), vv_docker_stopped()
|
||
// Network vv_network_stats()
|
||
// Remote vv_remote_hosts_stats()
|
||
// Misc vv_transcode_sessions(), vv_log_tail(), vv_parse_bash_array()
|
||
//
|
||
// CONFIGURATION
|
||
// STATE_DIR transcode_state.db lives here
|
||
// HOST*_UNRAID_API_KEY per-host, read from every host*.conf for remote metrics
|
||
// VV_CACHE_DIR ext_ip (300s) and monitor_remote_<host> (written externally)
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
|
||
require_once __DIR__ . '/config.php';
|
||
require_once __DIR__ . '/unraid_api.php';
|
||
|
||
// Timestamp out of the container restart log, whose second field is a formatted local date
|
||
// ("2026-08-02 13:15:07") rather than an epoch — docker_watchdog.sh writes it that way because
|
||
// its own rolling-window trim compares the strings lexically in awk.
|
||
//
|
||
// Lives here rather than in watchdog.php because include/monitor.php parses the same file for
|
||
// the dashboard card and does not include watchdog.php. Both readers previously cast the field
|
||
// with (int), which stops at the first non-digit and returned 2026 for every line ever written —
|
||
// below any cutoff, so both restart lists were permanently empty and looked exactly like
|
||
// "nothing has restarted".
|
||
//
|
||
// Accepts an epoch too, so that changing the writer later does not require changing the readers.
|
||
function vv_wd_restart_ts(string $raw): int {
|
||
$raw = trim($raw);
|
||
if (ctype_digit($raw)) return (int)$raw;
|
||
return (int)(strtotime($raw) ?: 0);
|
||
}
|
||
|
||
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 fall back to /proc/uptime
|
||
$uptimeRaw = $os['uptime'] ?? '';
|
||
if (is_numeric($uptimeRaw)) {
|
||
$uptimeSec = (int)$uptimeRaw;
|
||
} else {
|
||
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
|
||
}
|
||
$uptime = vv_format_uptime($uptimeSec);
|
||
|
||
$load = sys_getloadavg();
|
||
return [
|
||
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
|
||
'comment' => $ident['COMMENT'] ?? '',
|
||
'timezone' => $ident['timeZone'] ?? 'UTC',
|
||
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
|
||
'cpu_threads' => (int)($cpu['threads'] ?? 0),
|
||
'cpu_cores' => (int)($cpu['cores'] ?? 0),
|
||
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
|
||
'reg_to' => $var['regTo'] ?? '',
|
||
'uptime' => $uptime,
|
||
'uptime_sec' => $uptimeSec,
|
||
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
|
||
'version' => trim($os['release'] ?? '') ?: $version,
|
||
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
|
||
];
|
||
}
|
||
|
||
// ── Local fallback ────────────────────────────────────────────────────────
|
||
vv_api_record_fallback('system_info');
|
||
$cpuModel = '';
|
||
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
|
||
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
|
||
}
|
||
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
|
||
$uptime = vv_format_uptime($uptimeSec);
|
||
|
||
$load = sys_getloadavg();
|
||
return [
|
||
'name' => $ident['NAME'] ?? gethostname(),
|
||
'comment' => $ident['COMMENT'] ?? '',
|
||
'timezone' => $ident['timeZone'] ?? 'UTC',
|
||
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
|
||
'cpu_threads' => 0,
|
||
'cpu_cores' => 0,
|
||
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
|
||
'reg_to' => $var['regTo'] ?? '',
|
||
'uptime' => $uptime,
|
||
'uptime_sec' => $uptimeSec,
|
||
'array_state' => $var['mdState'] ?? 'UNKNOWN',
|
||
'version' => $version,
|
||
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
|
||
];
|
||
}
|
||
|
||
function vv_docker_containers(): array {
|
||
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
|
||
$containers = [];
|
||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||
if (!$line) continue;
|
||
$c = json_decode($line, true);
|
||
if ($c) $containers[] = $c;
|
||
}
|
||
return $containers;
|
||
}
|
||
|
||
function vv_docker_stopped(): array {
|
||
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
|
||
$containers = [];
|
||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||
if (!$line) continue;
|
||
$c = json_decode($line, true);
|
||
if ($c) $containers[] = $c;
|
||
}
|
||
return $containers;
|
||
}
|
||
|
||
// Every installed GPU, one entry per card. Parsed line by line — nvidia-smi emits one row
|
||
// per GPU, so splitting the whole output on commas (as this once did) runs the rows together
|
||
// and only ever describes GPU 0.
|
||
function vv_gpu_stats_all(): array {
|
||
$out = shell_exec('nvidia-smi --query-gpu=index,uuid,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 [];
|
||
|
||
$gpus = [];
|
||
foreach (explode("\n", trim($out)) as $line) {
|
||
if (!$line) continue;
|
||
$p = array_map('trim', explode(',', $line));
|
||
if (count($p) < 10) continue;
|
||
$gpus[] = [
|
||
'available' => true,
|
||
'index' => (int)$p[0],
|
||
'uuid' => $p[1],
|
||
'name' => $p[2],
|
||
'memory_used' => (int)$p[3],
|
||
'memory_total' => (int)$p[4],
|
||
'utilization' => (int)$p[5],
|
||
'temperature' => (int)$p[6],
|
||
'power_w' => is_numeric($p[7]) ? round((float)$p[7], 1) : null,
|
||
'enc_pct' => (int)$p[8],
|
||
'dec_pct' => (int)$p[9],
|
||
];
|
||
}
|
||
return $gpus;
|
||
}
|
||
|
||
// Kept for the existing single-GPU consumers — same shape as before, always GPU 0.
|
||
function vv_gpu_stats(): array {
|
||
$gpus = vv_gpu_stats_all();
|
||
return $gpus[0] ?? ['available' => false];
|
||
}
|
||
|
||
// gpu_uuid is included so each process can be attributed to the card it is actually running
|
||
// on. Without it a two-GPU box shows every process under every card.
|
||
function vv_gpu_processes(): array {
|
||
$out = shell_exec('nvidia-smi --query-compute-apps=gpu_uuid,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[] = [
|
||
'gpu_uuid' => $parts[0] ?? '',
|
||
'pid' => $parts[1] ?? '',
|
||
'memory_mb' => $parts[2] ?? '',
|
||
'name' => $parts[3] ?? '',
|
||
];
|
||
}
|
||
return $procs;
|
||
}
|
||
|
||
function vv_system_resources(): array {
|
||
$mem = [];
|
||
foreach (file('/proc/meminfo') ?: [] as $line) {
|
||
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
|
||
$mem[$m[1]] = (int)$m[2];
|
||
}
|
||
return [
|
||
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
|
||
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
|
||
'cache' => vv_df('/mnt/cache'),
|
||
];
|
||
}
|
||
|
||
function vv_cpu_per_core(): array {
|
||
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
|
||
$raw = [];
|
||
foreach (file('/proc/stat') ?: [] as $line) {
|
||
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
|
||
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
|
||
}
|
||
|
||
$stateFile = VV_CACHE_DIR . '/vv_cpu_stat.json';
|
||
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
|
||
// Atomic write — concurrent fast/slow polls read a consistent snapshot
|
||
$tmp = $stateFile . '.tmp';
|
||
file_put_contents($tmp, json_encode($raw));
|
||
rename($tmp, $stateFile);
|
||
|
||
$usage = function(array $c, ?array $p): int {
|
||
if (!$p) return 0;
|
||
$dt = array_sum($c) - array_sum($p);
|
||
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
|
||
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
|
||
};
|
||
|
||
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
|
||
$cores = [];
|
||
foreach ($raw as $cpu => $c) {
|
||
if ($cpu === 'cpu') continue;
|
||
$num = (int)substr($cpu, 3);
|
||
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
|
||
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
|
||
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
|
||
$cores[] = [
|
||
'core' => $num,
|
||
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
|
||
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
|
||
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
|
||
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
|
||
];
|
||
}
|
||
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
|
||
return ['overall' => $overall, 'cores' => $cores];
|
||
}
|
||
|
||
function vv_memory_breakdown(): array {
|
||
$mem = [];
|
||
foreach (file('/proc/meminfo') ?: [] as $line) {
|
||
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
|
||
}
|
||
$totalKb = $mem['MemTotal'] ?? 0;
|
||
|
||
// ZFS ARC
|
||
$arcKb = 0;
|
||
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
|
||
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
|
||
}
|
||
|
||
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
|
||
$dockerKb = 0;
|
||
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
|
||
foreach (explode("\n", trim($dsOut)) as $line) {
|
||
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
|
||
$val = (float)$m[1];
|
||
$dockerKb += match($m[2]) {
|
||
'GiB' => (int)($val * 1048576),
|
||
'MiB' => (int)($val * 1024),
|
||
'KiB' => (int)$val,
|
||
default => (int)($val / 1024),
|
||
};
|
||
}
|
||
|
||
// VM (QEMU/KVM RSS)
|
||
$vmKb = 0;
|
||
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
|
||
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
|
||
}
|
||
|
||
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
|
||
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
|
||
|
||
// Top processes by RSS — group same-named procs, take top 5
|
||
$grouped = [];
|
||
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
|
||
foreach (explode("\n", trim($psOut)) as $line) {
|
||
$parts = preg_split('/\s+/', trim($line), 2);
|
||
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
|
||
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
|
||
}
|
||
arsort($grouped);
|
||
$topProcs = [];
|
||
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
|
||
$topProcs[] = ['name' => $name, 'kb' => $kb];
|
||
|
||
// Swap — from API metrics when available, else /proc/meminfo
|
||
$swapTotalKb = 0; $swapUsedKb = 0;
|
||
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
|
||
if (!empty($apiMem['swapTotal'])) {
|
||
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
|
||
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
|
||
} else {
|
||
$swapTotalKb = $mem['SwapTotal'] ?? 0;
|
||
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
|
||
}
|
||
|
||
return [
|
||
'total_kb' => $totalKb,
|
||
'system_kb' => $systemKb,
|
||
'vm_kb' => $vmKb,
|
||
'zfs_kb' => $arcKb,
|
||
'docker_kb' => $dockerKb,
|
||
'free_kb' => $freeKb,
|
||
'swap_total_kb' => $swapTotalKb,
|
||
'swap_used_kb' => $swapUsedKb,
|
||
'top_procs' => $topProcs,
|
||
];
|
||
}
|
||
|
||
function vv_df(string $path): array {
|
||
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
|
||
if (!$out) return ['available' => false, 'path' => $path];
|
||
$parts = preg_split('/\s+/', trim($out));
|
||
return [
|
||
'available' => true,
|
||
'path' => $path,
|
||
'size_mb' => (int)$parts[0],
|
||
'used_mb' => (int)$parts[1],
|
||
'free_mb' => (int)$parts[2],
|
||
];
|
||
}
|
||
|
||
function vv_network_stats(): array {
|
||
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
|
||
if (!$iface) {
|
||
$best = ''; $bestBytes = 0;
|
||
foreach (file('/proc/net/dev') ?: [] as $line) {
|
||
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
|
||
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
|
||
}
|
||
$iface = $best;
|
||
}
|
||
if (!$iface) return ['available' => false];
|
||
|
||
$rxBytes = $txBytes = 0;
|
||
foreach (file('/proc/net/dev') ?: [] as $line) {
|
||
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
|
||
$parts = preg_split('/\s+/', trim($m[1]));
|
||
$rxBytes = (int)($parts[0] ?? 0);
|
||
$txBytes = (int)($parts[8] ?? 0);
|
||
break;
|
||
}
|
||
|
||
$stateFile = VV_CACHE_DIR . '/vv_net_stat.json';
|
||
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
|
||
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
|
||
$tmp = $stateFile . '.tmp';
|
||
file_put_contents($tmp, json_encode($now));
|
||
rename($tmp, $stateFile);
|
||
|
||
$rxRate = $txRate = 0;
|
||
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
|
||
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
|
||
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
|
||
}
|
||
|
||
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
|
||
|
||
// Local IP — use primary iface
|
||
$localIp = trim(shell_exec(
|
||
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
|
||
) ?: '');
|
||
|
||
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
|
||
$extIp = '';
|
||
$extData = vv_cache_read('ext_ip', 300);
|
||
if ($extData) {
|
||
$extIp = $extData['ip'] ?? '';
|
||
} else {
|
||
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
|
||
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
|
||
$extIp = $fetched;
|
||
vv_cache_write('ext_ip', ['ip' => $extIp]);
|
||
}
|
||
}
|
||
|
||
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
|
||
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
|
||
|
||
return [
|
||
'available' => true,
|
||
'iface' => $iface,
|
||
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
|
||
'rx_bps' => $rxRate,
|
||
'tx_bps' => $txRate,
|
||
'local_ip' => $localIp,
|
||
'ext_ip' => $extIp,
|
||
'ts_ip' => $tsIp,
|
||
];
|
||
}
|
||
|
||
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
|
||
$name = $d['name'] ?? $key;
|
||
$isParity = $role === 'parity';
|
||
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
|
||
// Parity has no filesystem — use raw size only
|
||
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
|
||
$used_kb = (int)($isParity ? 0 : ($d['fsUsed'] ?? 0));
|
||
if ($size_kb <= 0) return null;
|
||
$tempRaw = trim($d['temp'] ?? '');
|
||
return [
|
||
'name' => $name,
|
||
'device' => $d['device'] ?? $key,
|
||
'role' => $role,
|
||
'size_gb' => round($size_kb / 1048576, 1),
|
||
'used_gb' => round($used_kb / 1048576, 1),
|
||
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
|
||
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
|
||
'transport' => $d['transport'] ?? 'ata',
|
||
'mounted' => $mounted,
|
||
'status' => $d['status'] ?? '',
|
||
];
|
||
}
|
||
|
||
function vv_ups_stats(): array {
|
||
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
|
||
if (!$raw) return ['available' => false];
|
||
|
||
$fields = [];
|
||
foreach (explode("\n", $raw) as $line) {
|
||
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
|
||
$fields[trim($m[1])] = trim($m[2]);
|
||
}
|
||
}
|
||
if (empty($fields)) return ['available' => false];
|
||
|
||
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
|
||
|
||
$loadPct = $parse_num('LOADPCT');
|
||
$nomPower = $parse_num('NOMPOWER');
|
||
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
|
||
|
||
return [
|
||
'available' => true,
|
||
'model' => $fields['MODEL'] ?? '',
|
||
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
|
||
'line_v' => $parse_num('LINEV'),
|
||
'output_v' => $parse_num('OUTPUTV'),
|
||
'load_pct' => $loadPct,
|
||
'nom_power' => $nomPower,
|
||
'watts' => $watts,
|
||
'bcharge' => $parse_num('BCHARGE'),
|
||
'timeleft' => $parse_num('TIMELEFT'),
|
||
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
|
||
'on_batt_s' => $parse_num('CUMONBATT'),
|
||
'selftest' => $fields['SELFTEST'] ?? '',
|
||
];
|
||
}
|
||
|
||
function vv_parity_status(): array {
|
||
$var = [];
|
||
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
|
||
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
|
||
}
|
||
|
||
$numDisabled = (int)($var['mdNumDisabled'] ?? 0);
|
||
$numMissing = (int)($var['mdNumMissing'] ?? 0);
|
||
$exitCode = (int)($var['sbSyncExit'] ?? 0);
|
||
$errors = (int)($var['sbSyncErrs'] ?? 0);
|
||
// Emulated (DISK_DSBL) disks are protected by parity and don't make parity invalid.
|
||
// True invalidity: sync errors on the last check, or unprotectable missing slots.
|
||
$isValid = $errors === 0 && $numMissing === 0;
|
||
$inProgress = ($var['mdResync'] ?? '0') !== '0';
|
||
$resyncAction = trim($var['mdResyncAction'] ?? '');
|
||
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
|
||
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
|
||
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
|
||
|
||
// Last check from log
|
||
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
|
||
$logFile = '/boot/config/parity-checks.log';
|
||
if (file_exists($logFile)) {
|
||
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||
if ($lines) {
|
||
$p = explode('|', trim(end($lines)));
|
||
$lastDate = trim($p[0] ?? '');
|
||
$lastDuration = (int)($p[1] ?? 0);
|
||
$lastSpeed = (int)($p[2] ?? 0);
|
||
$lastExit = (int)($p[3] ?? 0);
|
||
$lastErrors = (int)($p[4] ?? 0);
|
||
}
|
||
}
|
||
|
||
// Parse last date string to timestamp
|
||
$lastTs = $lastDate ? strtotime($lastDate) : null;
|
||
|
||
// Next scheduled check from cron
|
||
$nextTs = null;
|
||
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
|
||
foreach (@file($cronFile) ?: [] as $line) {
|
||
$line = trim($line);
|
||
if ($line === '' || $line[0] === '#') continue;
|
||
if (!str_contains($line, 'mdcmd')) continue;
|
||
$p = preg_split('/\s+/', $line);
|
||
// cron: min hour dom month dow command...
|
||
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
|
||
$next = new DateTime('now');
|
||
$next->setTime((int)$p[1], (int)$p[0], 0);
|
||
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
|
||
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
|
||
$nextTs = $next->getTimestamp();
|
||
}
|
||
break;
|
||
}
|
||
|
||
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
|
||
return [
|
||
'valid' => $isValid,
|
||
'num_disabled' => $numDisabled,
|
||
'num_missing' => $numMissing,
|
||
'in_progress' => $inProgress,
|
||
'resync_action' => $resyncAction,
|
||
'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,
|
||
];
|
||
}
|
||
|
||
// Returns a map of disk name → fsUsed in KB from disks.ini.
|
||
// Unraid keeps this updated even after spindown, so it's the authoritative source
|
||
// for used space when the API reports 0 because the filesystem is unmounted.
|
||
function _vv_ini_used_kb(): array {
|
||
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
|
||
$out = [];
|
||
foreach ($ini as $key => $d) {
|
||
$name = $d['name'] ?? $key;
|
||
$out[$name] = (int)($d['fsUsed'] ?? 0);
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
function vv_storage_pools(): array {
|
||
// ── API path ──────────────────────────────────────────────────────────────
|
||
$api = vv_api_data();
|
||
if ($api && isset($api['array']['caches'])) {
|
||
$ini_used = _vv_ini_used_kb();
|
||
$out = [];
|
||
foreach ($api['array']['caches'] as $d) {
|
||
$entry = vv_api_disk_entry($d, 'data', $ini_used[$d['name'] ?? ''] ?? 0);
|
||
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']))) {
|
||
$ini_used = _vv_ini_used_kb();
|
||
$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', $ini_used[$d['name'] ?? ''] ?? 0);
|
||
if ($entry) $data[] = $entry;
|
||
}
|
||
if (!empty($parity) || !empty($data)) {
|
||
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||
return array_merge($parity, $data);
|
||
}
|
||
}
|
||
|
||
// ── Local fallback ────────────────────────────────────────────────────────
|
||
vv_api_record_fallback('array_disks');
|
||
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
|
||
$parity = [];
|
||
$data = [];
|
||
foreach ($ini as $key => $d) {
|
||
$type = $d['type'] ?? '';
|
||
if ($type === 'Parity') {
|
||
$entry = vv_disk_entry($d, $key, 'parity');
|
||
if ($entry) $parity[] = $entry;
|
||
} elseif ($type === 'Data') {
|
||
$entry = vv_disk_entry($d, $key, 'data');
|
||
if ($entry) $data[] = $entry;
|
||
}
|
||
}
|
||
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||
return array_merge($parity, $data);
|
||
}
|
||
|
||
function vv_disk_io_rates(): array {
|
||
$snapFile = VV_CACHE_DIR . '/vv_diskio_snap.json';
|
||
$now = microtime(true);
|
||
|
||
// Read current whole-disk stats from /proc/diskstats
|
||
$current = [];
|
||
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||
$p = preg_split('/\s+/', trim($line));
|
||
if (count($p) < 14) continue;
|
||
$dev = $p[2];
|
||
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
|
||
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
|
||
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
|
||
}
|
||
|
||
// Load previous snapshot
|
||
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
|
||
$prevTime = (float)($snap['t'] ?? $now);
|
||
$prev = $snap['d'] ?? [];
|
||
|
||
// Save current snapshot
|
||
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
|
||
|
||
$dt = max(0.5, $now - $prevTime);
|
||
$out = [];
|
||
foreach ($current as $dev => [$rs, $ws]) {
|
||
$entry = [
|
||
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
|
||
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
|
||
];
|
||
if (isset($prev[$dev])) {
|
||
[$prs, $pws] = $prev[$dev];
|
||
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
|
||
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
|
||
if ($r > 0.01) $entry['r'] = round($r, 1);
|
||
if ($w > 0.01) $entry['w'] = round($w, 1);
|
||
}
|
||
$out[$dev] = $entry;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
function vv_disk_thresholds(): array {
|
||
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
|
||
$get = function(string $key) use ($cfg): ?int {
|
||
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
|
||
? (int)$m[1] : null;
|
||
};
|
||
return [
|
||
'util_warn' => $get('warning') ?? 70,
|
||
'util_crit' => $get('critical') ?? 90,
|
||
'hdd_warn' => $get('hot') ?? 45,
|
||
'hdd_crit' => $get('max') ?? 55,
|
||
'ssd_warn' => $get('hotssd') ?? 60,
|
||
'ssd_crit' => $get('maxssd') ?? 70,
|
||
];
|
||
}
|
||
|
||
// Fetch a lightweight snapshot from each remote host that has an API key configured.
|
||
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
|
||
function vv_remote_hosts_stats(): array {
|
||
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
|
||
$vars = vv_conf_vars();
|
||
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
|
||
$raw = file_get_contents($f) ?: '';
|
||
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
|
||
foreach ($m[1] as $i => $key) {
|
||
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
|
||
}
|
||
}
|
||
$myHost = vv_detect_host();
|
||
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
|
||
sort($hostIds);
|
||
|
||
$results = [];
|
||
foreach ($hostIds as $id) {
|
||
if (strtolower($id) === strtolower($myHost)) continue;
|
||
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
|
||
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
|
||
if (file_exists($bgCache)) {
|
||
$cached = json_decode(file_get_contents($bgCache), true);
|
||
if ($cached) {
|
||
$cached['cache_age'] = time() - (int)filemtime($bgCache);
|
||
$results[$id] = $cached;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// No background cache yet — fall back to live call (uses 30s inline cache).
|
||
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
|
||
if (!$key) {
|
||
$results[$id] = ['available' => false, 'no_api_key' => true,
|
||
'host_id' => $id, 'hostname' => $vars[$id]];
|
||
continue;
|
||
}
|
||
|
||
$cacheFile = VV_CACHE_DIR . "/vv_remote_{$id}.json";
|
||
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
|
||
$cached = json_decode(file_get_contents($cacheFile), true);
|
||
if ($cached) { $results[$id] = $cached; continue; }
|
||
}
|
||
|
||
$gql = '{
|
||
info { os { hostname uptime release } cpu { brand threads cores } }
|
||
metrics { cpu { percentTotal } memory { percentTotal total used available } }
|
||
array {
|
||
state
|
||
disks { fsSize fsUsed temp }
|
||
caches { fsSize fsUsed temp }
|
||
parities { temp }
|
||
}
|
||
vms { domains { name } }
|
||
}';
|
||
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
|
||
|
||
if (!$data) {
|
||
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
|
||
file_put_contents($cacheFile, json_encode($entry));
|
||
$results[$id] = $entry;
|
||
continue;
|
||
}
|
||
|
||
$os = $data['info']['os'] ?? [];
|
||
$cpu = $data['info']['cpu'] ?? [];
|
||
$mMem = $data['metrics']['memory'] ?? [];
|
||
|
||
$memPct = round((float)($mMem['percentTotal'] ?? 0));
|
||
if ($memPct === 0) {
|
||
$totalBytes = (float)($mMem['total'] ?? 0);
|
||
$availBytes = (float)($mMem['available'] ?? 0);
|
||
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
|
||
}
|
||
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
|
||
|
||
$uptimeRaw = $os['uptime'] ?? '';
|
||
if (is_numeric($uptimeRaw)) {
|
||
$uptimeSec = (int)$uptimeRaw;
|
||
$days = intdiv($uptimeSec, 86400);
|
||
$hours = intdiv($uptimeSec % 86400, 3600);
|
||
$mins = intdiv($uptimeSec % 3600, 60);
|
||
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
|
||
} else {
|
||
$uptimeSec = 0;
|
||
$uptime = $uptimeRaw ?: '—';
|
||
}
|
||
|
||
$nodeMetrics = vv_api_node_metrics($data);
|
||
$entry = array_merge([
|
||
'available' => true,
|
||
'host_id' => $id,
|
||
'hostname' => $os['hostname'] ?? $vars[$id],
|
||
'version' => $os['release'] ?? '',
|
||
'uptime' => $uptime,
|
||
'uptime_sec' => $uptimeSec,
|
||
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
|
||
'cpu_threads' => (int)($cpu['threads'] ?? 0),
|
||
'mem_total_gb' => $memTotalGb,
|
||
'mem_used_pct' => $memPct,
|
||
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
|
||
], $nodeMetrics);
|
||
file_put_contents($cacheFile, json_encode($entry));
|
||
$results[$id] = $entry;
|
||
}
|
||
return $results;
|
||
}
|
||
|
||
function vv_log_tail(string $path, int $lines): string {
|
||
$fp = @fopen($path, 'r');
|
||
if (!$fp) return '';
|
||
fseek($fp, 0, SEEK_END);
|
||
$size = ftell($fp);
|
||
if ($size <= 0) { fclose($fp); return ''; }
|
||
$chunk = min($size, 4096);
|
||
fseek($fp, -$chunk, SEEK_END);
|
||
$data = fread($fp, $chunk);
|
||
fclose($fp);
|
||
$all = explode("\n", $data ?: '');
|
||
return implode("\n", array_slice($all, -$lines));
|
||
}
|
||
|
||
function vv_parse_bash_array(string $raw, string $varName): array {
|
||
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
|
||
$items = [];
|
||
foreach (explode("\n", $m[1]) as $line) {
|
||
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
|
||
if ($line !== '') $items[] = $line;
|
||
}
|
||
return $items;
|
||
}
|
||
|
||
function vv_transcode_sessions(): array {
|
||
$v = vv_conf_vars();
|
||
$stateDir = rtrim($v['STATE_DIR'] ?? STATE_DIR, '/');
|
||
$stateFile = "$stateDir/transcode_state.db";
|
||
if (!file_exists($stateFile)) return ['available' => false];
|
||
|
||
$raw = [];
|
||
foreach (file($stateFile) ?: [] as $line) {
|
||
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
|
||
$raw[trim($k)] = trim($v);
|
||
}
|
||
|
||
$target = $raw['current_target'] ?? '';
|
||
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
|
||
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
|
||
$isRamdisk = str_contains($target, 'ramdisk');
|
||
|
||
// Count active sessions: subdirs (legacy transcode) + unique hex prefixes (Live TV / Direct Stream HLS)
|
||
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
|
||
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
|
||
$maxAge = (int)(vv_conf_vars()['TRANSCODE_MAX_AGE'] ?? 20);
|
||
$activeFiles = 0;
|
||
$cutoff = time() - $maxAge * 60;
|
||
$flatPrefixes = [];
|
||
if (is_dir($ramdiskPath)) {
|
||
foreach (new DirectoryIterator($ramdiskPath) as $f) {
|
||
if (!$f->isFile()) continue;
|
||
if (preg_match('/^([0-9a-f]{16,})/', $f->getFilename(), $m)) {
|
||
$flatPrefixes[$m[1]] = true;
|
||
}
|
||
if ($f->getMTime() >= $cutoff) $activeFiles++;
|
||
}
|
||
}
|
||
$ramSessions += count($flatPrefixes);
|
||
|
||
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
|
||
$ssdPath = '';
|
||
$ssdSessions = 0;
|
||
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
|
||
$parts = explode('/', rtrim($p, '/'));
|
||
array_pop($parts);
|
||
$mount = implode('/', $parts) ?: '/';
|
||
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
|
||
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
|
||
$ssdPath = $p;
|
||
break;
|
||
}
|
||
$ssd = ['available' => false];
|
||
if ($ssdPath) {
|
||
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
|
||
$parts = explode('/', rtrim($ssdPath, '/'));
|
||
array_pop($parts);
|
||
$ssdMount = implode('/', $parts) ?: '/';
|
||
$ssd = vv_df($ssdMount);
|
||
}
|
||
|
||
// Ramdisk disk usage
|
||
$rd = vv_df('/mnt/ramdisk_transcodes');
|
||
|
||
// Last cleanup values from transcode management log
|
||
$lastRdFreed = null;
|
||
$lastSsdFreed = null;
|
||
$logFile = LOG_DIR . '/Orchestrators/transcode_management.log';
|
||
if (file_exists($logFile)) {
|
||
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||
foreach (array_reverse($lines) as $line) {
|
||
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
|
||
$lastRdFreed = $m[1];
|
||
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
|
||
$lastSsdFreed = $m[1];
|
||
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
|
||
}
|
||
}
|
||
|
||
return [
|
||
'available' => true,
|
||
'current_target' => $target,
|
||
'is_ramdisk' => $isRamdisk,
|
||
'flip_count_hour' => $flipCount,
|
||
'last_flip_time' => $lastFlip,
|
||
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
|
||
'ram_sessions' => $ramSessions,
|
||
'ssd_sessions' => $ssdSessions,
|
||
'active_files' => $activeFiles,
|
||
'ramdisk' => $rd,
|
||
'ssd' => $ssd,
|
||
'last_rd_freed' => $lastRdFreed,
|
||
'last_ssd_freed' => $lastSsdFreed,
|
||
];
|
||
}
|
||
|
||
// ── Notification ─────────────────────────────────────────────────────────────────────────────
|
||
// Hands a message to common.sh's notify(), rather than reimplementing it here.
|
||
//
|
||
// notify() is the one place that knows this installation's channels — Unraid's native notifier
|
||
// via the adapter, and the per-host Discord webhook — and which of them are switched on. A PHP
|
||
// copy would be a second answer to "how does this machine reach its operator", and the two would
|
||
// drift the first time a channel is added.
|
||
//
|
||
// detect_hosts() is called explicitly and that is not optional. load_config.sh deliberately does
|
||
// not call it — its header says so — and MY_DISCORD_WEBHOOK is set by detect_hosts() from
|
||
// HOST<n>_DISCORD_WEBHOOK. Skipping it yields a notification that reaches the Unraid GUI and
|
||
// silently never reaches Discord, which is the failure that looks like success.
|
||
//
|
||
// Single-line messages only. notify() builds its Discord payload with printf into a JSON string
|
||
// literal, so a raw newline in the message produces invalid JSON and the webhook rejects it.
|
||
// Callers separate with ' · '.
|
||
// Is there anywhere for a notification to go?
|
||
//
|
||
// notify() exits 0 whether or not it did anything — with NOTIFY_UNRAID false and no webhook it
|
||
// logs a line and returns success, because from its point of view nothing went wrong. A caller
|
||
// that treats that as delivered will mark its work as told and go quiet about it forever, so the
|
||
// question "is any channel switched on" is answered here instead, from the same two settings
|
||
// notify() itself consults.
|
||
function vv_notify_available(): bool {
|
||
$conf = vv_conf_vars();
|
||
if (strtolower(trim((string)($conf['NOTIFY_UNRAID'] ?? 'false'))) === 'true') return true;
|
||
$hook = strtoupper(vv_detect_host()) . '_DISCORD_WEBHOOK';
|
||
return trim((string)($conf[$hook] ?? '')) !== '';
|
||
}
|
||
|
||
function vv_notify(string $message, string $subject, string $severity = 'normal'): bool {
|
||
$loader = SCRIPTS_DIR . '/load_config.sh';
|
||
if (!is_file($loader)) return false;
|
||
if (!vv_notify_available()) return false;
|
||
if (!in_array($severity, ['normal', 'warning', 'alert'], true)) $severity = 'normal';
|
||
|
||
// Newlines are stripped rather than refused: a caller that accidentally includes one should
|
||
// still reach the operator, just on one line.
|
||
$message = trim(preg_replace('/\s*[\r\n]+\s*/', ' | ', $message));
|
||
if ($message === '') return false;
|
||
|
||
// Typographic characters do not survive Unraid's notifier — it dropped an em dash outright
|
||
// and left the double space behind it, which was found by sending one and reading what
|
||
// arrived. Every string in this codebase is full of them, so they are folded to ASCII here
|
||
// rather than asked of each caller. Anything else non-ASCII is left alone: a share name with
|
||
// an accent should arrive imperfectly rather than not at all.
|
||
$message = strtr($message, ['—' => '-', '–' => '-', '·' => '|', '→' => '->',
|
||
'“' => '"', '”' => '"', '‘' => "'", '’' => "'", '…' => '...']);
|
||
$subject = strtr($subject, ['—' => '-', '–' => '-', '·' => '|', '→' => '->',
|
||
'“' => '"', '”' => '"', '‘' => "'", '’' => "'", '…' => '...']);
|
||
|
||
$script = 'source ' . escapeshellarg($loader) . ' >/dev/null 2>&1 || exit 91; '
|
||
. 'detect_hosts >/dev/null 2>&1; '
|
||
. 'notify ' . escapeshellarg($message) . ' ' . escapeshellarg($subject) . ' '
|
||
. escapeshellarg($severity) . ' >/dev/null 2>&1';
|
||
|
||
$out = []; $rc = 0;
|
||
exec('bash -c ' . escapeshellarg($script), $out, $rc);
|
||
return $rc === 0;
|
||
}
|