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:
@@ -16,5 +16,11 @@ echo json_encode([
|
|||||||
'containers' => vv_docker_containers(),
|
'containers' => vv_docker_containers(),
|
||||||
'stopped' => vv_docker_stopped(),
|
'stopped' => vv_docker_stopped(),
|
||||||
'transcode' => vv_transcode_sessions(),
|
'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(),
|
'ts' => time(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -14,15 +14,17 @@
|
|||||||
border-radius: 6px; padding: 12px; }
|
border-radius: 6px; padding: 12px; }
|
||||||
.vv-wide { flex: 100%; }
|
.vv-wide { flex: 100%; }
|
||||||
.vv-card h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase;
|
.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 */
|
/* System card — no h3, no top padding waste */
|
||||||
#vv-system { padding-top: 14px; }
|
#vv-system { padding-top: 14px; }
|
||||||
|
|
||||||
/* System action buttons */
|
/* System action buttons */
|
||||||
.vv-sys-btn { background: #2a2a2a; border: 1px solid #444; color: #888; border-radius: 4px;
|
.vv-sys-btn { background: #2a2a2a; border: 1px solid #e65100; color: #ff9800; border-radius: 3px;
|
||||||
padding: 3px 7px; font-size: 13px; cursor: pointer; line-height: 1; }
|
padding: 3px 0; font-size: 6px; cursor: pointer; line-height: 1;
|
||||||
.vv-sys-btn:hover { background: #3a3a3a; color: #ccc; border-color: #666; }
|
width: 50px; min-width: 0; text-align: center; }
|
||||||
|
.vv-sys-btn:hover { background: #3a2000; color: #ffb74d; border-color: #ff9800; }
|
||||||
|
|
||||||
/* Fallback state badge */
|
/* Fallback state badge */
|
||||||
.vv-state-badge { font-size: 20px; font-weight: bold; padding: 4px 0; margin-bottom: 2px; }
|
.vv-state-badge { font-size: 20px; font-weight: bold; padding: 4px 0; margin-bottom: 2px; }
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ function vv_fetch_jf_sessions(array $srv): array {
|
|||||||
|
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'server' => $srv['name'],
|
'server' => $srv['name'],
|
||||||
|
'server_type' => $srv['type'],
|
||||||
'user' => $s['UserName'] ?? '?',
|
'user' => $s['UserName'] ?? '?',
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
@@ -161,6 +162,7 @@ function vv_fetch_plex_sessions(array $srv): array {
|
|||||||
$player = $m['Player'] ?? [];
|
$player = $m['Player'] ?? [];
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'server' => $srv['name'],
|
'server' => $srv['name'],
|
||||||
|
'server_type' => $srv['type'],
|
||||||
'user' => ($m['User']['title'] ?? '?'),
|
'user' => ($m['User']['title'] ?? '?'),
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'type' => ucfirst($type),
|
'type' => ucfirst($type),
|
||||||
|
|||||||
@@ -423,13 +423,26 @@ function vv_transcode_sessions(): array {
|
|||||||
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
|
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
|
||||||
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
|
$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 = '';
|
$ssdPath = '';
|
||||||
$ssdSessions = 0;
|
$ssdSessions = 0;
|
||||||
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
|
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
|
// Ramdisk disk usage
|
||||||
$rd = vv_df('/mnt/ramdisk_transcodes');
|
$rd = vv_df('/mnt/ramdisk_transcodes');
|
||||||
@@ -444,5 +457,237 @@ function vv_transcode_sessions(): array {
|
|||||||
'ram_sessions' => $ramSessions,
|
'ram_sessions' => $ramSessions,
|
||||||
'ssd_sessions' => $ssdSessions,
|
'ssd_sessions' => $ssdSessions,
|
||||||
'ramdisk' => $rd,
|
'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'; ?>
|
<?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="grid-column:span 1;position:relative;overflow:hidden;">
|
||||||
<div class="vv-card" id="vv-system" style="flex:1;min-width:250px;">
|
|
||||||
<div id="vv-system-body">Loading...</div>
|
<div id="vv-system-body">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vv-card" id="vv-partner" style="flex:1;min-width:250px;">
|
<div class="vv-card" id="vv-partner" style="grid-column:span 1;">
|
||||||
<h3>Partner & Fallback</h3>
|
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||||
|
Partner & 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 id="vv-partner-body">Loading...</div>
|
||||||
</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>
|
<h3>CPU</h3>
|
||||||
<div id="vv-cpu-body">Loading...</div>
|
<div id="vv-cpu-body">Loading...</div>
|
||||||
</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>
|
<h3>Memory</h3>
|
||||||
<div id="vv-memory-body">Loading...</div>
|
<div id="vv-memory-body">Loading...</div>
|
||||||
</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>
|
<h3>Network</h3>
|
||||||
<div id="vv-network-body">Loading...</div>
|
<div id="vv-network-body">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="vv-row">
|
<div class="vv-card" id="vv-parity-card" style="grid-column:span 1;">
|
||||||
<div class="vv-card" id="vv-gpu-card" style="flex:2;min-width:240px;">
|
<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>
|
<h3>GPU</h3>
|
||||||
<div id="vv-gpu-body">Loading...</div>
|
<div id="vv-gpu-body">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vv-card" id="vv-transcode">
|
<div class="vv-card" id="vv-transcode" style="grid-column:span 2;">
|
||||||
<h3>Transcode</h3>
|
<h3>Transcode System</h3>
|
||||||
<div id="vv-transcode-body">Loading...</div>
|
<div id="vv-transcode-body">Loading...</div>
|
||||||
</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>
|
<h3>Streams</h3>
|
||||||
<div id="vv-streams-body">Loading...</div>
|
<div id="vv-streams-body">Loading...</div>
|
||||||
</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>
|
<h3>Containers</h3>
|
||||||
<table id="vv-docker-table">
|
<table id="vv-docker-table">
|
||||||
<thead><tr><th>Name</th><th>Status</th><th>Image</th></tr></thead>
|
<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>
|
<tbody id="vv-docker-body"><tr><td colspan="3">Loading...</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -59,7 +102,7 @@
|
|||||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function vvMeter(label, pct, text) {
|
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;">
|
return `<div style="margin-bottom:7px;">
|
||||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:#666;margin-bottom:3px;">
|
<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>
|
<span>${label}</span><span style="color:#999;">${text}</span>
|
||||||
@@ -74,6 +117,13 @@ function vvMeter(label, pct, text) {
|
|||||||
|
|
||||||
const VV_HIST_MAX = 24; // 24 × 5 s = 120 s
|
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 vvNetRxHistory = [];
|
||||||
let vvNetTxHistory = [];
|
let vvNetTxHistory = [];
|
||||||
@@ -203,8 +253,9 @@ function vvRenderCpu(cpu) {
|
|||||||
if (cores.length) {
|
if (cores.length) {
|
||||||
html += `<div style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;">`;
|
html += `<div style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;">`;
|
||||||
cores.forEach(c => {
|
cores.forEach(c => {
|
||||||
const color = vvCoreColor(c.freq_mhz, c.max_mhz, c.min_mhz);
|
|
||||||
const usePct = c.usage_pct ?? 0;
|
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 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') : '';
|
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;">
|
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
|
// Freq legend
|
||||||
html += `<div style="display:flex;justify-content:space-between;font-size:9px;color:#444;margin-bottom:8px;">
|
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%)">Low</span>
|
||||||
<span style="color:hsl(120,70%,45%)">Mid</span>
|
<span style="color:hsl(60,70%,45%)">Med</span>
|
||||||
<span style="color:hsl(0,70%,45%)">High freq</span>
|
<span style="color:hsl(0,70%,45%)">High</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +333,56 @@ function vvRenderMemory(mem) {
|
|||||||
return html;
|
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 ──────────────────────────────────────────────────────────────────────
|
// ── Poll ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function vvPollMonitor() {
|
function vvPollMonitor() {
|
||||||
@@ -289,6 +390,9 @@ function vvPollMonitor() {
|
|||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => {
|
.then(d => {
|
||||||
|
|
||||||
|
// ── Thresholds (from dynamix.cfg via backend) ────────────────────────────
|
||||||
|
if (d.thresholds) vvThresholds = d.thresholds;
|
||||||
|
|
||||||
// ── System ──────────────────────────────────────────────────────────────
|
// ── System ──────────────────────────────────────────────────────────────
|
||||||
const sys = d.system ?? {};
|
const sys = d.system ?? {};
|
||||||
const now = new Date();
|
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:14px;font-weight:700;color:#ddd;">${sys.name}</div>
|
||||||
<div style="font-size:11px;color:#666;margin-top:2px;">${sys.comment}</div>
|
<div style="font-size:11px;color:#666;margin-top:2px;">${sys.comment}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px;flex-shrink:0;margin-left:8px;">
|
<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">■</button>
|
<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">⏻</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">↺</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>
|
</div>
|
||||||
<div style="font-size:22px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</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;">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;">Array</span> <span style="color:${arrayColor};">${sys.array_state}</span>
|
||||||
<span style="color:#555;">Version</span> <span style="color:#555;">${ver}</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 ───────────────────────────────────────────────────
|
// ── Partner & Fallback ───────────────────────────────────────────────────
|
||||||
const pt = d.partner ?? {};
|
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>';
|
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 ─────────────────────────────────────────────────────────────────
|
// ── GPU ─────────────────────────────────────────────────────────────────
|
||||||
const gpu = d.gpu ?? {};
|
const gpu = d.gpu ?? {};
|
||||||
const gpuProcs = d.gpu_procs ?? [];
|
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>';
|
document.getElementById('vv-gpu-body').innerHTML = '<p style="color:#555;font-style:italic">No GPU detected</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Scripts ─────────────────────────────────────────────────────────────
|
||||||
|
vvLastScripts = d.scripts ?? {};
|
||||||
|
vvRenderScripts();
|
||||||
|
|
||||||
// ── Transcode ───────────────────────────────────────────────────────────
|
// ── Transcode ───────────────────────────────────────────────────────────
|
||||||
const tc = d.transcode ?? {};
|
const tc = d.transcode ?? {};
|
||||||
if (!tc.available) {
|
if (!tc.available) {
|
||||||
@@ -476,12 +794,22 @@ function vvPollMonitor() {
|
|||||||
} else {
|
} else {
|
||||||
const loc = tc.is_ramdisk ? 'Ramdisk' : 'SSD';
|
const loc = tc.is_ramdisk ? 'Ramdisk' : 'SSD';
|
||||||
const locColor = tc.is_ramdisk ? '#4caf50' : '#ff9800';
|
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;
|
const ago = tc.last_flip_ago;
|
||||||
let flipStr = 'never';
|
let flipStr = 'never';
|
||||||
if (ago !== null && ago !== undefined) {
|
if (ago !== null && ago !== undefined) {
|
||||||
@@ -490,27 +818,66 @@ function vvPollMonitor() {
|
|||||||
else flipStr = Math.floor(ago / 3600) + 'h ' + Math.floor((ago % 3600) / 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);
|
// Server icon helper
|
||||||
let sessStr = totalSess === 0 ? 'none' : totalSess + ' active';
|
function vvSrvIcon(type, name) {
|
||||||
if (tc.ram_sessions > 0 && tc.ssd_sessions > 0)
|
const cfg = { emby: ['#4caf50','#fff','E'], jellyfin: ['#00A4DC','#fff','JF'], plex: ['#E5A00D','#000','P'] };
|
||||||
sessStr += ` (split: ${tc.ram_sessions} RAM / ${tc.ssd_sessions} SSD)`;
|
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 = `
|
// Active sessions from shared streams data
|
||||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
|
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};
|
<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>
|
padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600;">● ${loc}</span>
|
||||||
<span style="color:#888;font-size:12px;">Sessions: ${sessStr}</span>
|
<span style="background:${nvmeColor}22;border:1px solid ${nvmeColor};color:${nvmeColor};
|
||||||
<span style="color:#666;font-size:12px;margin-left:auto;">Flips this hour: ${tc.flip_count_hour ?? 0}</span>
|
padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600;">● NVMe</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:4px;">
|
<span style="font-size:10px;color:#555;">Flips: ${tc.flip_count_hour ?? 0}/hr · ${flipStr}</span>
|
||||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:#666;margin-bottom:3px;">
|
</div>
|
||||||
<span>Ramdisk</span><span>${usedMb} MB / ${sizeMb} MB (${pct}%)</span>
|
<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>
|
||||||
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
|
<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 style="width:${rdPct}%;height:100%;background:hsl(${rdHue},70%,45%);border-radius:3px;transition:width 0.4s;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:11px;color:#555;margin-top:6px;">Last flip: ${flipStr}</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 ──────────────────────────────────────────────────────────────
|
// ── Docker ──────────────────────────────────────────────────────────────
|
||||||
@@ -528,6 +895,20 @@ function vvPollMonitor() {
|
|||||||
vvPollMonitor();
|
vvPollMonitor();
|
||||||
setInterval(vvPollMonitor, 5000);
|
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) ──────────────────────
|
// ── Media streams (slower poll — media server API calls) ──────────────────────
|
||||||
|
|
||||||
function vvFmtSec(sec) {
|
function vvFmtSec(sec) {
|
||||||
@@ -539,24 +920,22 @@ function vvFmtSec(sec) {
|
|||||||
: `${m}:${String(s).padStart(2,'0')}`;
|
: `${m}:${String(s).padStart(2,'0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function vvPollStreams() {
|
function vvRenderStreams() {
|
||||||
fetch('/plugins/varaverk/api/media.php')
|
if (vvStreamServerCount === 0) return;
|
||||||
.then(r => r.json())
|
const sessions = vvLastSessions;
|
||||||
.then(d => {
|
const names = vvLastStreamNames;
|
||||||
const sessions = d.sessions ?? [];
|
|
||||||
const names = d.server_names ?? [];
|
|
||||||
const el = document.getElementById('vv-streams-body');
|
const el = document.getElementById('vv-streams-body');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
if (d.server_count === 0) {
|
// Per-server counts from full session list
|
||||||
el.innerHTML = '<p class="vv-stream-empty">No media servers detected.<br>'
|
const serverCounts = {};
|
||||||
+ '<span>Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.</span></p>';
|
sessions.forEach(s => serverCounts[s.server] = (serverCounts[s.server] || 0) + 1);
|
||||||
return;
|
const badges = names.map(n => {
|
||||||
}
|
const cnt = serverCounts[n] ?? 0;
|
||||||
|
return cnt > 0
|
||||||
// Server badges in header
|
? `<span class="vv-server-badge">${n} <b style="color:#ccc;">${cnt}</b></span>`
|
||||||
const badges = names.map(n =>
|
: `<span class="vv-server-badge" style="color:#444;">${n}</span>`;
|
||||||
`<span class="vv-server-badge">${n}</span>`
|
}).join('');
|
||||||
).join('');
|
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>`
|
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>`
|
||||||
@@ -564,38 +943,86 @@ function vvPollStreams() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = sessions.map(s => {
|
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 icon = s.paused ? '⏸' : '▶';
|
||||||
const barColor = s.is_tc ? '#ff9800' : '#4caf50';
|
const iconColor = s.paused ? '#fdd835' : isLive ? '#2196f3' : s.is_tc ? '#ff9800' : '#aaa';
|
||||||
const methColor = s.is_tc ? '#ff9800' : '#4caf50';
|
|
||||||
const timeStr = s.dur_sec > 0
|
let timeStr = '';
|
||||||
? `${vvFmtSec(s.pos_sec)} / ${vvFmtSec(s.dur_sec)}`
|
let pct = s.pct ?? 0;
|
||||||
: '';
|
if (isLive) {
|
||||||
return `<div class="vv-stream-row">
|
pct = 75;
|
||||||
<div class="vv-stream-top">
|
timeStr = `<span style="color:${tcColor};">${vvFmtSec(curSec)}</span>`;
|
||||||
<span class="vv-stream-icon">${icon}</span>
|
} else if (s.dur_sec > 0) {
|
||||||
<span class="vv-stream-title" title="${s.title}">${s.title}</span>
|
pct = Math.min(100, Math.round(curSec / s.dur_sec * 100));
|
||||||
<span class="vv-server-badge vv-server-badge-sm">${s.server}</span>
|
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>
|
||||||
<div class="vv-stream-meta">
|
<div style="display:flex;justify-content:space-between;font-size:10px;color:#555;margin-bottom:3px;">
|
||||||
<span class="vv-stream-user">${s.user}</span>
|
<span>${s.user}</span>
|
||||||
<span class="vv-stream-client">${s.client}</span>
|
${timeStr}
|
||||||
<span class="vv-stream-method" style="color:${methColor};">${s.method}</span>
|
|
||||||
${timeStr ? `<span class="vv-stream-time">${timeStr}</span>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="vv-stream-bar">
|
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
|
||||||
<div style="width:${s.pct}%;background:${barColor};"></div>
|
<div style="width:${pct}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.9s linear;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}
|
||||||
|
|
||||||
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>${rows}`;
|
// 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 => {
|
||||||
|
vvLastSessions = d.sessions ?? [];
|
||||||
|
vvLastStreamNames = d.server_names ?? [];
|
||||||
|
vvStreamServerCount = d.server_count ?? 0;
|
||||||
|
vvLastStreamPollAt = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
vvRenderStreams();
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
vvPollStreams();
|
vvPollStreams();
|
||||||
setInterval(vvPollStreams, 12000);
|
setInterval(vvPollStreams, 12000);
|
||||||
|
setInterval(vvRenderStreams, 1000);
|
||||||
|
|
||||||
// ── System clock tick (updates time every 30s without a full poll) ────────────
|
// ── System clock tick (updates time every 30s without a full poll) ────────────
|
||||||
function vvTickClock() {
|
function vvTickClock() {
|
||||||
|
|||||||
Reference in New Issue
Block a user