From 760fb4a3e25c0a37a637e75e0b4999380c87b87f Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 24 May 2026 16:56:50 -0400 Subject: [PATCH] =?UTF-8?q?varaverk:=20monitor=20tab=20overhaul=20?= =?UTF-8?q?=E2=80=94=20grid=20layout,=20dynamic=20thresholds,=20new=20card?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout: - Switch from three flex rows to single CSS grid (repeat(8,1fr)) — eliminates gap-count discrepancy between rows; span 1/2/4/8 cards are now pixel-identical across all rows regardless of how many cards share the row New cards: - Scripts card: last-run status (ok/warn/running/unknown), clickable filter pills, 7-row scroll, flex:1 matching Power/Parity - Streams card: live per-second progress counters, color-coded play buttons (blue=live, orange=transcoding, green=direct, yellow=paused), server badges with counts, left-fill column layout (3 per col) - Transcode card: NVMe indicator, active session count + scrollable list, session rows with server icon + title + type + method Dynamic thresholds: - vv_disk_thresholds() reads /boot/config/plugins/dynamix/dynamix.cfg - Disk utilization bars: green/orange/red based on warning/critical settings - Temperature colors: hot/max for HDD, hotssd/maxssd for SSD/NVMe - Thresholds update every poll cycle; changing Unraid settings takes effect automatically Other: - SSD path detection uses findmnt FSTYPE to exclude tmpfs/ramfs (fixes RAM being reported as SSD transcode target) - varaverk.css h3: add white-space:normal + overflow:hidden to neutralise any Unraid global h3 styles that could force card width --- .../emhttp/plugins/varaverk/api/monitor.php | 6 + .../emhttp/plugins/varaverk/css/varaverk.css | 10 +- .../emhttp/plugins/varaverk/include/media.php | 46 +- .../plugins/varaverk/include/monitor.php | 251 ++++++- .../emhttp/plugins/varaverk/pages/monitor.php | 621 +++++++++++++++--- 5 files changed, 808 insertions(+), 126 deletions(-) diff --git a/Plugin/usr/local/emhttp/plugins/varaverk/api/monitor.php b/Plugin/usr/local/emhttp/plugins/varaverk/api/monitor.php index bb895b0..3c7afca 100644 --- a/Plugin/usr/local/emhttp/plugins/varaverk/api/monitor.php +++ b/Plugin/usr/local/emhttp/plugins/varaverk/api/monitor.php @@ -16,5 +16,11 @@ echo json_encode([ 'containers' => vv_docker_containers(), 'stopped' => vv_docker_stopped(), 'transcode' => vv_transcode_sessions(), + 'ups' => vv_ups_stats(), + 'parity' => vv_parity_status(), + 'storage' => vv_storage_pools(), + 'array_disks' => vv_array_disks(), + 'scripts' => vv_scripts_status(), + 'thresholds' => vv_disk_thresholds(), 'ts' => time(), ]); diff --git a/Plugin/usr/local/emhttp/plugins/varaverk/css/varaverk.css b/Plugin/usr/local/emhttp/plugins/varaverk/css/varaverk.css index 11ed20d..ccb4930 100644 --- a/Plugin/usr/local/emhttp/plugins/varaverk/css/varaverk.css +++ b/Plugin/usr/local/emhttp/plugins/varaverk/css/varaverk.css @@ -14,15 +14,17 @@ border-radius: 6px; padding: 12px; } .vv-wide { flex: 100%; } .vv-card h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; - color: #888; letter-spacing: 0.05em; } + color: #888; letter-spacing: 0.05em; white-space: normal; + overflow: hidden; min-width: 0; } /* System card — no h3, no top padding waste */ #vv-system { padding-top: 14px; } /* System action buttons */ -.vv-sys-btn { background: #2a2a2a; border: 1px solid #444; color: #888; border-radius: 4px; - padding: 3px 7px; font-size: 13px; cursor: pointer; line-height: 1; } -.vv-sys-btn:hover { background: #3a3a3a; color: #ccc; border-color: #666; } +.vv-sys-btn { background: #2a2a2a; border: 1px solid #e65100; color: #ff9800; border-radius: 3px; + padding: 3px 0; font-size: 6px; cursor: pointer; line-height: 1; + width: 50px; min-width: 0; text-align: center; } +.vv-sys-btn:hover { background: #3a2000; color: #ffb74d; border-color: #ff9800; } /* Fallback state badge */ .vv-state-badge { font-size: 20px; font-weight: bold; padding: 4px 0; margin-bottom: 2px; } diff --git a/Plugin/usr/local/emhttp/plugins/varaverk/include/media.php b/Plugin/usr/local/emhttp/plugins/varaverk/include/media.php index fdef0af..b32ae54 100644 --- a/Plugin/usr/local/emhttp/plugins/varaverk/include/media.php +++ b/Plugin/usr/local/emhttp/plugins/varaverk/include/media.php @@ -110,17 +110,18 @@ function vv_fetch_jf_sessions(array $srv): array { } $result[] = [ - 'server' => $srv['name'], - 'user' => $s['UserName'] ?? '?', - 'title' => $title, - 'type' => $type, - 'client' => trim(($s['Client'] ?? '') . ' / ' . ($s['DeviceName'] ?? ''), ' /'), - 'method' => $method, - 'paused' => !empty($ps['IsPaused']), - 'pct' => $pct, - 'pos_sec' => (int)($pos / 10000000), - 'dur_sec' => (int)($dur / 10000000), - 'is_tc' => $tc !== null, + 'server' => $srv['name'], + 'server_type' => $srv['type'], + 'user' => $s['UserName'] ?? '?', + 'title' => $title, + 'type' => $type, + 'client' => trim(($s['Client'] ?? '') . ' / ' . ($s['DeviceName'] ?? ''), ' /'), + 'method' => $method, + 'paused' => !empty($ps['IsPaused']), + 'pct' => $pct, + 'pos_sec' => (int)($pos / 10000000), + 'dur_sec' => (int)($dur / 10000000), + 'is_tc' => $tc !== null, ]; } return $result; @@ -160,17 +161,18 @@ function vv_fetch_plex_sessions(array $srv): array { $player = $m['Player'] ?? []; $result[] = [ - 'server' => $srv['name'], - 'user' => ($m['User']['title'] ?? '?'), - 'title' => $title, - 'type' => ucfirst($type), - 'client' => trim(($player['product'] ?? '') . ' / ' . ($player['title'] ?? ''), ' /'), - 'method' => $method, - 'paused' => ($player['state'] ?? '') === 'paused', - 'pct' => $pct, - 'pos_sec' => (int)($offset / 1000), - 'dur_sec' => (int)($dur / 1000), - 'is_tc' => $isTc, + 'server' => $srv['name'], + 'server_type' => $srv['type'], + 'user' => ($m['User']['title'] ?? '?'), + 'title' => $title, + 'type' => ucfirst($type), + 'client' => trim(($player['product'] ?? '') . ' / ' . ($player['title'] ?? ''), ' /'), + 'method' => $method, + 'paused' => ($player['state'] ?? '') === 'paused', + 'pct' => $pct, + 'pos_sec' => (int)($offset / 1000), + 'dur_sec' => (int)($dur / 1000), + 'is_tc' => $isTc, ]; } return $result; diff --git a/Plugin/usr/local/emhttp/plugins/varaverk/include/monitor.php b/Plugin/usr/local/emhttp/plugins/varaverk/include/monitor.php index ea6d531..98bdc02 100644 --- a/Plugin/usr/local/emhttp/plugins/varaverk/include/monitor.php +++ b/Plugin/usr/local/emhttp/plugins/varaverk/include/monitor.php @@ -423,13 +423,26 @@ function vv_transcode_sessions(): array { $ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp'; $ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []); - // SSD path: look for any other transcoding-temp sibling + // 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) { - if (!str_contains($p, 'ramdisk')) { $ssdPath = $p; break; } + $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); } - if ($ssdPath) $ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []); // Ramdisk disk usage $rd = vv_df('/mnt/ramdisk_transcodes'); @@ -444,5 +457,237 @@ function vv_transcode_sessions(): array { '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]); + } + } + 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 [ + '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)); +} + +function vv_scripts_status(): array { + $tmpBase = '/tmp/user.scripts/tmpScripts'; + $runDir = '/tmp/user.scripts/running'; + + $running = []; + foreach (glob($runDir . '/*') ?: [] as $f) { + $running[basename($f)] = true; + } + + $scripts = []; + foreach (glob($tmpBase . '/*/') ?: [] as $dir) { + $name = basename(rtrim($dir, '/')); + $logPath = $dir . 'log.txt'; + $ts = @filemtime($logPath); + if (!$ts) continue; + + $isRunning = isset($running[$name]); + + if ($isRunning) { + $status = 'running'; + } else { + $tail = vv_log_tail($logPath, 15); + $finished = stripos($tail, 'Script Finished') !== false; + if ($finished) { + $hasWarn = (bool)preg_match('/permission denied|command not found|no such file|: error[\s:]/i', $tail); + $status = $hasWarn ? 'warn' : 'ok'; + } else { + $status = 'unknown'; + } + } + + $scripts[] = ['name' => $name, 'last_ts' => $ts, 'status' => $status, 'running' => $isRunning]; + } + + usort($scripts, fn($a, $b) => ($b['last_ts'] ?? 0) <=> ($a['last_ts'] ?? 0)); + $scripts = array_slice($scripts, 0, 12); + + return [ + 'scripts' => $scripts, + 'running_count' => count(array_filter($scripts, fn($s) => $s['status'] === 'running')), + 'ok_count' => count(array_filter($scripts, fn($s) => $s['status'] === 'ok')), + 'warn_count' => count(array_filter($scripts, fn($s) => $s['status'] === 'warn')), ]; } diff --git a/Plugin/usr/local/emhttp/plugins/varaverk/pages/monitor.php b/Plugin/usr/local/emhttp/plugins/varaverk/pages/monitor.php index 0bf68e5..58e6993 100644 --- a/Plugin/usr/local/emhttp/plugins/varaverk/pages/monitor.php +++ b/Plugin/usr/local/emhttp/plugins/varaverk/pages/monitor.php @@ -1,57 +1,100 @@ -
+
-
-
+
Loading...
-
-

Partner & Fallback

+
+

+ Partner & Fallback + + + + + + + + + + + + + + + + + + + + +

Loading...
-
+

CPU

Loading...
-
+

Memory

Loading...
-
+

Network

Loading...
-
-
-
+
+

Power

+
Loading...
+
+ +
+

Parity

+
Loading...
+
+ +
+

Pools

+
Loading...
+
+ +
+

Array

+
Loading...
+
+ +
+

Scripts

+
Loading...
+
+ +

GPU

Loading...
-
-

Transcode

+
+

Transcode System

Loading...
-
+

Streams

Loading...
-
+

Containers

NameStatusImage
Loading...
-
@@ -59,7 +102,7 @@ // ── Shared helpers ──────────────────────────────────────────────────────────── function vvMeter(label, pct, text) { - const color = pct >= 85 ? '#f44336' : pct >= 65 ? '#ff9800' : '#4caf50'; + const color = `hsl(${Math.round(120 * (1 - pct / 100))},70%,45%)`; return `
${label}${text} @@ -73,7 +116,14 @@ function vvMeter(label, pct, text) { // ── Rolling history ─────────────────────────────────────────────────────────── const VV_HIST_MAX = 24; // 24 × 5 s = 120 s -let vvCpuHistory = []; +let vvCpuHistory = []; +let vvLastSessions = []; +let vvLastStreamPollAt = 0; +let vvLastStreamNames = []; +let vvStreamServerCount = 0; +let vvScriptsFilter = null; +let vvLastScripts = {}; +let vvThresholds = {util_warn:70,util_crit:90,hdd_warn:45,hdd_crit:55,ssd_warn:60,ssd_crit:70}; let vvNetRxHistory = []; let vvNetTxHistory = []; @@ -203,8 +253,9 @@ function vvRenderCpu(cpu) { if (cores.length) { html += `
`; cores.forEach(c => { - const color = vvCoreColor(c.freq_mhz, c.max_mhz, c.min_mhz); const usePct = c.usage_pct ?? 0; + const hue = Math.round(120 * (1 - usePct / 100)); + const color = `hsl(${hue},70%,45%)`; const barH = Math.max(2, Math.round(usePct * 0.46)); // max ~46px at 100% const label = c.freq_mhz ? (c.freq_mhz >= 1000 ? (c.freq_mhz/1000).toFixed(1)+'G' : c.freq_mhz+'M') : ''; html += `
@@ -219,9 +270,9 @@ function vvRenderCpu(cpu) { // Freq legend html += `
- Low freq - Mid - High freq + Low + Med + High
`; } @@ -282,6 +333,56 @@ function vvRenderMemory(mem) { return html; } +// ── Scripts render (filter-aware) ───────────────────────────────────────────── + +function vvRenderScripts() { + const sc = vvLastScripts; + const scList = sc.scripts ?? []; + const running = sc.running_count ?? 0; + const ok = sc.ok_count ?? 0; + const warn = sc.warn_count ?? 0; + const now = Math.floor(Date.now() / 1000); + + function vvScriptPill(type, count, color, bg, border, icon) { + const active = vvScriptsFilter === type; + return `${icon} ${count} ${type}`; + } + + let html = `
`; + if (running > 0) html += vvScriptPill('running', running, '#4fc3f7', '#0a2233', '#1e4060', '●'); + if (ok > 0) html += vvScriptPill('ok', ok, '#4caf50', '#0a1f0a', '#1a3a1a', '✓'); + if (warn > 0) html += vvScriptPill('warn', warn, '#ff9800', '#1f1200', '#3a2200', '!'); + if (!running && !ok && !warn) html += `No recent runs`; + html += `
`; + + const filtered = vvScriptsFilter ? scList.filter(s => s.status === vvScriptsFilter) : scList; + let listHtml = ''; + filtered.forEach(s => { + const diff = now - (s.last_ts ?? now); + const ago = diff < 60 ? diff + 's' : diff < 3600 ? Math.floor(diff / 60) + 'm' : diff < 86400 ? Math.floor(diff / 3600) + 'h' : Math.floor(diff / 86400) + 'd'; + const icon = s.status === 'running' ? '●' : s.status === 'ok' ? '✓' : s.status === 'warn' ? '!' : '?'; + const color = s.status === 'running' ? '#4fc3f7' : s.status === 'ok' ? '#4caf50' : s.status === 'warn' ? '#ff9800' : '#555'; + const name = s.name.length > 24 ? s.name.slice(0, 23) + '…' : s.name; + listHtml += `
+ ${icon} + ${name} + ${ago} +
`; + }); + + if (!filtered.length) listHtml = `

${vvScriptsFilter ? 'None in this group' : 'No script logs found'}

`; + html += `
${listHtml}
`; + const el = document.getElementById('vv-scripts-body'); + if (el) el.innerHTML = html; +} + +function vvScriptsFilterSet(type) { + vvScriptsFilter = vvScriptsFilter === type ? null : type; + vvRenderScripts(); +} + // ── Poll ────────────────────────────────────────────────────────────────────── function vvPollMonitor() { @@ -289,6 +390,9 @@ function vvPollMonitor() { .then(r => r.json()) .then(d => { + // ── Thresholds (from dynamix.cfg via backend) ──────────────────────────── + if (d.thresholds) vvThresholds = d.thresholds; + // ── System ────────────────────────────────────────────────────────────── const sys = d.system ?? {}; const now = new Date(); @@ -304,10 +408,10 @@ function vvPollMonitor() {
${sys.name}
${sys.comment}
-
- - - +
+ + +
${timeStr}
@@ -318,7 +422,40 @@ function vvPollMonitor() { Uptime ${sys.uptime} Array ${sys.array_state} Version ${ver} -
`; +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; // ── Partner & Fallback ─────────────────────────────────────────────────── const pt = d.partner ?? {}; @@ -435,6 +572,183 @@ function vvPollMonitor() { document.getElementById('vv-network-body').innerHTML = '

No network interface detected

'; } + // ── UPS / Power ───────────────────────────────────────────────────────── + const ups = d.ups ?? {}; + if (ups.available) { + const onBatt = ups.status === 'ONBATT'; + const statColor = onBatt ? '#f44336' : ups.status === 'ONLINE' ? '#4caf50' : '#ff9800'; + const loadPct = ups.load_pct ?? 0; + const loadHue = Math.round(120 * (1 - loadPct / 100)); + const bPct = ups.bcharge ?? 0; + const bHue = Math.round(120 * (bPct / 100)); + const bColor = onBatt ? '#f44336' : `hsl(${bHue},70%,45%)`; + const timeLeft = ups.timeleft != null ? ups.timeleft.toFixed(1) + ' min' : '—'; + const watts = ups.watts != null ? ups.watts + ' W' : '—'; + 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; + + document.getElementById('vv-ups-body').innerHTML = + `
+ ${ups.model} + ${ups.status} +
+
+
+ Load + ${loadPct.toFixed(1)}% · ${watts} +
+
+
+
+
+
+
+ Battery + ${bPct.toFixed(1)}% · ${timeLeft} +
+
+
+
+
+
+ Line in ${lineV} + Output ${outV} + Transfers ${xfers} +
`; + } else { + document.getElementById('vv-ups-body').innerHTML = '

No UPS detected

'; + } + + // ── Parity ────────────────────────────────────────────────────────────── + const par = d.parity ?? {}; + (function() { + const valid = par.valid !== false; + const inProg = par.in_progress; + const validColor = valid ? '#4caf50' : '#f44336'; + const validLabel = valid ? 'Parity is valid' : 'Parity is INVALID'; + + function vvRelTime(ts) { + if (!ts) return ''; + const diff = Math.floor(Date.now() / 1000) - ts; + const d = Math.floor(diff / 86400), h = Math.floor((diff % 86400) / 3600), m = Math.floor((diff % 3600) / 60); + const parts = []; + if (d) parts.push(d + ' day' + (d !== 1 ? 's' : '')); + if (h) parts.push(h + ' hour' + (h !== 1 ? 's' : '')); + if (!d && m) parts.push(m + ' minute' + (m !== 1 ? 's' : '')); + return parts.join(', ') + ' ago'; + } + function vvDueIn(ts) { + if (!ts) return '—'; + const diff = ts - Math.floor(Date.now() / 1000); + if (diff <= 0) return 'overdue'; + const d = Math.floor(diff / 86400), h = Math.floor((diff % 86400) / 3600), m = Math.floor((diff % 3600) / 60); + const parts = []; + if (d) parts.push(d + ' day' + (d !== 1 ? 's' : '')); + if (h) parts.push(h + ' hour' + (h !== 1 ? 's' : '')); + if (!d && m) parts.push(m + ' minute' + (m !== 1 ? 's' : '')); + return 'Due in: ' + parts.join(', '); + } + function vvFmtDate(ts) { + if (!ts) return '—'; + return new Date(ts * 1000).toLocaleString([], {weekday:'short',day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}); + } + + const exitColor = par.exit_label === 'Completed' ? '#4caf50' : par.exit_label === 'Aborted' ? '#ff9800' : '#f44336'; + const errColor = (par.errors ?? 0) > 0 ? '#f44336' : '#555'; + const speedStr = par.last_speed_mb ? ` · ${par.last_speed_mb} MB/s` : ''; + const nextDate = vvFmtDate(par.next_ts); + const dueIn = vvDueIn(par.next_ts); + + let html = `
${validLabel}
`; + + if (inProg) { + const pct = par.resync_pct ?? 0; + html += `
Check in progress — ${pct}%
+
+
+
`; + } + + html += ` +
+ Last check + ${par.last_date ?? '—'}${speedStr} + Status + ${par.exit_label ?? '—'} + Errors + ${par.errors ?? 0} + Next check + ${nextDate} +
+
${dueIn}
`; + + 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'; } + + function vvDiskRow(disk) { + const tempC = disk.temp; + const tempColor = vvTempColor(tempC, disk.transport); + const tempStr = tempC !== null ? `${tempC}°` : '—'; + const isParity = disk.role === 'parity'; + const nameColor = isParity ? '#6a8faf' : '#aaa'; + const pct = disk.pct ?? 0; + const barColor = isParity ? '#1e3a5a' + : pct >= vvThresholds.util_crit ? '#f44336' + : pct >= vvThresholds.util_warn ? '#ff9800' + : '#4caf50'; + const barWidth = isParity ? '100' : pct; + const spinLabel = (!isParity && !disk.mounted) ? `` : ''; + const right = isParity + ? `${vvFmt(disk.size_gb)}` + : `${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}`; + return `
+
+ ${disk.name}${spinLabel} + ${right} + ${tempStr} +
+
+
+
+
`; + } + + function vvDiskCol(disks) { + return `
${disks.map(vvDiskRow).join('')}
`; + } + + // ── Pools — single column ───────────────────────────────────────────────── + const storageDisks = d.storage ?? []; + if (storageDisks.length) { + document.getElementById('vv-storage-body').innerHTML = storageDisks.map(vvDiskRow).join(''); + } else { + document.getElementById('vv-storage-body').innerHTML = '

No pools found

'; + } + + // ── Array disks — min 3 columns, balanced left-to-right, grows as needed ─── + const arrayDisks = d.array_disks ?? []; + if (arrayDisks.length) { + const numCols = Math.max(3, Math.ceil(arrayDisks.length / 6)); + const perCol = Math.ceil(arrayDisks.length / numCols); + const cols = []; + for (let i = 0; i < numCols; i++) cols.push(arrayDisks.slice(i * perCol, (i + 1) * perCol)); + document.getElementById('vv-array-body').innerHTML = + `
${cols.map(vvDiskCol).join('')}
`; + } else { + document.getElementById('vv-array-body').innerHTML = '

No array disks

'; + } + // ── GPU ───────────────────────────────────────────────────────────────── const gpu = d.gpu ?? {}; const gpuProcs = d.gpu_procs ?? []; @@ -468,6 +782,10 @@ function vvPollMonitor() { document.getElementById('vv-gpu-body').innerHTML = '

No GPU detected

'; } + // ── Scripts ───────────────────────────────────────────────────────────── + vvLastScripts = d.scripts ?? {}; + vvRenderScripts(); + // ── Transcode ─────────────────────────────────────────────────────────── const tc = d.transcode ?? {}; if (!tc.available) { @@ -476,41 +794,90 @@ function vvPollMonitor() { } else { const loc = tc.is_ramdisk ? 'Ramdisk' : 'SSD'; const locColor = tc.is_ramdisk ? '#4caf50' : '#ff9800'; - const rd = tc.ramdisk ?? {}; - const usedMb = rd.used_mb ?? 0; - const sizeMb = rd.size_mb ?? 0; - const pct = sizeMb > 0 ? Math.round(usedMb / sizeMb * 100) : 0; - const barColor = pct > 85 ? '#f44336' : pct > 65 ? '#ff9800' : '#4caf50'; + // RAM disk bar + const rd = tc.ramdisk ?? {}; + const rdUsed = rd.used_mb ?? 0; + const rdSize = rd.size_mb ?? 0; + const rdPct = rdSize > 0 ? Math.round(rdUsed / rdSize * 100) : 0; + const rdHue = Math.round(120 * (1 - rdPct / 100)); + + // SSD bar + const ssd = tc.ssd ?? {}; + const ssdUsed = ssd.used_mb ?? 0; + const ssdSize = ssd.size_mb ?? 0; + const ssdPct = ssdSize > 0 ? Math.round(ssdUsed / ssdSize * 100) : 0; + const ssdHue = Math.round(120 * (1 - ssdPct / 100)); + + // Flip info const ago = tc.last_flip_ago; let flipStr = 'never'; if (ago !== null && ago !== undefined) { - if (ago < 60) flipStr = ago + 's ago'; - else if (ago < 3600) flipStr = Math.floor(ago / 60) + 'm ago'; - else flipStr = Math.floor(ago / 3600) + 'h ' + Math.floor((ago % 3600) / 60) + 'm ago'; + if (ago < 60) flipStr = ago + 's ago'; + else if (ago < 3600) flipStr = Math.floor(ago / 60) + 'm ago'; + else flipStr = Math.floor(ago / 3600) + 'h ' + Math.floor((ago % 3600) / 60) + 'm ago'; } - const totalSess = (tc.ram_sessions ?? 0) + (tc.ssd_sessions ?? 0); - let sessStr = totalSess === 0 ? 'none' : totalSess + ' active'; - if (tc.ram_sessions > 0 && tc.ssd_sessions > 0) - sessStr += ` (split: ${tc.ram_sessions} RAM / ${tc.ssd_sessions} SSD)`; + // Server icon helper + function vvSrvIcon(type, name) { + const cfg = { emby: ['#4caf50','#fff','E'], jellyfin: ['#00A4DC','#fff','JF'], plex: ['#E5A00D','#000','P'] }; + const [bg, fg, lbl] = cfg[type] ?? ['#555','#fff', (type[0] || '?').toUpperCase()]; + return `${lbl}`; + } - document.getElementById('vv-transcode-body').innerHTML = ` -
- ● ${loc} - Sessions: ${sessStr} - Flips this hour: ${tc.flip_count_hour ?? 0} -
-
-
- Ramdisk${usedMb} MB / ${sizeMb} MB (${pct}%) -
-
-
-
-
-
Last flip: ${flipStr}
`; + // Active sessions from shared streams data + const activeSessions = vvLastSessions.filter(s => s.is_tc); + const typeLabel = t => ({ LiveTvProgram:'Live TV', Movie:'Movie', Episode:'TV', Audio:'Music' }[t] ?? t); + + let sessHtml = ''; + if (activeSessions.length) { + let rowsHtml = ''; + activeSessions.forEach(s => { + const meth = s.method.replace('Transcode', 'TC').replace('Direct ', ''); + rowsHtml += `
+ ${vvSrvIcon(s.server_type, s.server)} + ${s.title} + ${typeLabel(s.type)} + ${meth} +
`; + }); + sessHtml = `
+
Active transcodes ${activeSessions.length}
+
${rowsHtml}
+
`; + } + + const nvmeColor = ssd.available ? '#4caf50' : '#f44336'; + document.getElementById('vv-transcode-body').innerHTML = + `
+
+ ● ${loc} + ● NVMe +
+ Flips: ${tc.flip_count_hour ?? 0}/hr · ${flipStr} +
+
+
+ Ramdisk + ${rdUsed} / ${rdSize} MB +
+
+
+
+
+
+
+ SSD Fallback + ${ssd.available ? ssdUsed + ' / ' + ssdSize + ' MB' : '—'} +
+
+
+
+
${sessHtml}`; } // ── Docker ────────────────────────────────────────────────────────────── @@ -528,6 +895,20 @@ function vvPollMonitor() { vvPollMonitor(); setInterval(vvPollMonitor, 5000); +// Pin pools card width to CPU card width across rows +function vvSyncCardWidths() { + const cpu = document.getElementById('vv-cpu'); + const pools = document.getElementById('vv-storage-card'); + if (!cpu || !pools) return; + const w = cpu.offsetWidth; + if (w === 0) return; + pools.style.flex = 'none'; + pools.style.boxSizing = 'border-box'; + pools.style.width = w + 'px'; +} +new ResizeObserver(vvSyncCardWidths).observe(document.getElementById('vv-cpu')); +requestAnimationFrame(vvSyncCardWidths); + // ── Media streams (slower poll — media server API calls) ────────────────────── function vvFmtSec(sec) { @@ -539,63 +920,109 @@ function vvFmtSec(sec) { : `${m}:${String(s).padStart(2,'0')}`; } +function vvRenderStreams() { + if (vvStreamServerCount === 0) return; + const sessions = vvLastSessions; + const names = vvLastStreamNames; + const el = document.getElementById('vv-streams-body'); + if (!el) return; + + // Per-server counts from full session list + const serverCounts = {}; + sessions.forEach(s => serverCounts[s.server] = (serverCounts[s.server] || 0) + 1); + const badges = names.map(n => { + const cnt = serverCounts[n] ?? 0; + return cnt > 0 + ? `${n} ${cnt}` + : `${n}`; + }).join(''); + + if (sessions.length === 0) { + el.innerHTML = `
${badges}
` + + '

Nothing playing

'; + return; + } + + const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - vvLastStreamPollAt); + + function vvStreamRow(s) { + const isLive = s.type === 'LiveTvProgram' || s.dur_sec === 0; + const inc = s.paused ? 0 : elapsed; + const curSec = Math.max(0, (s.pos_sec ?? 0) + inc); + const barColor = isLive ? '#ff9800' : (s.is_tc ? '#ff9800' : '#4caf50'); + const tcColor = s.paused ? '#fdd835' : '#555'; + + const icon = s.paused ? '⏸' : '▶'; + const iconColor = s.paused ? '#fdd835' : isLive ? '#2196f3' : s.is_tc ? '#ff9800' : '#aaa'; + + let timeStr = ''; + let pct = s.pct ?? 0; + if (isLive) { + pct = 75; + timeStr = `${vvFmtSec(curSec)}`; + } else if (s.dur_sec > 0) { + pct = Math.min(100, Math.round(curSec / s.dur_sec * 100)); + timeStr = `${vvFmtSec(curSec)} / ${vvFmtSec(s.dur_sec)}`; + } + + return `
+
+ + ${icon} ${s.title} + ${s.server} +
+
+ ${s.user} + ${timeStr} +
+
+
+
+
`; + } + + // Left-fill columns of 3 — col 1 fills first, col 2 opens when col 1 hits 3 + const shown = sessions.slice(0, 12); + const perCol = 3; + const sCols = []; + for (let i = 0; i < shown.length; i += perCol) sCols.push(shown.slice(i, i + perCol)); + + const vvStreamCol = col => + `
${col.map(s => + `
${vvStreamRow(s)}
` + ).join('')}
`; + + const overflow = sessions.length > 12 + ? `
+${sessions.length - 12} more not shown
` + : ''; + + el.innerHTML = `
${badges}
+
${sCols.map(vvStreamCol).join('')}
${overflow}`; +} + function vvPollStreams() { fetch('/plugins/varaverk/api/media.php') .then(r => r.json()) .then(d => { - const sessions = d.sessions ?? []; - const names = d.server_names ?? []; - const el = document.getElementById('vv-streams-body'); + vvLastSessions = d.sessions ?? []; + vvLastStreamNames = d.server_names ?? []; + vvStreamServerCount = d.server_count ?? 0; + vvLastStreamPollAt = Math.floor(Date.now() / 1000); - if (d.server_count === 0) { + const el = document.getElementById('vv-streams-body'); + if (vvStreamServerCount === 0) { el.innerHTML = '

No media servers detected.
' + 'Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.

'; return; } - - // Server badges in header - const badges = names.map(n => - `${n}` - ).join(''); - - if (sessions.length === 0) { - el.innerHTML = `
${badges}
` - + '

Nothing playing

'; - return; - } - - const rows = sessions.map(s => { - const icon = s.paused ? '⏸' : '▶'; - const barColor = s.is_tc ? '#ff9800' : '#4caf50'; - const methColor = s.is_tc ? '#ff9800' : '#4caf50'; - const timeStr = s.dur_sec > 0 - ? `${vvFmtSec(s.pos_sec)} / ${vvFmtSec(s.dur_sec)}` - : ''; - return `
-
- ${icon} - ${s.title} - ${s.server} -
-
- ${s.user} - ${s.client} - ${s.method} - ${timeStr ? `${timeStr}` : ''} -
-
-
-
-
`; - }).join(''); - - el.innerHTML = `
${badges}
${rows}`; + vvRenderStreams(); }) .catch(() => {}); } vvPollStreams(); setInterval(vvPollStreams, 12000); +setInterval(vvRenderStreams, 1000); // ── System clock tick (updates time every 30s without a full poll) ──────────── function vvTickClock() {