varaverk: monitor tab overhaul — grid layout, dynamic thresholds, new cards

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
This commit is contained in:
Gmer4Lfe
2026-05-24 16:56:50 -04:00
parent f85493cf35
commit 760fb4a3e2
5 changed files with 808 additions and 126 deletions
@@ -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(),
]);
@@ -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; }
@@ -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;
@@ -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')),
];
}
@@ -1,57 +1,100 @@
<?php require_once dirname(__DIR__) . '/include/monitor.php'; ?>
<div id="vv-monitor">
<div id="vv-monitor" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div class="vv-row">
<div class="vv-card" id="vv-system" style="flex:1;min-width:250px;">
<div class="vv-card" id="vv-system" style="grid-column:span 1;position:relative;overflow:hidden;">
<div id="vv-system-body">Loading...</div>
</div>
<div class="vv-card" id="vv-partner" style="flex:1;min-width:250px;">
<h3>Partner &amp; Fallback</h3>
<div class="vv-card" id="vv-partner" style="grid-column:span 1;">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
Partner &amp; Fallback
<svg width="32" height="18" viewBox="0 0 32 18" fill="none" xmlns="http://www.w3.org/2000/svg" style="opacity:0.45;flex-shrink:0;">
<!-- Server left -->
<rect x="0.5" y="2" width="9" height="14" rx="1.5" stroke="#888" stroke-width="1" fill="#1a1a1a"/>
<line x1="0.5" y1="5.5" x2="9.5" y2="5.5" stroke="#555" stroke-width="0.6"/>
<circle cx="2.5" cy="3.8" r="0.8" fill="#4caf50"/>
<line x1="2" y1="8.5" x2="8" y2="8.5" stroke="#333" stroke-width="0.6"/>
<line x1="2" y1="10.5" x2="8" y2="10.5" stroke="#333" stroke-width="0.6"/>
<line x1="2" y1="12.5" x2="6" y2="12.5" stroke="#333" stroke-width="0.6"/>
<!-- Arrows between -->
<line x1="10.5" y1="8" x2="21.5" y2="8" stroke="#555" stroke-width="0.8"/>
<polyline points="13,5.5 10.5,8 13,10.5" stroke="#555" stroke-width="0.8" fill="none"/>
<polyline points="19,5.5 21.5,8 19,10.5" stroke="#555" stroke-width="0.8" fill="none"/>
<!-- Server right -->
<rect x="22.5" y="2" width="9" height="14" rx="1.5" stroke="#888" stroke-width="1" fill="#1a1a1a"/>
<line x1="22.5" y1="5.5" x2="31.5" y2="5.5" stroke="#555" stroke-width="0.6"/>
<circle cx="24.5" cy="3.8" r="0.8" fill="#4caf50"/>
<line x1="24" y1="8.5" x2="30" y2="8.5" stroke="#333" stroke-width="0.6"/>
<line x1="24" y1="10.5" x2="30" y2="10.5" stroke="#333" stroke-width="0.6"/>
<line x1="24" y1="12.5" x2="28" y2="12.5" stroke="#333" stroke-width="0.6"/>
</svg>
</h3>
<div id="vv-partner-body">Loading...</div>
</div>
<div class="vv-card" id="vv-cpu" style="flex:2;min-width:240px;">
<div class="vv-card" id="vv-cpu" style="grid-column:span 2;">
<h3>CPU</h3>
<div id="vv-cpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-memory" style="flex:2;min-width:240px;">
<div class="vv-card" id="vv-memory" style="grid-column:span 2;">
<h3>Memory</h3>
<div id="vv-memory-body">Loading...</div>
</div>
<div class="vv-card" id="vv-network" style="flex:2;min-width:240px;">
<div class="vv-card" id="vv-network" style="grid-column:span 2;">
<h3>Network</h3>
<div id="vv-network-body">Loading...</div>
</div>
</div>
<div class="vv-row">
<div class="vv-card" id="vv-gpu-card" style="flex:2;min-width:240px;">
<div class="vv-card" id="vv-ups-card" style="grid-column:span 1;">
<h3>Power</h3>
<div id="vv-ups-body">Loading...</div>
</div>
<div class="vv-card" id="vv-parity-card" style="grid-column:span 1;">
<h3>Parity</h3>
<div id="vv-parity-body">Loading...</div>
</div>
<div class="vv-card" id="vv-storage-card" style="grid-column:span 2;">
<h3>Pools</h3>
<div id="vv-storage-body">Loading...</div>
</div>
<div class="vv-card" id="vv-array-card" style="grid-column:span 4;">
<h3>Array</h3>
<div id="vv-array-body">Loading...</div>
</div>
<div class="vv-card" id="vv-scripts-card" style="grid-column:span 1;">
<h3>Scripts</h3>
<div id="vv-scripts-body">Loading...</div>
</div>
<div class="vv-card" id="vv-gpu-card" style="grid-column:span 1;">
<h3>GPU</h3>
<div id="vv-gpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-transcode">
<h3>Transcode</h3>
<div class="vv-card" id="vv-transcode" style="grid-column:span 2;">
<h3>Transcode System</h3>
<div id="vv-transcode-body">Loading...</div>
</div>
<div class="vv-card" id="vv-streams" style="flex:2;min-width:280px;">
<div class="vv-card" id="vv-streams" style="grid-column:span 4;">
<h3>Streams</h3>
<div id="vv-streams-body">Loading...</div>
</div>
<div class="vv-card vv-wide" id="vv-docker">
<div class="vv-card" id="vv-docker" style="grid-column:span 8;">
<h3>Containers</h3>
<table id="vv-docker-table">
<thead><tr><th>Name</th><th>Status</th><th>Image</th></tr></thead>
<tbody id="vv-docker-body"><tr><td colspan="3">Loading...</td></tr></tbody>
</table>
</div>
</div>
</div>
@@ -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 `<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;font-size:11px;color:#666;margin-bottom:3px;">
<span>${label}</span><span style="color:#999;">${text}</span>
@@ -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 += `<div style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;">`;
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 += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;min-width:10px;">
@@ -219,9 +270,9 @@ function vvRenderCpu(cpu) {
// Freq legend
html += `<div style="display:flex;justify-content:space-between;font-size:9px;color:#444;margin-bottom:8px;">
<span style="color:hsl(220,70%,45%)">Low freq</span>
<span style="color:hsl(120,70%,45%)">Mid</span>
<span style="color:hsl(0,70%,45%)">High freq</span>
<span style="color:hsl(120,70%,45%)">Low</span>
<span style="color:hsl(60,70%,45%)">Med</span>
<span style="color:hsl(0,70%,45%)">High</span>
</div>`;
}
@@ -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 `<span onclick="vvScriptsFilterSet('${type}')"
style="font-size:10px;color:${color};background:${bg};border:1px solid ${active ? color : border};
padding:1px 7px;border-radius:10px;cursor:pointer;font-weight:${active ? '700' : '400'};">${icon} ${count} ${type}</span>`;
}
let html = `<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px;">`;
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 += `<span style="font-size:10px;color:#555;">No recent runs</span>`;
html += `</div>`;
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 += `<div style="display:flex;align-items:center;gap:5px;margin-bottom:4px;font-size:11px;">
<span style="color:${color};flex-shrink:0;width:10px;text-align:center;">${icon}</span>
<span style="color:#888;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${name}</span>
<span style="color:#444;font-size:10px;flex-shrink:0;">${ago}</span>
</div>`;
});
if (!filtered.length) listHtml = `<p style="color:#555;font-style:italic;font-size:12px;">${vvScriptsFilter ? 'None in this group' : 'No script logs found'}</p>`;
html += `<div style="max-height:133px;overflow-y:auto;">${listHtml}</div>`;
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() {
<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>
<div style="display:flex;gap:6px;flex-shrink:0;margin-left:8px;">
<button onclick="vvArrayAction('stop')" class="vv-sys-btn" title="Stop Array">■</button>
<button onclick="vvArrayAction('shutdown')" class="vv-sys-btn" title="Shutdown">⏻</button>
<button onclick="vvArrayAction('restart')" class="vv-sys-btn" title="Restart">↺</button>
<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>
</div>
<div style="font-size:22px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div>
@@ -318,7 +422,40 @@ function vvPollMonitor() {
<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>`;
</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>`;
// ── Partner & Fallback ───────────────────────────────────────────────────
const pt = d.partner ?? {};
@@ -435,6 +572,183 @@ function vvPollMonitor() {
document.getElementById('vv-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</p>';
}
// ── 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 =
`<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>
<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>
<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>
<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>
<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>
</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>`;
} else {
document.getElementById('vv-ups-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No UPS detected</p>';
}
// ── 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 = `<div style="font-size:13px;font-weight:600;color:${validColor};margin-bottom:10px;">${validLabel}</div>`;
if (inProg) {
const pct = par.resync_pct ?? 0;
html += `<div style="font-size:11px;color:#aaa;margin-bottom:4px;">Check in progress — ${pct}%</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;margin-bottom:10px;">
<div style="width:${pct}%;height:100%;background:#4caf50;border-radius:3px;transition:width 2s;"></div>
</div>`;
}
html += `
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;">
<span style="color:#555;">Last check</span>
<span style="color:#888;">${par.last_date ?? '—'}${speedStr}</span>
<span style="color:#555;">Status</span>
<span style="color:${exitColor};">${par.exit_label ?? '—'}</span>
<span style="color:#555;">Errors</span>
<span style="color:${errColor};">${par.errors ?? 0}</span>
<span style="color:#555;">Next check</span>
<span style="color:#888;">${nextDate}</span>
</div>
<div style="font-size:10px;color:#555;margin-top:8px;">${dueIn}</div>`;
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) ? `<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>`;
}
// ── 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>';
}
// ── 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 =
`<div style="display:flex;gap:10px;">${cols.map(vvDiskCol).join('')}</div>`;
} else {
document.getElementById('vv-array-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No array disks</p>';
}
// ── GPU ─────────────────────────────────────────────────────────────────
const gpu = d.gpu ?? {};
const gpuProcs = d.gpu_procs ?? [];
@@ -468,6 +782,10 @@ function vvPollMonitor() {
document.getElementById('vv-gpu-body').innerHTML = '<p style="color:#555;font-style:italic">No GPU detected</p>';
}
// ── 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 `<span title="${name}" style="display:inline-flex;align-items:center;justify-content:center;
width:18px;height:18px;background:${bg};border-radius:3px;font-size:8px;font-weight:bold;
color:${fg};flex-shrink:0;">${lbl}</span>`;
}
document.getElementById('vv-transcode-body').innerHTML = `
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="background:${locColor}22;border:1px solid ${locColor};color:${locColor};
padding:2px 10px;border-radius:12px;font-size:12px;font-weight:bold;">● ${loc}</span>
<span style="color:#888;font-size:12px;">Sessions: ${sessStr}</span>
<span style="color:#666;font-size:12px;margin-left:auto;">Flips this hour: ${tc.flip_count_hour ?? 0}</span>
</div>
<div style="margin-bottom:4px;">
<div style="display:flex;justify-content:space-between;font-size:11px;color:#666;margin-bottom:3px;">
<span>Ramdisk</span><span>${usedMb} MB / ${sizeMb} MB (${pct}%)</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>
<div style="font-size:11px;color:#555;margin-top:6px;">Last flip: ${flipStr}</div>`;
// 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 += `<div style="display:flex;align-items:center;gap:5px;margin-bottom:4px;min-width:0;overflow:hidden;">
${vvSrvIcon(s.server_type, s.server)}
<span style="font-size:10px;color:#888;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${s.title}</span>
<span style="font-size:9px;color:#555;flex-shrink:0;">${typeLabel(s.type)}</span>
<span style="font-size:9px;color:#ff9800;flex-shrink:0;">${meth}</span>
</div>`;
});
sessHtml = `<div style="margin-top:8px;border-top:1px solid #2a2a2a;padding-top:6px;">
<div style="font-size:10px;color:#555;margin-bottom:4px;">Active transcodes <span style="color:#ff9800;font-weight:600;">${activeSessions.length}</span></div>
<div style="max-height:57px;overflow-y:auto;overflow-x:hidden;">${rowsHtml}</div>
</div>`;
}
const nvmeColor = ssd.available ? '#4caf50' : '#f44336';
document.getElementById('vv-transcode-body').innerHTML =
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="display:flex;gap:6px;align-items:center;">
<span style="background:${locColor}22;border:1px solid ${locColor};color:${locColor};
padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600;">● ${loc}</span>
<span style="background:${nvmeColor}22;border:1px solid ${nvmeColor};color:${nvmeColor};
padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600;">● NVMe</span>
</div>
<span style="font-size:10px;color:#555;">Flips: ${tc.flip_count_hour ?? 0}/hr · ${flipStr}</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;">Ramdisk</span>
<span style="color:#555;font-size:10px;">${rdUsed} / ${rdSize} MB</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${rdPct}%;height:100%;background:hsl(${rdHue},70%,45%);border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>
<div style="margin-bottom:4px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px;">
<span style="color:#666;">SSD Fallback</span>
<span style="color:#555;font-size:10px;">${ssd.available ? ssdUsed + ' / ' + ssdSize + ' MB' : '—'}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${ssdPct}%;height:100%;background:hsl(${ssdHue},70%,45%);border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>${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
? `<span class="vv-server-badge">${n} <b style="color:#ccc;">${cnt}</b></span>`
: `<span class="vv-server-badge" style="color:#444;">${n}</span>`;
}).join('');
if (sessions.length === 0) {
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>`
+ '<p class="vv-stream-empty">Nothing playing</p>';
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 = `<span style="color:${tcColor};">${vvFmtSec(curSec)}</span>`;
} else if (s.dur_sec > 0) {
pct = Math.min(100, Math.round(curSec / s.dur_sec * 100));
timeStr = `<span style="color:${tcColor};">${vvFmtSec(curSec)} / ${vvFmtSec(s.dur_sec)}</span>`;
}
return `<div>
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:2px;">
<span style="color:${s.paused ? '#fdd835' : '#aaa'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">
<span style="color:${iconColor};">${icon}</span> ${s.title}</span>
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${s.server}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:#555;margin-bottom:3px;">
<span>${s.user}</span>
${timeStr}
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.9s linear;"></div>
</div>
</div>`;
}
// 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 =>
`<div style="flex:1;min-width:0;">${col.map(s =>
`<div style="margin-bottom:8px;">${vvStreamRow(s)}</div>`
).join('')}</div>`;
const overflow = sessions.length > 12
? `<div style="font-size:10px;color:#555;margin-top:4px;">+${sessions.length - 12} more not shown</div>`
: '';
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>
<div style="display:flex;gap:10px;">${sCols.map(vvStreamCol).join('')}</div>${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 = '<p class="vv-stream-empty">No media servers detected.<br>'
+ '<span>Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.</span></p>';
return;
}
// Server badges in header
const badges = names.map(n =>
`<span class="vv-server-badge">${n}</span>`
).join('');
if (sessions.length === 0) {
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>`
+ '<p class="vv-stream-empty">Nothing playing</p>';
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 `<div class="vv-stream-row">
<div class="vv-stream-top">
<span class="vv-stream-icon">${icon}</span>
<span class="vv-stream-title" title="${s.title}">${s.title}</span>
<span class="vv-server-badge vv-server-badge-sm">${s.server}</span>
</div>
<div class="vv-stream-meta">
<span class="vv-stream-user">${s.user}</span>
<span class="vv-stream-client">${s.client}</span>
<span class="vv-stream-method" style="color:${methColor};">${s.method}</span>
${timeStr ? `<span class="vv-stream-time">${timeStr}</span>` : ''}
</div>
<div class="vv-stream-bar">
<div style="width:${s.pct}%;background:${barColor};"></div>
</div>
</div>`;
}).join('');
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>${rows}`;
vvRenderStreams();
})
.catch(() => {});
}
vvPollStreams();
setInterval(vvPollStreams, 12000);
setInterval(vvRenderStreams, 1000);
// ── System clock tick (updates time every 30s without a full poll) ────────────
function vvTickClock() {