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:
@@ -15,7 +15,26 @@
|
||||
.vv-wide { flex: 100%; }
|
||||
.vv-card h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase;
|
||||
color: #888; letter-spacing: 0.05em; white-space: normal;
|
||||
overflow: hidden; min-width: 0; }
|
||||
overflow: hidden; min-width: 0;
|
||||
display: flex; align-items: center; justify-content: space-between; }
|
||||
|
||||
/* Cog icon — links to related Unraid page. Hidden until card hovered. */
|
||||
.vv-card-cog { color: #2a2a2a; font-size: 13px; line-height: 1; text-decoration: none;
|
||||
padding: 1px 3px; border-radius: 3px; flex-shrink: 0;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
font-style: normal; font-weight: normal; text-transform: none;
|
||||
letter-spacing: 0; cursor: pointer; }
|
||||
.vv-card:hover .vv-card-cog { color: #4a4a4a; }
|
||||
.vv-card-cog:hover { color: #aaa !important; background: #333; }
|
||||
|
||||
/* Status banner — coloured strip at top of card body */
|
||||
.vv-banner { border-radius: 4px; padding: 5px 10px; margin-bottom: 10px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 12px; font-weight: 600; }
|
||||
.vv-banner-ok { background: #061306; border: 1px solid #1a401a; color: #4caf50; }
|
||||
.vv-banner-warn { background: #130e00; border: 1px solid #3d2e00; color: #ff9800; }
|
||||
.vv-banner-err { background: #140404; border: 1px solid #3d1010; color: #f44336; }
|
||||
.vv-banner-off { background: #0d0d0d; border: 1px solid #252525; color: #555; }
|
||||
|
||||
/* System card — no h3, no top padding waste */
|
||||
#vv-system { padding-top: 14px; }
|
||||
@@ -118,7 +137,60 @@
|
||||
.vv-nb-settings { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 5px 10px; background: #101010; border-bottom: 1px solid #1a1a1a; }
|
||||
|
||||
/* ── Monitor: locked row height + scrollable cards ────────────────────────── */
|
||||
|
||||
/* Cards on the monitor grid are flex columns — h3 pins, body scrolls */
|
||||
#vv-monitor .vv-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
#vv-monitor .vv-card h3 { flex-shrink: 0; }
|
||||
#vv-monitor .vv-card > div {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
#vv-monitor .vv-card > div::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Dynamic row heights capped per screen tier — rows size to content, never exceed the cap.
|
||||
minmax(0, Xpx): track is content-driven but capped; align-items:stretch makes all cards
|
||||
in a row fill the track, so short cards (Pools, Watchdog) match tall ones (Array).
|
||||
Breakpoints are viewport height (after browser chrome), not screen height. */
|
||||
|
||||
/* ~720p (viewport ≤ 700px) */
|
||||
@media (min-width: 481px) and (max-height: 700px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 160px) / 4)); }
|
||||
}
|
||||
/* ~1080p (viewport 701–1100px) */
|
||||
@media (min-width: 481px) and (min-height: 701px) and (max-height: 1100px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 240px) / 4)); }
|
||||
}
|
||||
/* ~1440p (viewport 1101–1450px) — calibrated on 15" 1440p display */
|
||||
@media (min-width: 481px) and (min-height: 1101px) and (max-height: 1450px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 335px) / 4)); }
|
||||
}
|
||||
/* ~4K (viewport > 1450px) */
|
||||
@media (min-width: 481px) and (min-height: 1451px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 500px) / 4)); }
|
||||
}
|
||||
|
||||
/* Mobile: natural heights, let page scroll */
|
||||
@media (max-width: 480px) {
|
||||
#vv-monitor { grid-auto-rows: auto !important; }
|
||||
#vv-monitor .vv-card { overflow: visible !important; }
|
||||
#vv-monitor .vv-card > div { overflow-y: visible; min-height: auto; }
|
||||
}
|
||||
|
||||
/* Monitor responsive — 4-column grid at medium width */
|
||||
@media (max-width: 1400px) {
|
||||
/* CPU core bars — reduce gap/min-width at intermediate widths before cores get clipped */
|
||||
.vv-cpu-cores { gap: 2px !important; }
|
||||
.vv-cpu-core { min-width: 7px !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
#vv-monitor { grid-template-columns: repeat(4, 1fr) !important; }
|
||||
#vv-docker { grid-column: span 4 !important; }
|
||||
@@ -166,8 +238,8 @@
|
||||
.vv-snap-media { font-size: 11px; }
|
||||
|
||||
/* CPU core bars — shrink gap and min-width so many cores don't overflow */
|
||||
.vv-cpu-cores { gap: 1px !important; }
|
||||
.vv-cpu-core { min-width: 4px !important; }
|
||||
.vv-cpu-cores { gap: 2px !important; }
|
||||
.vv-cpu-core { min-width: 6px !important; }
|
||||
|
||||
/* Monitor single-column — explicit placement cards need override too */
|
||||
#vv-monitor { grid-template-columns: 1fr !important; }
|
||||
@@ -663,6 +735,7 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; }
|
||||
.vv-df-vm-meta { font-size: 10px; color: #555; }
|
||||
.vv-df-cols { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.vv-df-col { flex: 1; min-width: 0; }
|
||||
#vv-docker-folders-body { overflow-x: hidden; }
|
||||
.vv-df-folder { border-bottom: 1px solid #1a1a1a; }
|
||||
.vv-df-folder:last-child { border-bottom: none; }
|
||||
.vv-df-folder-hdr { display: flex; align-items: center; gap: 6px; padding: 5px 4px;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
$files = vv_get_conf_files();
|
||||
$active = $_GET['conf'] ?? ($files[0] ?? '');
|
||||
if (!in_array($active, $files)) $active = $files[0] ?? '';
|
||||
$currentScriptsDir = SCRIPTS_DIR;
|
||||
?>
|
||||
|
||||
<div id="vv-config">
|
||||
|
||||
<!-- Plugin Settings -->
|
||||
<div class="vv-card vv-wide" style="margin-bottom:16px;">
|
||||
<h3>Plugin Settings</h3>
|
||||
<div class="vv-job-row" style="max-width:700px;gap:8px;">
|
||||
<label style="color:#aaa;font-size:13px;white-space:nowrap;">Scripts directory</label>
|
||||
<input type="text" id="vv-scripts-dir" value="<?= htmlspecialchars($currentScriptsDir) ?>"
|
||||
style="flex:1;background:#111;border:1px solid #444;color:#ddd;padding:4px 8px;
|
||||
border-radius:4px;font-family:monospace;font-size:13px;">
|
||||
<button type="button" onclick="vvSaveSettings()" style="padding:4px 14px;background:#4caf50;
|
||||
border:none;color:#fff;border-radius:4px;cursor:pointer;font-size:13px;">Save</button>
|
||||
<span id="vv-settings-status" style="font-size:13px;color:#aaa;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File selector -->
|
||||
<div id="vv-conf-tabs">
|
||||
<?php foreach ($files as $f): ?>
|
||||
<a href="?tab=config&conf=<?= urlencode($f) ?>"
|
||||
class="vv-conf-tab<?= $f === $active ? ' active' : '' ?>">
|
||||
<?= htmlspecialchars($f) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($active): ?>
|
||||
<form id="vv-conf-form">
|
||||
<input type="hidden" name="file" value="<?= htmlspecialchars($active) ?>">
|
||||
<textarea id="vv-conf-editor" name="content" spellcheck="false"><?=
|
||||
htmlspecialchars(vv_read_conf_raw($active))
|
||||
?></textarea>
|
||||
<div id="vv-conf-actions">
|
||||
<button type="button" onclick="vvSaveConf()">Save</button>
|
||||
<span id="vv-conf-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p>No configuration files found.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function vvPost(url, data) {
|
||||
const params = new URLSearchParams({csrf_token, ...data});
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: params
|
||||
}).then(r => r.json());
|
||||
}
|
||||
|
||||
function vvSaveSettings() {
|
||||
const dir = document.getElementById('vv-scripts-dir').value.trim();
|
||||
const status = document.getElementById('vv-settings-status');
|
||||
if (!dir) { status.textContent = '✗ Path required'; return; }
|
||||
status.textContent = 'Saving...';
|
||||
vvPost('/plugins/varaverk/api/settings.php', {scripts_dir: dir})
|
||||
.then(d => { status.textContent = d.ok ? '✓ Saved — reload to apply' : '✗ ' + (d.error ?? 'Error'); })
|
||||
.catch(() => { status.textContent = '✗ Request failed'; });
|
||||
}
|
||||
|
||||
function vvSaveConf() {
|
||||
const form = document.getElementById('vv-conf-form');
|
||||
const file = form.querySelector('[name=file]').value;
|
||||
const content = form.querySelector('[name=content]').value;
|
||||
const status = document.getElementById('vv-conf-status');
|
||||
|
||||
status.textContent = 'Saving...';
|
||||
vvPost('/plugins/varaverk/api/config.php', {file, content})
|
||||
.then(d => { status.textContent = d.ok ? '✓ Saved' : '✗ ' + (d.error ?? 'Error'); })
|
||||
.catch(() => { status.textContent = '✗ Request failed'; });
|
||||
}
|
||||
</script>
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__) . '/include/docs.php';
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$tree = vv_docs_tree();
|
||||
$vars = vv_conf_vars();
|
||||
$active = $_GET['doc'] ?? '';
|
||||
|
||||
// Validate: must be a .md file within SCRIPTS_DIR
|
||||
$active = preg_match('/^[a-zA-Z0-9_\-\/]+\.md$/', $active) ? $active : '';
|
||||
if ($active && !file_exists(SCRIPTS_DIR . '/' . $active)) $active = '';
|
||||
?>
|
||||
|
||||
<div id="vv-docs">
|
||||
|
||||
<div id="vv-docs-sidebar">
|
||||
<h3>Documents</h3>
|
||||
<ul>
|
||||
<?php foreach ($tree as $rel): ?>
|
||||
<li>
|
||||
<a href="?tab=docs&doc=<?= urlencode($rel) ?>"
|
||||
class="<?= $rel === $active ? 'active' : '' ?>">
|
||||
<?= htmlspecialchars($rel) ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="vv-docs-content">
|
||||
<?php if ($active): ?>
|
||||
<div class="vv-doc-body">
|
||||
<?= vv_docs_render($active, $vars) ?>
|
||||
</div>
|
||||
<p class="vv-doc-hint">
|
||||
Values shown in <code class="vv-live-var">green</code> are live from your conf files.
|
||||
<code class="vv-unknown-var">Orange</code> means the variable was not found.
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<p>Select a document from the sidebar.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
+530
-152
@@ -1,35 +1,48 @@
|
||||
<?php require_once dirname(__DIR__) . '/include/monitor.php'; ?>
|
||||
|
||||
<div id="vv-monitor" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
|
||||
<div id="vv-api-banner" style="display:none;background:#1a1200;border:1px solid #3a2800;border-radius:4px;
|
||||
padding:5px 10px;margin-bottom:8px;font-size:11px;color:#ff9800;">
|
||||
⚠ Unraid API unavailable — using local reads. Check API key in host conf.
|
||||
<span id="vv-api-fallback-list" style="color:#666;margin-left:6px;"></span>
|
||||
</div>
|
||||
|
||||
<div id="vv-monitor" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;width:100%;box-sizing:border-box;">
|
||||
|
||||
<!-- Row 1: System | Power | CPU | Memory | Network -->
|
||||
<div class="vv-card" id="vv-system" style="grid-column:span 1;position:relative;overflow:hidden;">
|
||||
<div class="vv-card" id="vv-system" style="grid-column:span 1;">
|
||||
<div id="vv-system-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-ups-card" style="grid-column:span 1;">
|
||||
<h3>Power</h3>
|
||||
<h3>
|
||||
<span style="display:flex;align-items:center;gap:6px;">
|
||||
<svg width="9" height="15" viewBox="0 0 11 18" fill="none" style="opacity:0.4;flex-shrink:0;margin-bottom:1px;">
|
||||
<polygon points="9,0 2,10 6,10 3,18 11,6 6,6 8,0" fill="#ff9800"/>
|
||||
</svg>Power
|
||||
</span>
|
||||
<a href="/Settings/UPS" target="_blank" class="vv-card-cog" title="UPS Settings">⚙</a>
|
||||
</h3>
|
||||
<div id="vv-ups-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-cpu" style="grid-column:span 2;">
|
||||
<h3>CPU</h3>
|
||||
<h3><span id="vv-cpu-title">CPU</span><a href="/Settings/CPUset" target="_blank" class="vv-card-cog" title="CPU Settings">⚙</a></h3>
|
||||
<div id="vv-cpu-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-memory" style="grid-column:span 2;">
|
||||
<h3>Memory</h3>
|
||||
<h3>Memory<a href="/" target="_blank" class="vv-card-cog" title="Dashboard">⚙</a></h3>
|
||||
<div id="vv-memory-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-network" style="grid-column:span 2;">
|
||||
<h3>Network</h3>
|
||||
<h3>Network<a href="/Settings/NetworkSettings" target="_blank" class="vv-card-cog" title="Network Settings">⚙</a></h3>
|
||||
<div id="vv-network-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Scripts | Fallback (span 1) | Partner (span 2) | Containers & VMs (span 4) -->
|
||||
<!-- Row 2: Scripts | Fallback | Partner | Containers & VMs -->
|
||||
<div class="vv-card" id="vv-scripts-card" style="grid-column:span 1;">
|
||||
<h3>Scripts</h3>
|
||||
<h3>Scripts<a href="/Apps/plugin_userscripts" target="_blank" class="vv-card-cog" title="User Scripts">⚙</a></h3>
|
||||
<div id="vv-scripts-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
@@ -44,18 +57,21 @@
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-docker-folders" style="grid-column:span 4;">
|
||||
<h3>Containers and VMs</h3>
|
||||
<h3>
|
||||
<span>Containers & VMs <span id="vv-docker-count" style="font-size:10px;color:#4a4a4a;font-weight:400;text-transform:none;letter-spacing:0;margin-left:2px;"></span></span>
|
||||
<a href="/Docker" target="_blank" class="vv-card-cog" title="Docker">⚙</a>
|
||||
</h3>
|
||||
<div id="vv-docker-folders-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: GPU | Transcode | Streams -->
|
||||
<div class="vv-card" id="vv-gpu-card" style="grid-column:span 2;">
|
||||
<h3>GPU</h3>
|
||||
<h3>GPU<a href="/" target="_blank" class="vv-card-cog" title="Dashboard">⚙</a></h3>
|
||||
<div id="vv-gpu-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-transcode" style="grid-column:span 2;">
|
||||
<h3>Transcode System</h3>
|
||||
<h3>Transcode</h3>
|
||||
<div id="vv-transcode-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
@@ -64,19 +80,24 @@
|
||||
<div id="vv-streams-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 4: Parity (cols 1-2) | Pools (col 3, span 2) | Array (col 5, span 4) -->
|
||||
<div class="vv-card" id="vv-parity-card" style="grid-column:1/span 2;">
|
||||
<h3>Parity</h3>
|
||||
<!-- Row 4: Watchdog | Parity | Pools | Array -->
|
||||
<div class="vv-card" id="vv-watchdog-card" style="grid-column:span 1;">
|
||||
<h3>Watchdog</h3>
|
||||
<div id="vv-watchdog-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-parity-card" style="grid-column:span 1;">
|
||||
<h3>Parity<a href="/Main" target="_blank" class="vv-card-cog" title="Array Management">⚙</a></h3>
|
||||
<div id="vv-parity-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-storage-card" style="grid-column:3/span 2;">
|
||||
<h3>Pools</h3>
|
||||
<h3>Pools<a href="/Main" target="_blank" class="vv-card-cog" title="Array Management">⚙</a></h3>
|
||||
<div id="vv-storage-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-card" id="vv-array-card" style="grid-column:5/span 4;">
|
||||
<h3>Array</h3>
|
||||
<h3><span id="vv-array-title">Array</span><a href="/Main" target="_blank" class="vv-card-cog" title="Array Management">⚙</a></h3>
|
||||
<div id="vv-array-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
@@ -112,6 +133,9 @@ let vvThresholds = {util_warn:70,util_crit:90,hdd_warn:45,hdd_crit:55,ssd
|
||||
|
||||
let vvNetRxHistory = [];
|
||||
let vvNetTxHistory = [];
|
||||
let vvPoolsOpen = {};
|
||||
let vvPoolGroupOpen = {};
|
||||
let vvLastStorageDisks = [];
|
||||
|
||||
// ── Canvas chart ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -236,7 +260,7 @@ function vvRenderCpu(cpu) {
|
||||
|
||||
// Per-core vertical bars
|
||||
if (cores.length) {
|
||||
html += `<div class="vv-cpu-cores" style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;">`;
|
||||
html += `<div class="vv-cpu-cores" style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;overflow:hidden;">`;
|
||||
cores.forEach(c => {
|
||||
const usePct = c.usage_pct ?? 0;
|
||||
const hue = Math.round(120 * (1 - usePct / 100));
|
||||
@@ -315,6 +339,23 @@ function vvRenderMemory(mem) {
|
||||
+ vvMemRow('ZFS', mem.zfs_kb ?? 0, total, VV_MEM_COLORS.zfs)
|
||||
+ vvMemRow('Docker', mem.docker_kb ?? 0, total, VV_MEM_COLORS.docker)
|
||||
+ vvMemRow('Free', mem.free_kb ?? 0, total, VV_MEM_COLORS.free);
|
||||
|
||||
// Swap — only shown when swap is configured and has some usage
|
||||
const swapTotal = mem.swap_total_kb ?? 0;
|
||||
const swapUsed = mem.swap_used_kb ?? 0;
|
||||
if (swapTotal > 0) {
|
||||
const swapPct = Math.round(swapUsed / swapTotal * 100);
|
||||
const swapColor = swapPct >= 50 ? '#f44336' : swapPct >= 20 ? '#ff9800' : '#607d8b';
|
||||
html += `<div style="margin-top:6px;padding-top:6px;border-top:1px solid #1a1a1a;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
|
||||
<span style="color:${swapColor};font-weight:500;">Swap</span>
|
||||
<span style="color:#777;">${vvFmtGib(swapUsed)} / ${vvFmtGib(swapTotal)}</span>
|
||||
</div>
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
|
||||
<div style="width:${swapPct}%;height:100%;background:${swapColor};border-radius:3px;transition:width 0.4s;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -371,6 +412,50 @@ function vvScriptsFilterSet(type) {
|
||||
vvRenderScripts();
|
||||
}
|
||||
|
||||
// ── Disk / storage helpers (module-level so vvRenderPools can call them) ─────
|
||||
|
||||
function vvTempColor(tempC, transport) {
|
||||
if (tempC === null) return '#444';
|
||||
const isSsd = transport === 'nvme' || transport === 'ssd';
|
||||
const warn = isSsd ? vvThresholds.ssd_warn : vvThresholds.hdd_warn;
|
||||
const crit = isSsd ? vvThresholds.ssd_crit : vvThresholds.hdd_crit;
|
||||
return tempC >= crit ? '#f44336' : tempC >= warn ? '#ff9800' : '#4caf50';
|
||||
}
|
||||
|
||||
function vvFmt(v) { return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB'; }
|
||||
|
||||
function vvDiskRow(disk) {
|
||||
const tempC = disk.temp;
|
||||
const tempColor = vvTempColor(tempC, disk.transport);
|
||||
const tempStr = tempC !== null ? `${tempC}°` : '—';
|
||||
const isParity = disk.role === 'parity';
|
||||
const nameColor = isParity ? '#6a8faf' : '#aaa';
|
||||
const pct = disk.pct ?? 0;
|
||||
const barColor = isParity ? '#1e3a5a'
|
||||
: pct >= vvThresholds.util_crit ? '#f44336'
|
||||
: pct >= vvThresholds.util_warn ? '#ff9800'
|
||||
: '#4caf50';
|
||||
const barWidth = isParity ? '100' : pct;
|
||||
const spinLabel = (!isParity && !disk.mounted) ? `<span style="color:#555;font-size:9px;margin-left:4px;">↓</span>` : '';
|
||||
const right = isParity
|
||||
? `<span style="color:#444;font-size:10px;">${vvFmt(disk.size_gb)}</span>`
|
||||
: `<span style="color:#555;font-size:10px;">${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}</span>`;
|
||||
return `<div style="margin-bottom:7px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;">
|
||||
<span style="color:${nameColor};">${disk.name}${spinLabel}</span>
|
||||
${right}
|
||||
<span style="color:${tempColor};font-size:10px;margin-left:6px;flex-shrink:0;">${tempStr}</span>
|
||||
</div>
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
|
||||
<div style="width:${barWidth}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.4s;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function vvDiskCol(disks) {
|
||||
return `<div style="flex:1;min-width:0;">${disks.map(vvDiskRow).join('')}</div>`;
|
||||
}
|
||||
|
||||
// ── Poll ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function vvPollMonitor() {
|
||||
@@ -378,6 +463,16 @@ function vvPollMonitor() {
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
|
||||
// ── API status banner ────────────────────────────────────────────────────
|
||||
const apiStatus = d._api_status ?? {};
|
||||
const banner = document.getElementById('vv-api-banner');
|
||||
if (banner) {
|
||||
const hasFallbacks = apiStatus.fallbacks && apiStatus.fallbacks.length > 0;
|
||||
banner.style.display = hasFallbacks ? '' : 'none';
|
||||
const listEl = document.getElementById('vv-api-fallback-list');
|
||||
if (listEl && hasFallbacks) listEl.textContent = '(' + apiStatus.fallbacks.join(', ') + ')';
|
||||
}
|
||||
|
||||
// ── Thresholds (from dynamix.cfg via backend) ────────────────────────────
|
||||
if (d.thresholds) vvThresholds = d.thresholds;
|
||||
|
||||
@@ -390,64 +485,56 @@ function vvPollMonitor() {
|
||||
const arrayColor = (sys.array_state === 'STARTED') ? '#4caf50' : '#f44336';
|
||||
const ver = (sys.version || '').replace('version=','').replace(/"/g,'');
|
||||
|
||||
// Running container + VM counts for system card
|
||||
const _runningCtrs = [...(d.docker_folders?.folders ?? []), {containers: d.docker_folders?.ungrouped ?? []}]
|
||||
.flatMap(f => f.containers).filter(c => c.running).length;
|
||||
const _runningVMs = (d.vms?.vms ?? []).filter(v => v.state === 'running').length;
|
||||
const _threadInfo = sys.cpu_threads ? `${sys.cpu_cores}c / ${sys.cpu_threads}t` : '';
|
||||
|
||||
document.getElementById('vv-system-body').innerHTML =
|
||||
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px;">
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:700;color:#ddd;">${sys.name}</div>
|
||||
<div style="font-size:11px;color:#666;margin-top:2px;">${sys.comment}</div>
|
||||
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
|
||||
<div style="min-width:0;">
|
||||
<div style="font-size:14px;font-weight:700;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.name}</div>
|
||||
<div style="font-size:10px;color:#555;margin-top:2px;">${sys.comment || ' '}</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:0;flex-shrink:0;margin-left:8px;align-items:flex-start;align-self:flex-start;">
|
||||
<button onclick="vvArrayAction('stop')" class="vv-sys-btn" title="Stop Array" style="width:50px;min-width:0;padding:3px 0;margin:0;font-size:6px;line-height:1;display:block;box-sizing:border-box;overflow:hidden;">■</button>
|
||||
<button onclick="vvArrayAction('shutdown')" class="vv-sys-btn" title="Shutdown" style="width:50px;min-width:0;padding:3px 0;margin:0;font-size:6px;line-height:1;display:block;box-sizing:border-box;overflow:hidden;">⏻</button>
|
||||
<button onclick="vvArrayAction('restart')" class="vv-sys-btn" title="Restart" style="width:50px;min-width:0;padding:3px 0;margin:0;font-size:6px;line-height:1;display:block;box-sizing:border-box;overflow:hidden;">↺</button>
|
||||
<div style="display:flex;align-items:flex-start;gap:6px;flex-shrink:0;margin-left:6px;">
|
||||
<svg width="32" height="54" viewBox="0 0 38 64" fill="none" style="opacity:0.15;pointer-events:none;">
|
||||
<rect x="1" y="1" width="36" height="62" rx="3" stroke="#aaa" stroke-width="1.2" fill="#111"/>
|
||||
<rect x="1" y="1" width="36" height="10" rx="3" fill="#1c1c1c" stroke="#aaa" stroke-width="1.2"/>
|
||||
<circle cx="19" cy="6" r="2.5" stroke="#ff9800" stroke-width="1" fill="none"/>
|
||||
<line x1="19" y1="3.8" x2="19" y2="2.2" stroke="#ff9800" stroke-width="1"/>
|
||||
<rect x="26" y="4" width="4" height="2" rx="0.5" fill="#555"/>
|
||||
<rect x="3" y="14" width="16" height="44" rx="1" fill="#0d0d0d" stroke="#444" stroke-width="0.6"/>
|
||||
<line x1="3" y1="18" x2="19" y2="18" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="22" x2="19" y2="22" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="26" x2="19" y2="26" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="30" x2="19" y2="30" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="34" x2="19" y2="34" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="38" x2="19" y2="38" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="42" x2="19" y2="42" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="46" x2="19" y2="46" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="50" x2="19" y2="50" stroke="#333" stroke-width="0.6"/>
|
||||
<rect x="21" y="14" width="14" height="44" rx="1" fill="#08080f" stroke="#444" stroke-width="0.6" opacity="0.7"/>
|
||||
<rect x="5" y="60" width="5" height="2" rx="1" fill="#333"/>
|
||||
<rect x="28" y="60" width="5" height="2" rx="1" fill="#333"/>
|
||||
</svg>
|
||||
<a href="/" target="_blank" class="vv-card-cog" title="Dashboard" style="margin-top:1px;">⚙</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:22px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div>
|
||||
<div style="font-size:11px;color:#666;margin-bottom:12px;">${dateStr}, ${tz}</div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;">
|
||||
<span style="color:#555;">Model</span> <span style="color:#aaa;">${sys.cpu_model}</span>
|
||||
<span style="color:#555;">Registration</span><span style="color:#aaa;">${sys.reg_type}</span>
|
||||
<span style="color:#555;">Uptime</span> <span style="color:#aaa;">${sys.uptime}</span>
|
||||
<span style="color:#555;">Array</span> <span style="color:${arrayColor};">${sys.array_state}</span>
|
||||
<span style="color:#555;">Version</span> <span style="color:#555;">${ver}</span>
|
||||
</div>
|
||||
<svg width="38" height="64" viewBox="0 0 38 64" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||
style="position:absolute;bottom:8px;right:8px;opacity:0.13;pointer-events:none;">
|
||||
<!-- Case body -->
|
||||
<rect x="1" y="1" width="36" height="62" rx="3" stroke="#aaa" stroke-width="1.2" fill="#111"/>
|
||||
<!-- Top strip -->
|
||||
<rect x="1" y="1" width="36" height="10" rx="3" fill="#1c1c1c" stroke="#aaa" stroke-width="1.2"/>
|
||||
<!-- Power button -->
|
||||
<circle cx="19" cy="6" r="2.5" stroke="#ff9800" stroke-width="1" fill="none"/>
|
||||
<line x1="19" y1="3.8" x2="19" y2="2.2" stroke="#ff9800" stroke-width="1"/>
|
||||
<!-- USB dots top -->
|
||||
<rect x="26" y="4" width="4" height="2" rx="0.5" fill="#555"/>
|
||||
<!-- Mesh front panel -->
|
||||
<rect x="3" y="14" width="16" height="44" rx="1" fill="#0d0d0d" stroke="#444" stroke-width="0.6"/>
|
||||
<!-- Mesh lines -->
|
||||
<line x1="3" y1="17" x2="19" y2="17" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="20" x2="19" y2="20" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="23" x2="19" y2="23" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="26" x2="19" y2="26" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="29" x2="19" y2="29" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="32" x2="19" y2="32" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="35" x2="19" y2="35" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="38" x2="19" y2="38" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="41" x2="19" y2="41" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="44" x2="19" y2="44" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="47" x2="19" y2="47" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="50" x2="19" y2="50" stroke="#333" stroke-width="0.6"/>
|
||||
<line x1="3" y1="53" x2="19" y2="53" stroke="#333" stroke-width="0.6"/>
|
||||
<!-- Glass side panel -->
|
||||
<rect x="21" y="14" width="14" height="44" rx="1" fill="#08080f" stroke="#444" stroke-width="0.6" opacity="0.7"/>
|
||||
<!-- Bottom feet -->
|
||||
<rect x="5" y="60" width="5" height="2" rx="1" fill="#333"/>
|
||||
<rect x="28" y="60" width="5" height="2" rx="1" fill="#333"/>
|
||||
</svg>`;
|
||||
<div style="font-size:20px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div>
|
||||
<div style="font-size:10px;color:#555;margin-bottom:10px;">${dateStr} · ${tz}</div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11px;">
|
||||
<span style="color:#444;">Model</span> <span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.cpu_model}${_threadInfo ? ` <span style="color:#444;">(${_threadInfo})</span>` : ''}</span>
|
||||
<span style="color:#444;">Array</span> <span style="color:${arrayColor};font-weight:600;">${sys.array_state}</span>
|
||||
<span style="color:#444;">Uptime</span> <span style="color:#888;">${sys.uptime}</span>
|
||||
<span style="color:#444;">Running</span> <span style="color:#888;">${_runningCtrs} ctr${_runningCtrs !== 1 ? 's' : ''}${_runningVMs > 0 ? ` · ${_runningVMs} VM` : ''}</span>
|
||||
<span style="color:#444;">Version</span> <span style="color:#3a3a3a;">${ver}</span>
|
||||
</div>`;
|
||||
|
||||
// ── Partner ──────────────────────────────────────────────────────────────
|
||||
const pt = d.partner ?? {};
|
||||
const ptHosts = pt.hosts ?? [];
|
||||
const pt = d.partner ?? {};
|
||||
const ptHosts = pt.hosts ?? [];
|
||||
const ptRemote = d.remote_hosts ?? {};
|
||||
const ptStatus = pt.enabled
|
||||
? `<span style="color:#4caf50;">enabled</span> · sync every ${pt.sync_min}min`
|
||||
: `<span style="color:#555;">disabled</span>`;
|
||||
@@ -460,13 +547,52 @@ function vvPollMonitor() {
|
||||
h.is_me ? `<span style="background:#1a3a1a;color:#4caf50;font-size:8px;padding:1px 5px;border-radius:3px;margin-left:4px;">US</span>` : '',
|
||||
h.is_owner ? `<span style="background:#1a2a3a;color:#4a9eff;font-size:8px;padding:1px 5px;border-radius:3px;margin-left:4px;">OWNER</span>` : '',
|
||||
].join('');
|
||||
ptHtml += `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
|
||||
<div>
|
||||
<span style="font-size:10px;color:#555;margin-right:4px;">${h.id}</span>
|
||||
<span style="font-size:12px;color:#ccc;font-weight:500;">${h.owner || h.hostname}</span>${tags}
|
||||
<div style="font-size:10px;color:#555;margin-top:1px;">${h.hostname}</div>
|
||||
|
||||
// Remote stats from Unraid API (only available for non-self hosts with an API key)
|
||||
const rs = !h.is_me ? (ptRemote[h.id] ?? null) : null;
|
||||
let statsHtml = '';
|
||||
if (rs && rs.available) {
|
||||
const cpuAvail = rs.cpu_load > 0;
|
||||
const ramAvail = rs.mem_used_pct > 0;
|
||||
const cpuHue = cpuAvail ? Math.round(120 * (1 - rs.cpu_load / 100)) : 0;
|
||||
const memHue = ramAvail ? Math.round(120 * (1 - rs.mem_used_pct / 100)) : 0;
|
||||
const cpuStr = cpuAvail ? `<span style="color:hsl(${cpuHue},70%,45%);font-weight:600;">${rs.cpu_load}%</span>` : `<span style="color:#333;">—</span>`;
|
||||
const ramStr = ramAvail ? `<span style="color:hsl(${memHue},70%,45%);font-weight:600;">${rs.mem_used_pct}%</span>` : `<span style="color:#333;">—</span>`;
|
||||
const arrColor = rs.array_state === 'Started' || rs.array_state === 'STARTED' ? '#4caf50' : '#f44336';
|
||||
const uptimeStr = rs.uptime && rs.uptime !== '—' ? rs.uptime : '—';
|
||||
|
||||
// Version mismatch warning — scripts will refuse sync operations until versions match
|
||||
const myVer = (sys.version || '').replace('version=','').replace(/"/g,'').trim();
|
||||
const remoteVer = (rs.version || '').trim();
|
||||
const verMismatch = myVer && remoteVer && myVer !== remoteVer;
|
||||
const verWarn = verMismatch
|
||||
? `<div style="font-size:10px;color:#ff9800;margin-top:4px;padding:3px 6px;background:#1a1000;border:1px solid #3a2800;border-radius:3px;">
|
||||
⚠ Version mismatch: local ${myVer} · remote ${remoteVer}<br>
|
||||
<span style="color:#555;">Script sync ops are gated until versions match</span>
|
||||
</div>` : '';
|
||||
|
||||
statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:10px;margin-top:5px;margin-bottom:2px;">
|
||||
<span style="color:#444;">CPU</span>${cpuStr}
|
||||
<span style="color:#444;">RAM</span>${ramStr}
|
||||
<span style="color:#444;">Array</span>
|
||||
<span style="color:${arrColor};font-weight:600;">${rs.array_state}</span>
|
||||
<span style="color:#444;">Uptime</span>
|
||||
<span style="color:#555;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${uptimeStr}</span>
|
||||
</div>${verWarn}`;
|
||||
} else if (rs && !rs.available) {
|
||||
statsHtml = `<div style="font-size:10px;color:#444;margin-top:4px;">API unreachable</div>`;
|
||||
}
|
||||
|
||||
ptHtml += `<div style="margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid #222;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div>
|
||||
<span style="font-size:10px;color:#555;margin-right:4px;">${h.id}</span>
|
||||
<span style="font-size:12px;color:#ccc;font-weight:500;">${h.owner || h.hostname}</span>${tags}
|
||||
<div style="font-size:10px;color:#555;margin-top:1px;">${h.hostname}</div>
|
||||
</div>
|
||||
<span style="color:${dot};font-size:10px;white-space:nowrap;">● ${label}</span>
|
||||
</div>
|
||||
<span style="color:${dot};font-size:10px;white-space:nowrap;">● ${label}</span>
|
||||
${statsHtml}
|
||||
</div>`;
|
||||
});
|
||||
document.getElementById('vv-partner-body').innerHTML = ptHtml;
|
||||
@@ -564,10 +690,15 @@ function vvPollMonitor() {
|
||||
document.getElementById('vv-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</p>';
|
||||
}
|
||||
|
||||
// ── CPU title ────────────────────────────────────────────────────────────
|
||||
const _cpuTitleEl = document.getElementById('vv-cpu-title');
|
||||
if (_cpuTitleEl && sys.cpu_threads) _cpuTitleEl.textContent = `CPU · ${sys.cpu_cores}c/${sys.cpu_threads}t`;
|
||||
|
||||
// ── UPS / Power ─────────────────────────────────────────────────────────
|
||||
const ups = d.ups ?? {};
|
||||
if (ups.available) {
|
||||
const onBatt = ups.status === 'ONBATT';
|
||||
const statCls = onBatt ? 'vv-banner-err' : ups.status === 'ONLINE' ? 'vv-banner-ok' : 'vv-banner-warn';
|
||||
const statColor = onBatt ? '#f44336' : ups.status === 'ONLINE' ? '#4caf50' : '#ff9800';
|
||||
const loadPct = ups.load_pct ?? 0;
|
||||
const loadHue = Math.round(120 * (1 - loadPct / 100));
|
||||
@@ -579,34 +710,38 @@ function vvPollMonitor() {
|
||||
const lineV = ups.line_v != null ? ups.line_v + ' V' : '—';
|
||||
const outV = ups.output_v != null ? ups.output_v + ' V' : '—';
|
||||
const xfers = ups.num_xfers ?? 0;
|
||||
const batIcon = bPct >= 80 ? '▰▰▰▰' : bPct >= 60 ? '▰▰▰▱' : bPct >= 40 ? '▰▰▱▱' : bPct >= 20 ? '▰▱▱▱' : '▱▱▱▱';
|
||||
|
||||
document.getElementById('vv-ups-body').innerHTML =
|
||||
`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||
<span style="font-size:11px;color:#666;">${ups.model}</span>
|
||||
<span style="font-size:11px;font-weight:600;color:${statColor};">${ups.status}</span>
|
||||
`<div class="vv-banner ${statCls}" style="margin-bottom:8px;">
|
||||
<span>${ups.status}${onBatt ? ' — ON BATTERY' : ''}</span>
|
||||
<span style="font-size:11px;font-weight:400;opacity:0.8;">${ups.model}</span>
|
||||
</div>
|
||||
<div style="margin-bottom:7px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px;">
|
||||
<span style="color:#666;">Load</span>
|
||||
<span style="color:#999;">${loadPct.toFixed(1)}% · ${watts}</span>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;">
|
||||
<div>
|
||||
<div style="font-size:10px;color:#555;margin-bottom:3px;">Load</div>
|
||||
<div style="font-size:15px;font-weight:600;color:hsl(${loadHue},70%,45%);line-height:1;">${loadPct.toFixed(0)}<span style="font-size:10px;font-weight:400;color:#555;">%</span></div>
|
||||
<div style="font-size:10px;color:#444;margin-top:2px;">${watts}</div>
|
||||
</div>
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
|
||||
<div style="width:${loadPct}%;height:100%;background:hsl(${loadHue},70%,45%);border-radius:3px;transition:width 0.4s;"></div>
|
||||
<div>
|
||||
<div style="font-size:10px;color:#555;margin-bottom:3px;">Battery</div>
|
||||
<div style="font-size:15px;font-weight:600;color:${bColor};line-height:1;">${bPct.toFixed(0)}<span style="font-size:10px;font-weight:400;color:#555;">%</span></div>
|
||||
<div style="font-size:10px;color:#444;margin-top:2px;">${timeLeft}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px;">
|
||||
<span style="color:#666;">Battery</span>
|
||||
<span style="color:#999;">${bPct.toFixed(1)}% · ${timeLeft}</span>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:8px;">
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
|
||||
<div style="width:${loadPct}%;height:100%;background:hsl(${loadHue},70%,45%);transition:width 0.4s;"></div>
|
||||
</div>
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
|
||||
<div style="width:${bPct}%;height:100%;background:${bColor};border-radius:3px;transition:width 0.4s;"></div>
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
|
||||
<div style="width:${bPct}%;height:100%;background:${bColor};transition:width 0.4s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;">
|
||||
<span style="color:#555;">Line in</span> <span style="color:#888;">${lineV}</span>
|
||||
<span style="color:#555;">Output</span> <span style="color:#888;">${outV}</span>
|
||||
<span style="color:#555;">Transfers</span> <span style="color:${xfers > 0 ? '#ff9800' : '#555'};">${xfers}</span>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:3px 8px;font-size:10px;">
|
||||
<span style="color:#444;">In</span> <span style="color:#777;">${lineV}</span>
|
||||
<span style="color:#444;">Out</span> <span style="color:#777;">${outV}</span>
|
||||
<span style="color:#444;">Xfers</span> <span style="color:${xfers > 0 ? '#ff9800' : '#444'};">${xfers}</span>
|
||||
<span style="color:#444;">Test</span> <span style="color:#555;">${ups.selftest || '—'}</span>
|
||||
</div>`;
|
||||
} else {
|
||||
document.getElementById('vv-ups-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No UPS detected</p>';
|
||||
@@ -647,12 +782,16 @@ function vvPollMonitor() {
|
||||
}
|
||||
|
||||
const exitColor = par.exit_label === 'Completed' ? '#4caf50' : par.exit_label === 'Aborted' ? '#ff9800' : '#f44336';
|
||||
const errColor = (par.errors ?? 0) > 0 ? '#f44336' : '#555';
|
||||
const errColor = (par.errors ?? 0) > 0 ? '#f44336' : '#444';
|
||||
const speedStr = par.last_speed_mb ? ` · ${par.last_speed_mb} MB/s` : '';
|
||||
const nextDate = vvFmtDate(par.next_ts);
|
||||
const dueIn = vvDueIn(par.next_ts);
|
||||
const parBannerCls = !valid ? 'vv-banner-err' : (par.errors ?? 0) > 0 ? 'vv-banner-warn' : 'vv-banner-ok';
|
||||
|
||||
let html = `<div style="font-size:13px;font-weight:600;color:${validColor};margin-bottom:10px;">${validLabel}</div>`;
|
||||
let html = `<div class="vv-banner ${parBannerCls}">
|
||||
<span>${valid ? '✓ Valid' : '✗ INVALID'}</span>
|
||||
${(par.errors ?? 0) > 0 ? `<span style="font-size:11px;">${par.errors} error${par.errors !== 1 ? 's' : ''}</span>` : ''}
|
||||
</div>`;
|
||||
|
||||
if (inProg) {
|
||||
const pct = par.resync_pct ?? 0;
|
||||
@@ -678,59 +817,170 @@ function vvPollMonitor() {
|
||||
document.getElementById('vv-parity-body').innerHTML = html;
|
||||
})();
|
||||
|
||||
// ── Storage helpers ──────────────────────────────────────────────────────
|
||||
function vvTempColor(tempC, transport) {
|
||||
if (tempC === null) return '#444';
|
||||
const isSsd = transport === 'nvme' || transport === 'ssd';
|
||||
const warn = isSsd ? vvThresholds.ssd_warn : vvThresholds.hdd_warn;
|
||||
const crit = isSsd ? vvThresholds.ssd_crit : vvThresholds.hdd_crit;
|
||||
return tempC >= crit ? '#f44336' : tempC >= warn ? '#ff9800' : '#4caf50';
|
||||
}
|
||||
function vvFmt(v) { return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB'; }
|
||||
// ── Watchdog ─────────────────────────────────────────────────────────────
|
||||
(function() {
|
||||
const wd = d.watchdog ?? {};
|
||||
const el = document.getElementById('vv-watchdog-body');
|
||||
if (!el) return;
|
||||
|
||||
function vvDiskRow(disk) {
|
||||
const tempC = disk.temp;
|
||||
const tempColor = vvTempColor(tempC, disk.transport);
|
||||
const tempStr = tempC !== null ? `${tempC}°` : '—';
|
||||
const isParity = disk.role === 'parity';
|
||||
const nameColor = isParity ? '#6a8faf' : '#aaa';
|
||||
const pct = disk.pct ?? 0;
|
||||
const barColor = isParity ? '#1e3a5a'
|
||||
: pct >= vvThresholds.util_crit ? '#f44336'
|
||||
: pct >= vvThresholds.util_warn ? '#ff9800'
|
||||
: '#4caf50';
|
||||
const barWidth = isParity ? '100' : pct;
|
||||
const spinLabel = (!isParity && !disk.mounted) ? `<span style="color:#555;font-size:9px;margin-left:4px;">↓</span>` : '';
|
||||
const right = isParity
|
||||
? `<span style="color:#444;font-size:10px;">${vvFmt(disk.size_gb)}</span>`
|
||||
: `<span style="color:#555;font-size:10px;">${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}</span>`;
|
||||
return `<div style="margin-bottom:7px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;">
|
||||
<span style="color:${nameColor};">${disk.name}${spinLabel}</span>
|
||||
${right}
|
||||
<span style="color:${tempColor};font-size:10px;margin-left:6px;flex-shrink:0;">${tempStr}</span>
|
||||
</div>
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
|
||||
<div style="width:${barWidth}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.4s;"></div>
|
||||
</div>
|
||||
const healthy = wd.healthy ?? true;
|
||||
const ctrStrikes = wd.ctr_strikes ?? {};
|
||||
const ctrNames = Object.keys(ctrStrikes);
|
||||
const rwLevel = wd.rw_level ?? 0;
|
||||
const rwPaused = wd.rw_paused ?? [];
|
||||
const rwStopped = wd.rw_stopped ?? [];
|
||||
const daemonHit = (wd.daemon_strikes ?? 0) > 0;
|
||||
const oom = wd.oom_count ?? 0;
|
||||
const reboots = wd.reboots_12h ?? 0;
|
||||
const restartCount = wd.restart_count ?? 0;
|
||||
const restarts = wd.restarts_24h ?? [];
|
||||
|
||||
const stab = wd.stability ?? {};
|
||||
const stabStrikes = stab.strikes ?? {};
|
||||
const stabNames = Object.keys(stabStrikes);
|
||||
|
||||
const storWd = wd.storage_wd ?? {};
|
||||
const growthStr = storWd.growth_strikes ?? {};
|
||||
const logStr = storWd.log_strikes ?? {};
|
||||
const storIssues = Object.keys(growthStr).length + Object.keys(logStr).length;
|
||||
|
||||
const netWd = wd.network_wd ?? {};
|
||||
const npmStrikes = netWd.npm_strikes ?? 0;
|
||||
|
||||
const issueCount = ctrNames.length + stabNames.length + storIssues
|
||||
+ (daemonHit ? 1 : 0) + (oom > 0 ? 1 : 0)
|
||||
+ (reboots > 0 ? 1 : 0) + (npmStrikes > 0 ? 1 : 0);
|
||||
const bannerCls = healthy ? 'vv-banner-ok' : (reboots || oom || daemonHit ? 'vv-banner-err' : 'vv-banner-warn');
|
||||
const bannerTxt = healthy ? '✓ All clear' : `⚠ ${issueCount} issue${issueCount !== 1 ? 's' : ''}`;
|
||||
|
||||
let html = `<div class="vv-banner ${bannerCls}">${bannerTxt}`;
|
||||
if (reboots > 0) html += `<span style="font-size:10px;">${reboots} reboot${reboots !== 1 ? 's' : ''}/12h</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
// ── Alerts (critical items) ──────────────────────────────────────────
|
||||
if (rwLevel > 0) {
|
||||
const rwColor = rwLevel >= 3 ? '#f44336' : rwLevel >= 2 ? '#ff9800' : '#fdd835';
|
||||
const rwLabel = ['', 'Soft', 'Medium', 'Hard'][rwLevel] ?? `L${rwLevel}`;
|
||||
html += `<div style="font-size:11px;color:${rwColor};margin-bottom:4px;">⚡ Resource mgr: ${rwLabel}`;
|
||||
if (rwPaused.length) html += ` · ${rwPaused.length} paused`;
|
||||
if (rwStopped.length) html += ` · ${rwStopped.length} stopped`;
|
||||
html += `</div>`;
|
||||
}
|
||||
if (daemonHit) html += `<div style="font-size:11px;color:#f44336;margin-bottom:4px;">✗ Docker daemon strikes</div>`;
|
||||
if (oom > 0) html += `<div style="font-size:11px;color:#f44336;margin-bottom:4px;">✗ OOM events: ${oom}</div>`;
|
||||
|
||||
// ── System stats grid ────────────────────────────────────────────────
|
||||
function wdPct(v, warn, crit) {
|
||||
return v >= crit ? '#f44336' : v >= warn ? '#ff9800' : '#4caf50';
|
||||
}
|
||||
const ramFree = stab.ram_free_gb ?? 0;
|
||||
const ramColor = ramFree < 6 ? '#f44336' : ramFree < 12 ? '#ff9800' : '#4caf50';
|
||||
const load = stab.load_1min ?? 0;
|
||||
const loadColor = load > 6 ? '#f44336' : load > 3 ? '#ff9800' : '#4caf50';
|
||||
const nicOk = (stab.nic_state ?? '') === 'up';
|
||||
const sshdOk = stab.sshd_ok ?? true;
|
||||
const zombies = stab.zombies ?? 0;
|
||||
|
||||
let statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 8px;font-size:11px;margin-top:8px;margin-bottom:6px;">
|
||||
<span style="color:#444;">rootfs</span><span style="color:${wdPct(stab.rootfs_pct??0,75,90)};">${stab.rootfs_pct??0}%</span>
|
||||
<span style="color:#444;">/var/log</span><span style="color:${wdPct(stab.log_pct??0,75,90)};">${stab.log_pct??0}%</span>
|
||||
<span style="color:#444;">/tmp</span><span style="color:${wdPct(stab.tmp_pct??0,75,90)};">${stab.tmp_pct??0}%</span>
|
||||
<span style="color:#444;">RAM free</span><span style="color:${ramColor};">${ramFree}GB</span>
|
||||
<span style="color:#444;">Load</span><span style="color:${loadColor};">${load}</span>
|
||||
${stab.cpu_temp != null ? `<span style="color:#444;">CPU</span><span style="color:${wdPct(stab.cpu_temp,75,90)};">${stab.cpu_temp}°C</span>` : ''}
|
||||
<span style="color:#444;">Zombies</span><span style="color:${zombies>0?'#ff9800':'#4caf50'};">${zombies}</span>
|
||||
<span style="color:#444;">${stab.nic??'nic'}</span><span style="color:${nicOk?'#4caf50':'#f44336'};">● ${stab.nic_state??'?'}</span>
|
||||
<span style="color:#444;">sshd</span><span style="color:${sshdOk?'#4caf50':'#f44336'};">${sshdOk?'● ok':'✗ down'}</span>
|
||||
<span style="color:#444;">NPM</span><span style="color:${npmStrikes>0?'#ff9800':'#4caf50'};">${npmStrikes>0?npmStrikes+'× strikes':'● ok'}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += statsHtml;
|
||||
|
||||
function vvDiskCol(disks) {
|
||||
return `<div style="flex:1;min-width:0;">${disks.map(vvDiskRow).join('')}</div>`;
|
||||
}
|
||||
// ── Stability strikes (system watchdog) ──────────────────────────────
|
||||
if (stabNames.length) {
|
||||
html += `<div style="font-size:10px;color:#555;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Stability strikes</div>`;
|
||||
stabNames.forEach(k => {
|
||||
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
|
||||
<span style="color:#ff9800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${k}</span>
|
||||
<span style="color:#555;flex-shrink:0;margin-left:6px;">${stabStrikes[k]}×</span>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pools — single column ─────────────────────────────────────────────────
|
||||
const storageDisks = d.storage ?? [];
|
||||
if (storageDisks.length) {
|
||||
document.getElementById('vv-storage-body').innerHTML = storageDisks.map(vvDiskRow).join('');
|
||||
} else {
|
||||
document.getElementById('vv-storage-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No pools found</p>';
|
||||
}
|
||||
// ── Storage watchdog strikes ─────────────────────────────────────────
|
||||
if (storIssues > 0) {
|
||||
html += `<div style="font-size:10px;color:#555;margin-top:${stabNames.length?6:0}px;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Storage watchdog</div>`;
|
||||
Object.entries(growthStr).forEach(([k, v]) => {
|
||||
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
|
||||
<span style="color:#ff9800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">↑ ${k}</span>
|
||||
<span style="color:#555;flex-shrink:0;margin-left:6px;">${v}×</span>
|
||||
</div>`;
|
||||
});
|
||||
Object.entries(logStr).forEach(([k, v]) => {
|
||||
const shortKey = k.length > 22 ? '…' + k.slice(-22) : k;
|
||||
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
|
||||
<span style="color:#ff9800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;" title="${k}">log ${shortKey}</span>
|
||||
<span style="color:#555;flex-shrink:0;margin-left:6px;">${v}×</span>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Array disks — min 3 columns, balanced left-to-right, grows as needed ───
|
||||
// ── Container strikes ────────────────────────────────────────────────
|
||||
if (ctrNames.length) {
|
||||
html += `<div style="font-size:10px;color:#555;margin-top:${stabNames.length||storIssues?6:0}px;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Container strikes</div>`;
|
||||
ctrNames.forEach(name => {
|
||||
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
|
||||
<span style="color:#f44336;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${name}</span>
|
||||
<span style="color:#555;flex-shrink:0;margin-left:6px;">${ctrStrikes[name]}×</span>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Recent restarts ──────────────────────────────────────────────────
|
||||
if (restartCount > 0) {
|
||||
const hasAbove = ctrNames.length || stabNames.length || storIssues;
|
||||
html += `<div style="font-size:10px;color:#555;margin-top:${hasAbove?6:0}px;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Restarts 24h <span style="color:#ff9800;">${restartCount}</span></div>`;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
restarts.forEach(r => {
|
||||
const diff = now - r.ts;
|
||||
const ago = diff < 3600 ? Math.floor(diff / 60) + 'm' : Math.floor(diff / 3600) + 'h';
|
||||
html += `<div style="display:flex;justify-content:space-between;font-size:10px;color:#666;margin-bottom:2px;">
|
||||
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${r.name}</span>
|
||||
<span style="flex-shrink:0;margin-left:6px;color:#444;">${ago}</span>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
})();
|
||||
|
||||
|
||||
// ── Pools ─────────────────────────────────────────────────────────────────
|
||||
vvLastStorageDisks = d.storage ?? [];
|
||||
document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks);
|
||||
|
||||
// ── Array disks — summary header + min 3 columns ─────────────────────────
|
||||
const arrayDisks = d.array_disks ?? [];
|
||||
if (arrayDisks.length) {
|
||||
const dataDisks = arrayDisks.filter(d => d.role === 'data');
|
||||
const okDisks = dataDisks.filter(d => d.status === 'DISK_OK').length;
|
||||
const errDisks = dataDisks.length - okDisks;
|
||||
const temps = arrayDisks.map(d => d.temp).filter(t => t != null);
|
||||
const maxTemp = temps.length ? Math.max(...temps) : null;
|
||||
const maxTempColor = maxTemp == null ? '#444' : maxTemp >= (vvThresholds.hdd_crit ?? 55) ? '#f44336' : maxTemp >= (vvThresholds.hdd_warn ?? 45) ? '#ff9800' : '#4caf50';
|
||||
const totalUsedGb = dataDisks.reduce((s, d) => s + (d.used_gb ?? 0), 0);
|
||||
const totalSizeGb = dataDisks.reduce((s, d) => s + (d.size_gb ?? 0), 0);
|
||||
const arrPct = totalSizeGb > 0 ? Math.round(totalUsedGb / totalSizeGb * 100) : 0;
|
||||
const arrPctColor = arrPct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : arrPct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50';
|
||||
|
||||
const titleEl = document.getElementById('vv-array-title');
|
||||
if (titleEl) titleEl.innerHTML = `Array
|
||||
<span style="font-size:10px;color:#444;font-weight:400;text-transform:none;letter-spacing:0;margin-left:6px;">
|
||||
${okDisks}/${dataDisks.length} ok
|
||||
${errDisks > 0 ? `<span style="color:#f44336;margin-left:4px;">· ${errDisks} err</span>` : ''}
|
||||
· <span style="color:${arrPctColor};">${arrPct}%</span> used
|
||||
${maxTemp != null ? `· <span style="color:${maxTempColor};">max ${maxTemp}°</span>` : ''}
|
||||
</span>`;
|
||||
|
||||
const numCols = Math.max(3, Math.ceil(arrayDisks.length / 6));
|
||||
const perCol = Math.ceil(arrayDisks.length / numCols);
|
||||
const cols = [];
|
||||
@@ -1013,6 +1263,126 @@ vvPollStreams();
|
||||
setInterval(vvPollStreams, 12000);
|
||||
setInterval(vvRenderStreams, 1000);
|
||||
|
||||
// ── Pools card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function vvTogglePool(name) {
|
||||
vvPoolsOpen[name] = !vvPoolsOpen[name];
|
||||
document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks);
|
||||
}
|
||||
|
||||
function vvTogglePoolGroup(groupName) {
|
||||
vvPoolGroupOpen[groupName] = !vvPoolGroupOpen[groupName];
|
||||
document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks);
|
||||
}
|
||||
|
||||
function vvRenderPools(disks) {
|
||||
if (!disks.length) return '<p style="color:#555;font-style:italic;font-size:12px;">No pools found</p>';
|
||||
|
||||
// Group drives by pool name
|
||||
const poolMap = {};
|
||||
disks.forEach(d => {
|
||||
const n = d.name;
|
||||
if (!poolMap[n]) poolMap[n] = [];
|
||||
poolMap[n].push(d);
|
||||
});
|
||||
|
||||
// Build super-groups: pools whose names share a common prefix + trailing digits
|
||||
const superGroupMap = {}; // groupName -> [poolName, ...]
|
||||
Object.keys(poolMap).forEach(poolName => {
|
||||
const match = poolName.match(/^(.+?)(\d+)$/);
|
||||
const gName = match ? match[1] : poolName;
|
||||
if (!superGroupMap[gName]) superGroupMap[gName] = [];
|
||||
superGroupMap[gName].push(poolName);
|
||||
});
|
||||
|
||||
// Render a single pool row (with optional drive-expand toggle)
|
||||
function renderPoolRow(poolName, drives) {
|
||||
const isOpen = !!vvPoolsOpen[poolName];
|
||||
const multi = drives.length > 1;
|
||||
const totalGb = drives.reduce((s, d) => s + (d.size_gb ?? 0), 0);
|
||||
const usedGb = drives.reduce((s, d) => s + (d.used_gb ?? 0), 0);
|
||||
const pct = totalGb > 0 ? Math.round(usedGb / totalGb * 100) : 0;
|
||||
const pctColor = pct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : pct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50';
|
||||
const temps = drives.map(d => d.temp).filter(t => t != null);
|
||||
const maxTemp = temps.length ? Math.max(...temps) : null;
|
||||
const tempColor = maxTemp != null ? vvTempColor(maxTemp, drives[0].transport) : '#444';
|
||||
const allOk = drives.every(d => d.status === 'DISK_OK' || !d.status);
|
||||
const statusColor = allOk ? '#aaa' : '#f44336';
|
||||
const sName = poolName.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||||
|
||||
let html = `<div style="margin-bottom:8px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;
|
||||
${multi ? 'cursor:pointer;' : ''}" ${multi ? `onclick="vvTogglePool('${sName}')"` : ''}>
|
||||
<span style="color:${statusColor};display:flex;align-items:center;gap:5px;">
|
||||
${multi ? `<span style="color:#555;font-size:9px;width:8px;">${isOpen ? '▾' : '▸'}</span>` : '<span style="width:8px;display:inline-block;"></span>'}
|
||||
${poolName}
|
||||
${multi ? `<span style="font-size:9px;color:#444;background:#1a1a1a;padding:0 4px;border-radius:2px;">${drives.length} drives</span>` : ''}
|
||||
</span>
|
||||
<span style="display:flex;align-items:center;gap:8px;">
|
||||
${maxTemp != null ? `<span style="color:${tempColor};font-size:10px;">${maxTemp}°</span>` : ''}
|
||||
<span style="color:#555;font-size:10px;">${vvFmt(usedGb)} / ${vvFmt(totalGb)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
|
||||
<div style="width:${pct}%;height:100%;background:${pctColor};border-radius:3px;transition:width 0.4s;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (multi && isOpen) {
|
||||
html += `<div style="padding-left:12px;border-left:1px solid #252525;margin-bottom:8px;margin-top:-4px;">`;
|
||||
drives.forEach(d => { html += vvDiskRow({ ...d, name: d.device || d.name }); });
|
||||
html += `</div>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
Object.entries(superGroupMap).forEach(([groupName, poolNames]) => {
|
||||
if (poolNames.length === 1) {
|
||||
// Single pool — render directly, no super-group header
|
||||
html += renderPoolRow(poolNames[0], poolMap[poolNames[0]]);
|
||||
} else {
|
||||
// Super-group: aggregate header + expandable list of individual pools
|
||||
const isGroupOpen = !!vvPoolGroupOpen[groupName];
|
||||
const sGroup = groupName.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||||
const allDrives = poolNames.flatMap(n => poolMap[n]);
|
||||
const totalGb = allDrives.reduce((s, d) => s + (d.size_gb ?? 0), 0);
|
||||
const usedGb = allDrives.reduce((s, d) => s + (d.used_gb ?? 0), 0);
|
||||
const pct = totalGb > 0 ? Math.round(usedGb / totalGb * 100) : 0;
|
||||
const pctColor = pct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : pct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50';
|
||||
const temps = allDrives.map(d => d.temp).filter(t => t != null);
|
||||
const maxTemp = temps.length ? Math.max(...temps) : null;
|
||||
const tempColor = maxTemp != null ? vvTempColor(maxTemp, allDrives[0].transport) : '#444';
|
||||
|
||||
html += `<div style="margin-bottom:${isGroupOpen ? '2' : '8'}px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;cursor:pointer;"
|
||||
onclick="vvTogglePoolGroup('${sGroup}')">
|
||||
<span style="color:#aaa;display:flex;align-items:center;gap:5px;">
|
||||
<span style="color:#555;font-size:9px;width:8px;">${isGroupOpen ? '▾' : '▸'}</span>
|
||||
${groupName}
|
||||
<span style="font-size:9px;color:#444;background:#1a1a1a;padding:0 4px;border-radius:2px;">${poolNames.length} pools</span>
|
||||
</span>
|
||||
<span style="display:flex;align-items:center;gap:8px;">
|
||||
${maxTemp != null ? `<span style="color:${tempColor};font-size:10px;">${maxTemp}°</span>` : ''}
|
||||
<span style="color:#555;font-size:10px;">${vvFmt(usedGb)} / ${vvFmt(totalGb)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
|
||||
<div style="width:${pct}%;height:100%;background:${pctColor};border-radius:3px;transition:width 0.4s;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (isGroupOpen) {
|
||||
html += `<div style="padding-left:12px;border-left:1px solid #252525;margin-bottom:8px;margin-top:2px;">`;
|
||||
poolNames.forEach(poolName => { html += renderPoolRow(poolName, poolMap[poolName]); });
|
||||
html += `</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── System clock tick (updates time every 30s without a full poll) ────────────
|
||||
function vvTickClock() {
|
||||
const el = document.getElementById('vv-system-body');
|
||||
@@ -1061,6 +1431,13 @@ function vvRenderDockerFolders(data) {
|
||||
const el = document.getElementById('vv-docker-folders-body');
|
||||
if (!el || !data) return;
|
||||
|
||||
// Update container count badge in header
|
||||
const allCtrs = [...(data.folders ?? []).flatMap(f => f.containers), ...(data.ungrouped ?? [])];
|
||||
const totalCtrs = allCtrs.length;
|
||||
const runCtrs = allCtrs.filter(c => c.running).length;
|
||||
const countEl = document.getElementById('vv-docker-count');
|
||||
if (countEl && totalCtrs > 0) countEl.textContent = `${runCtrs}/${totalCtrs}`;
|
||||
|
||||
const osIcon = os => ({ windows:'🪟', macos:'🍎', bsd:'🦬' })[os] ?? '🐧';
|
||||
const stateColor = s => ({ running:'#4caf50', paused:'#ff9800' })[s] ?? '#444';
|
||||
const stateLabel = s => ({ running:'Running', paused:'Paused', 'shut off':'Off', crashed:'Crashed' })[s] ?? s;
|
||||
@@ -1167,17 +1544,18 @@ function vvRenderDockerFolders(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2-column balanced split — single column on narrow screens
|
||||
if (window.innerWidth <= 640) {
|
||||
html += `<div class="vv-df-col" style="min-width:0;">${allFolders.map(renderFolder).join('')}</div>`;
|
||||
// Column count: 3 big / 2 intermediate / 1 small
|
||||
const _w = window.innerWidth;
|
||||
const _cols = _w > 1400 ? 3 : _w > 640 ? 2 : 1;
|
||||
|
||||
if (_cols === 1) {
|
||||
html += `<div class="vv-df-col">${allFolders.map(renderFolder).join('')}</div>`;
|
||||
} else {
|
||||
const half = Math.ceil(allFolders.length / 2);
|
||||
const left = allFolders.slice(0, half);
|
||||
const right = allFolders.slice(half);
|
||||
html += `<div class="vv-df-cols">
|
||||
<div class="vv-df-col">${left.map(renderFolder).join('')}</div>
|
||||
<div class="vv-df-col">${right.map(renderFolder).join('')}</div>
|
||||
</div>`;
|
||||
const perCol = Math.ceil(allFolders.length / _cols);
|
||||
const colDivs = Array.from({length: _cols}, (_, i) =>
|
||||
`<div class="vv-df-col">${allFolders.slice(i * perCol, (i + 1) * perCol).map(renderFolder).join('')}</div>`
|
||||
).join('');
|
||||
html += `<div class="vv-df-cols">${colDivs}</div>`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
require_once dirname(__DIR__) . '/include/scheduler.php';
|
||||
$tree = vv_job_tree();
|
||||
$customs = vv_custom_scripts();
|
||||
$tools = vv_tools_scripts();
|
||||
$_library = vv_script_library();
|
||||
$_folders = vv_folders_load();
|
||||
|
||||
@@ -195,6 +196,54 @@ $runningScripts = array_unique($runningScripts);
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Tools container -->
|
||||
<div class="vv-card vv-wide vv-sched-card vv-tools-card">
|
||||
<div class="vv-job-row">
|
||||
<span class="vv-job-label" style="font-weight:bold;">Tools</span>
|
||||
<span class="vv-custom-count"><?= count($tools) ?> tool<?= count($tools) !== 1 ? 's' : '' ?></span>
|
||||
<button class="vv-advanced-toggle" style="margin-left:auto;width:100px" onclick="vvToggleTools(this)">Tools ▸</button>
|
||||
</div>
|
||||
<div class="vv-children" id="vv-tools-children" style="display:none;">
|
||||
<?php if (empty($tools)): ?>
|
||||
<p class="vv-custom-empty">No scripts found in Tools/.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($tools as $ts): $tsid = htmlspecialchars($ts['id']); ?>
|
||||
<div class="vv-script" data-id="<?= $tsid ?>">
|
||||
<div class="vv-job-row">
|
||||
<label class="vv-toggle" title="Enable/disable cron">
|
||||
<input type="checkbox" class="vv-enabled"
|
||||
<?= $ts['enabled'] ? 'checked' : '' ?>
|
||||
onchange="vvSaveJob(this)">
|
||||
<span class="vv-slider"></span>
|
||||
</label>
|
||||
<span class="vv-job-label"><?= htmlspecialchars($ts['label']) ?></span>
|
||||
<input type="text" class="vv-cron" value="<?= htmlspecialchars($ts['cron']) ?>"
|
||||
placeholder="cron expression"
|
||||
onblur="vvSaveCronBlur(this)">
|
||||
<span class="vv-save-check"></span>
|
||||
</div>
|
||||
<?php if (!empty($ts['desc'])): ?>
|
||||
<div class="vv-job-desc" title="<?= htmlspecialchars($ts['desc']) ?>"><?= htmlspecialchars($ts['desc']) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="vv-job-actions">
|
||||
<button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">▶ Run</button>
|
||||
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">▶ Dry Run</button>
|
||||
<?php $tsLog = vv_job_log_path($ts['id']); ?>
|
||||
<button class="vv-btn-sm vv-log-btn<?= (file_exists($tsLog) && filesize($tsLog) > 0) ? ' vv-has-log' : '' ?>" onclick="vvSelectLog(this)">Log</button>
|
||||
<label class="vv-log-label" title="Enable verbose --log output">
|
||||
<input type="checkbox" class="vv-log-enabled"
|
||||
<?= $ts['log_enabled'] ? 'checked' : '' ?>
|
||||
onchange="vvSaveJob(this)">
|
||||
<span>Verbose</span>
|
||||
</label>
|
||||
<span class="vv-job-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Scripts container -->
|
||||
<?php
|
||||
// Build set of IDs that belong to a folder
|
||||
@@ -1406,6 +1455,14 @@ function vvToggleAdvanced(btn) {
|
||||
btn.textContent = visible ? 'Advanced ▸' : 'Advanced ▾';
|
||||
}
|
||||
|
||||
function vvToggleTools(btn) {
|
||||
const card = btn.closest('.vv-tools-card');
|
||||
const children = card.querySelector('.vv-children');
|
||||
const visible = children.style.display !== 'none';
|
||||
children.style.display = visible ? 'none' : 'block';
|
||||
btn.textContent = visible ? 'Tools ▸' : 'Tools ▾';
|
||||
}
|
||||
|
||||
function vvToggleCustom(btn) {
|
||||
const card = btn.closest('.vv-custom-card');
|
||||
const children = card.querySelector('.vv-children');
|
||||
@@ -1818,8 +1875,8 @@ function vvBuildNextRuns() {
|
||||
const next = vvCronNext(cron);
|
||||
if (next) rows.push({ label, cron, next, diffMs: next - now });
|
||||
});
|
||||
// Custom scripts with their own cron
|
||||
document.querySelectorAll('.vv-custom-card .vv-script').forEach(script => {
|
||||
// Tools and custom scripts with their own cron
|
||||
document.querySelectorAll('.vv-tools-card .vv-script, .vv-custom-card .vv-script').forEach(script => {
|
||||
const enabled = script.querySelector('.vv-enabled')?.checked;
|
||||
if (!enabled) return;
|
||||
const cronEl = script.querySelector('input.vv-cron');
|
||||
|
||||
@@ -28,6 +28,17 @@
|
||||
.vv-wd-ctr-name{ font-size:11px;color:#888; }
|
||||
.vv-wd-ctr-lim { font-size:11px;color:#555; }
|
||||
.vv-wd-pressure{ grid-column:1/-1;border-color:#3a2000;background:#1a1000; }
|
||||
|
||||
/* One host per row — inner grid sizes all cards equally */
|
||||
.vv-wd-host-row {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.vv-wd-host-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
|
||||
@@ -113,7 +124,7 @@ function _pressureCard(node) {
|
||||
function _systemCard(node, cfg) {
|
||||
const sys = node.system;
|
||||
if (!sys) {
|
||||
return `<div class="vv-wd-card" style="grid-column:span 2;">
|
||||
return `<div class="vv-wd-card">
|
||||
<div class="vv-wd-node-h">
|
||||
<span class="vv-wd-dot" style="background:#444"></span>
|
||||
<span class="vv-wd-node-id">${node.id}</span>
|
||||
@@ -136,18 +147,24 @@ function _systemCard(node, cfg) {
|
||||
: sys.load1 > sys.cores * cfg.rw_load_soft ? '#ffb74d'
|
||||
: '#4caf50';
|
||||
|
||||
const daemonDot = sys.daemon_ok ? '#4caf50' : '#ef5350';
|
||||
const apiOnly = sys.api_only === true;
|
||||
const daemonDot = sys.daemon_ok === null ? '#555' : sys.daemon_ok ? '#4caf50' : '#ef5350';
|
||||
const daemonTxt = sys.daemon_ok === null ? '—' : sys.daemon_ok ? 'daemon ok' : 'daemon err';
|
||||
|
||||
const st = node.states || {};
|
||||
const level = st.rw_level || 0;
|
||||
const dotCol = level >= 3 ? '#ef5350' : level >= 2 ? '#ffb74d' : level >= 1 ? '#cddc39' : '#4caf50';
|
||||
const dotCol = level >= 3 ? '#ef5350' : level >= 2 ? '#ffb74d' : level >= 1 ? '#cddc39'
|
||||
: apiOnly ? '#4a7a9b' // blue-grey: API-only, no watchdog state
|
||||
: '#4caf50';
|
||||
|
||||
return `<div class="vv-wd-card" style="grid-column:span 2;">
|
||||
return `<div class="vv-wd-card">
|
||||
<div class="vv-wd-node-h">
|
||||
<span class="vv-wd-dot" style="background:${dotCol}"></span>
|
||||
<span class="vv-wd-node-id">${node.id}</span>
|
||||
<span style="font-size:11px;color:#3a3a3a;">${node.hostname}</span>
|
||||
<span class="vv-wd-badge ${_levelCls(level)}" style="margin-left:auto;">${_levelLabel(level)}</span>
|
||||
<span class="vv-wd-badge ${apiOnly ? '' : _levelCls(level)}"
|
||||
style="margin-left:auto;${apiOnly ? 'background:#0d1f2a;color:#4a9eff;' : ''}"
|
||||
>${apiOnly ? 'API ONLY' : _levelLabel(level)}</span>
|
||||
</div>
|
||||
<div class="vv-wd-sec">System</div>
|
||||
${_row('RAM free', `<span style="color:${memCol}">${_fmtBytes(sys.mem_avail)}</span> / ${_fmtBytes(sys.mem_total)}`)}
|
||||
@@ -157,19 +174,20 @@ function _systemCard(node, cfg) {
|
||||
<span>${cfg.rw_soft_gb}G soft · ${cfg.rw_hard_gb}G hard · ${cfg.sys_mem_gb}G reboot</span>
|
||||
</div>
|
||||
<div style="height:5px"></div>
|
||||
${_row('Load avg', `<span style="color:${loadCol}">${sys.load1.toFixed(2)}</span> / ${sys.cores} cores`)}
|
||||
${!apiOnly ? `${_row('Load avg', `<span style="color:${loadCol}">${sys.load1.toFixed(2)}</span> / ${sys.cores} cores`)}
|
||||
${_bar(loadPct, loadCol)}
|
||||
<div style="height:5px"></div>
|
||||
${_row('Uptime', _dur(sys.uptime))}
|
||||
${_row('Docker', `<span style="color:${daemonDot}">${sys.daemon_ok ? 'daemon ok' : 'daemon err'}</span>`)}
|
||||
${sys.oom_count > 0 ? _row('OOM kills', `<span style="color:#ef5350">${sys.oom_count}</span>`) : _row('OOM kills', '<span style="color:#333">0</span>')}
|
||||
<div style="height:5px"></div>` : ''}
|
||||
${_row('Uptime', sys.uptime ? _dur(sys.uptime) : '—')}
|
||||
${_row('Docker', `<span style="color:${daemonDot}">${daemonTxt}</span>`)}
|
||||
${!apiOnly ? (sys.oom_count > 0 ? _row('OOM kills', `<span style="color:#ef5350">${sys.oom_count}</span>`) : _row('OOM kills', '<span style="color:#333">0</span>')) : ''}
|
||||
${apiOnly ? `<div style="font-size:10px;color:#333;margin-top:6px;">Watchdog state unavailable — SSH not configured</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Docker watchdog state card ────────────────────────────────────────────────
|
||||
function _dockerCard(node, cfg) {
|
||||
const st = node.states;
|
||||
if (!st) return `<div class="vv-wd-card" style="grid-column:span 2;"><div class="vv-wd-sec">Docker Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
|
||||
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Docker Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
|
||||
|
||||
const strikes = Object.entries(st.ctr_strikes || {});
|
||||
const skiplist = st.skiplist || [];
|
||||
@@ -212,7 +230,7 @@ function _dockerCard(node, cfg) {
|
||||
).join('');
|
||||
}
|
||||
|
||||
return `<div class="vv-wd-card" style="grid-column:span 3;">
|
||||
return `<div class="vv-wd-card">
|
||||
<div class="vv-wd-sec">Docker Watchdog</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:8px;">
|
||||
${_pill(st.daemon_restart ? 'daemon restarted' : 'daemon ok', daemonCls)}
|
||||
@@ -232,7 +250,7 @@ function _dockerCard(node, cfg) {
|
||||
// ── Stability / reboot card ───────────────────────────────────────────────────
|
||||
function _stabilityCard(node, cfg) {
|
||||
const st = node.states;
|
||||
if (!st) return `<div class="vv-wd-card" style="grid-column:span 1;"><div class="vv-wd-sec">Stability</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
|
||||
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Stability</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
|
||||
|
||||
const reboots = st.reboots || [];
|
||||
const sysStr = Object.entries(st.sys_strikes || {});
|
||||
@@ -251,7 +269,7 @@ function _stabilityCard(node, cfg) {
|
||||
? '<div style="color:#333;font-size:11px;">none (12h)</div>'
|
||||
: reboots.map(ts => `<div class="vv-wd-reboot-ts">${_relTime(ts)}</div>`).join('');
|
||||
|
||||
return `<div class="vv-wd-card" style="grid-column:span 3;">
|
||||
return `<div class="vv-wd-card">
|
||||
<div class="vv-wd-sec">Stability</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:8px;">
|
||||
${_pill(`${reboots.length} / ${cfg.reboot_limit} reboots`, rebootCls)}
|
||||
@@ -325,23 +343,138 @@ function _configCard(node) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Storage watchdog card ─────────────────────────────────────────────────────
|
||||
function _storageCard(node, cfg) {
|
||||
const st = node.states;
|
||||
const nodeCfg = node.config || {};
|
||||
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Storage Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
|
||||
|
||||
const storWd = st.storage_wd || {};
|
||||
const growthStr = Object.entries(storWd.growth_strikes || {});
|
||||
const logStr = Object.entries(storWd.log_strikes || {});
|
||||
const totalIssues = growthStr.length + logStr.length;
|
||||
const allClear = totalIssues === 0;
|
||||
|
||||
// Baseline info
|
||||
const bCount = storWd.baseline_count ?? 0;
|
||||
const bAge = storWd.baseline_age_sec;
|
||||
let baselineNote = bCount > 0
|
||||
? `${bCount} containers tracked`
|
||||
: 'no baseline yet (builds after first cycle)';
|
||||
if (bAge != null && bCount > 0) {
|
||||
const bAgeStr = bAge < 120 ? bAge + 's ago' : bAge < 3600 ? Math.floor(bAge/60) + 'm ago' : Math.floor(bAge/3600) + 'h ago';
|
||||
baselineNote += ` · updated ${bAgeStr}`;
|
||||
}
|
||||
|
||||
// Suppress ceilings configured for this host
|
||||
const sizes = Object.entries(nodeCfg.appdata_sizes || {});
|
||||
const sizesHtml = sizes.length
|
||||
? sizes.map(([c, mb]) => _pill(`${c} <${Math.round(mb/1024)}GB`, '')).join('')
|
||||
: '';
|
||||
|
||||
let growthHtml = '';
|
||||
if (growthStr.length === 0) {
|
||||
growthHtml = '<div style="color:#333;font-size:11px;">no active strikes</div>';
|
||||
} else {
|
||||
growthHtml = growthStr.map(([name, cnt]) =>
|
||||
`<div class="vv-wd-strike-row">
|
||||
<span class="vv-wd-strike-name">${name}</span>
|
||||
<span class="vv-wd-strike-cnt">${cnt} / ${cfg.stor_strike_lim}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
let logHtml = '';
|
||||
if (logStr.length === 0) {
|
||||
logHtml = '<div style="color:#333;font-size:11px;">no active strikes</div>';
|
||||
} else {
|
||||
logHtml = logStr.map(([key, cnt]) => {
|
||||
const display = key.length > 36 ? '…' + key.slice(-36) : key;
|
||||
return `<div class="vv-wd-strike-row">
|
||||
<span class="vv-wd-strike-name" style="font-size:10px;" title="${key}">${display}</span>
|
||||
<span class="vv-wd-strike-cnt">${cnt} / ${cfg.stor_strike_lim}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
return `<div class="vv-wd-card">
|
||||
<div class="vv-wd-sec">Storage Watchdog</div>
|
||||
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:6px;">
|
||||
${allClear ? _pill('all clear', 'ok') : _pill(totalIssues + ' active strike' + (totalIssues !== 1 ? 's' : ''), 'warn')}
|
||||
${_pill('growth >' + cfg.growth_gb + 'GB/cycle', '')}
|
||||
${_pill('log max ' + cfg.log_max_gb + 'GB', '')}
|
||||
${cfg.truncate_logs ? _pill('auto-truncate on', 'ok') : _pill('auto-truncate off', '')}
|
||||
</div>
|
||||
<div style="font-size:10px;color:#3a3a3a;margin-bottom:8px;">${baselineNote}</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div>
|
||||
<div class="vv-wd-sec">Growth strikes</div>
|
||||
${growthHtml}
|
||||
</div>
|
||||
<div>
|
||||
<div class="vv-wd-sec">Log size strikes</div>
|
||||
${logHtml}
|
||||
</div>
|
||||
</div>
|
||||
${sizes.length ? `<hr class="vv-wd-sep"><div class="vv-wd-sec">Suppress ceilings (this host)</div><div class="vv-wd-pill-row">${sizesHtml}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Network watchdog card ─────────────────────────────────────────────────────
|
||||
function _networkCard(node, cfg) {
|
||||
const st = node.states;
|
||||
const nodeCfg = node.config || {};
|
||||
if (!st) return `<div class="vv-wd-card"><div class="vv-wd-sec">Network Watchdog</div><div style="color:#3a3a3a;font-size:11px;padding:8px 0;">No data</div></div>`;
|
||||
|
||||
const netWd = st.network_wd || {};
|
||||
const npmStr = netWd.npm_strikes ?? 0;
|
||||
const npmCls = npmStr >= cfg.npm_strike_lim ? 'err' : npmStr > 0 ? 'warn' : 'ok';
|
||||
const ddnsDomain = nodeCfg.ddns_domain || '';
|
||||
const ddnsCtr = nodeCfg.ddns_container || '';
|
||||
const npmUrl = nodeCfg.npm_url || '';
|
||||
|
||||
const ddnsHtml = ddnsDomain
|
||||
? `${_row('DDNS domain', `<span style="color:#888;">${ddnsDomain}</span>`)}
|
||||
${ddnsCtr ? _row('DDNS container', `<span style="color:#888;">${ddnsCtr}</span>`) : ''}`
|
||||
: _row('DDNS', '<span style="color:#444;">not configured for this host</span>');
|
||||
|
||||
const npmHtml = npmUrl
|
||||
? `${_row('NPM URL', `<span style="color:#888;font-size:10px;">${npmUrl}</span>`)}
|
||||
${_row('NPM strikes', `<span class="vv-wd-pill ${npmCls}" style="font-size:10px;">${npmStr} / ${cfg.npm_strike_lim}</span>`)}`
|
||||
: _row('NPM check', '<span style="color:#444;">not configured for this host</span>');
|
||||
|
||||
return `<div class="vv-wd-card">
|
||||
<div class="vv-wd-sec">Network Watchdog</div>
|
||||
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px;">
|
||||
${cfg.net_wd_enabled ? _pill('enabled', 'ok') : _pill('disabled', '')}
|
||||
${_pill('Tailscale check ' + (cfg.ts_check ? 'on' : 'off'), cfg.ts_check ? '' : '')}
|
||||
${npmUrl ? _pill('NPM ' + npmStr + '/' + cfg.npm_strike_lim + ' strikes', npmCls) : ''}
|
||||
</div>
|
||||
${ddnsHtml}
|
||||
<hr class="vv-wd-sep">
|
||||
${npmHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Main render ───────────────────────────────────────────────────────────────
|
||||
function _render(data) {
|
||||
const nodes = data.nodes || [];
|
||||
const cfg = data.cfg || {};
|
||||
let html = '';
|
||||
|
||||
// Pressure alerts (full width, per node)
|
||||
for (const node of nodes) html += _pressureCard(node);
|
||||
|
||||
// System + watchdog state rows per node
|
||||
// Per-host: pressure alert (if active) then all 5 watchdog cards in one equal-spaced row
|
||||
for (const node of nodes) {
|
||||
html += _systemCard(node, cfg);
|
||||
html += _dockerCard(node, cfg);
|
||||
html += _stabilityCard(node, cfg);
|
||||
html += _pressureCard(node);
|
||||
html += `<div class="vv-wd-host-row">
|
||||
${_systemCard(node, cfg)}
|
||||
${_dockerCard(node, cfg)}
|
||||
${_stabilityCard(node, cfg)}
|
||||
${_storageCard(node, cfg)}
|
||||
${_networkCard(node, cfg)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Config inventory per node
|
||||
// Config inventory — separate row, each node span 4 (2 nodes = full row)
|
||||
for (const node of nodes) html += _configCard(node);
|
||||
|
||||
if (!html) html = '<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">No nodes configured.</div>';
|
||||
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Docker Prune Images ============================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Removes dangling Docker images — images no longer tagged or referenced by any
|
||||
# container. These accumulate after container updates pull a new image version,
|
||||
# leaving the old image behind with no tag.
|
||||
#
|
||||
# Safe to run at any time. Only removes images with no tag and no container
|
||||
# reference — running containers are never affected.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# docker_prune_images.sh
|
||||
# Show dangling images and remove them.
|
||||
#
|
||||
# docker_prune_images.sh --dry-run
|
||||
# Show what would be removed without removing anything.
|
||||
#
|
||||
# docker_prune_images.sh --status
|
||||
# Show current dangling images and disk usage. No changes.
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
source "$SCRIPT_DIR/../load_config.sh"
|
||||
|
||||
parse_args "$@"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
error "Must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
acquire_lock
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Status ━━━
|
||||
# ==============================================================================================
|
||||
if [[ "$SHOW_STATUS" == true ]]; then
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY DOCKER PRUNE IMAGES STATUS ━━━━━"
|
||||
DANGLING=$(docker images -f "dangling=true" --format "{{.ID}}\t{{.Size}}\t{{.CreatedSince}}" 2>/dev/null)
|
||||
if [[ -z "$DANGLING" ]]; then
|
||||
echo "$ICON_DONE No dangling images."
|
||||
else
|
||||
COUNT=$(echo "$DANGLING" | wc -l)
|
||||
echo "$ICON_CONTAINERS $COUNT dangling image(s):"
|
||||
echo "$DANGLING" | while IFS=$'\t' read -r id size age; do
|
||||
echo " $id $size created $age"
|
||||
done
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Run ━━━
|
||||
# ==============================================================================================
|
||||
echo "━━━ $ICON_CONTAINERS Docker Prune Images — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
||||
|
||||
DANGLING_IDS=$(docker images -f "dangling=true" -q 2>/dev/null)
|
||||
|
||||
if [[ -z "$DANGLING_IDS" ]]; then
|
||||
success "No dangling images — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
COUNT=$(echo "$DANGLING_IDS" | wc -l)
|
||||
log "Found $COUNT dangling image(s)"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would remove $COUNT dangling image(s):"
|
||||
docker images -f "dangling=true" --format " {{.ID}} {{.Size}} created {{.CreatedSince}}" 2>/dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OUTPUT=$(docker image prune -f 2>&1)
|
||||
echo "$OUTPUT"
|
||||
|
||||
RECLAIMED=$(echo "$OUTPUT" | grep -E "^Total reclaimed" || echo "")
|
||||
if [[ -n "$RECLAIMED" ]]; then
|
||||
success "Done — $RECLAIMED"
|
||||
else
|
||||
success "Done — $COUNT image(s) removed"
|
||||
fi
|
||||
Reference in New Issue
Block a user