Plugin: common.php library, watchdog expansion, monitor + scheduler improvements

PHP architecture:
- Extract common.php from monitor.php — shared system functions (vv_system_info,
  vv_memory_breakdown, vv_remote_hosts_stats, disk/GPU/UPS/network/docker/parity, etc.)
  now live in one place; monitor.php and watchdog.php both require common.php
- Add unraid_api.php as explicit include (was implicit via config.php chain)
- confform.php: add missing require_once config.php (implicit dep made explicit)
- Delete orphaned pages/docs.php and pages/config.php (absorbed into scheduler)

Watchdog page:
- Add Storage watchdog card (growth + log strikes, baseline age, suppress ceilings)
- Add Network watchdog card (NPM strikes, DDNS domain/container, NPM URL)
- One host per row layout — all 5 watchdog cards equally spaced via inner grid
- Watchdog now uses Unraid API for local system stats; remote nodes with API key
  but no SSH get system info from vv_remote_hosts_stats() with api_only flag
- SSH bundle: /proc/meminfo passed as raw section instead of awk-parsed header
  fields — fixes RAM showing 0 on remote hosts where awk quoting was unreliable
- vv_wd_local_system() rewritten as thin wrapper over vv_system_info() + vv_memory_breakdown()

Monitor page:
- Watchdog card: add stability strikes, storage watchdog strikes, network NPM status,
  live system stats (rootfs/log/tmp %, RAM free, load, CPU temp, zombies, NIC, sshd)
- Row height: switch from max-height on cards to minmax(0, calc(...)) on grid track —
  all cards in a row now fill to the tallest card's height correctly (fixes Pools card
  being shorter than neighbours)

Scheduler page:
- Add Tools section above Custom Scripts — lists Tools/*.sh with run/dry-run/cron/log
- vv_tools_scripts() function in scheduler.php include

Tools:
- Add docker_prune_images.sh — removes dangling Docker images; --dry-run and --status modes
This commit is contained in:
Gmer4Lfe
2026-05-29 23:33:52 -04:00
parent 8f05f0d27c
commit 86048b32d3
13 changed files with 2072 additions and 898 deletions
+530 -152
View File
@@ -1,35 +1,48 @@
<?php require_once dirname(__DIR__) . '/include/monitor.php'; ?>
<div id="vv-monitor" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div id="vv-api-banner" style="display:none;background:#1a1200;border:1px solid #3a2800;border-radius:4px;
padding:5px 10px;margin-bottom:8px;font-size:11px;color:#ff9800;">
⚠ Unraid API unavailable — using local reads. Check API key in host conf.
<span id="vv-api-fallback-list" style="color:#666;margin-left:6px;"></span>
</div>
<div id="vv-monitor" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;width:100%;box-sizing:border-box;">
<!-- Row 1: System | Power | CPU | Memory | Network -->
<div class="vv-card" id="vv-system" style="grid-column:span 1;position:relative;overflow:hidden;">
<div class="vv-card" id="vv-system" style="grid-column:span 1;">
<div id="vv-system-body">Loading...</div>
</div>
<div class="vv-card" id="vv-ups-card" style="grid-column:span 1;">
<h3>Power</h3>
<h3>
<span style="display:flex;align-items:center;gap:6px;">
<svg width="9" height="15" viewBox="0 0 11 18" fill="none" style="opacity:0.4;flex-shrink:0;margin-bottom:1px;">
<polygon points="9,0 2,10 6,10 3,18 11,6 6,6 8,0" fill="#ff9800"/>
</svg>Power
</span>
<a href="/Settings/UPS" target="_blank" class="vv-card-cog" title="UPS Settings">⚙</a>
</h3>
<div id="vv-ups-body">Loading...</div>
</div>
<div class="vv-card" id="vv-cpu" style="grid-column:span 2;">
<h3>CPU</h3>
<h3><span id="vv-cpu-title">CPU</span><a href="/Settings/CPUset" target="_blank" class="vv-card-cog" title="CPU Settings">⚙</a></h3>
<div id="vv-cpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-memory" style="grid-column:span 2;">
<h3>Memory</h3>
<h3>Memory<a href="/" target="_blank" class="vv-card-cog" title="Dashboard">⚙</a></h3>
<div id="vv-memory-body">Loading...</div>
</div>
<div class="vv-card" id="vv-network" style="grid-column:span 2;">
<h3>Network</h3>
<h3>Network<a href="/Settings/NetworkSettings" target="_blank" class="vv-card-cog" title="Network Settings">⚙</a></h3>
<div id="vv-network-body">Loading...</div>
</div>
<!-- Row 2: Scripts | Fallback (span 1) | Partner (span 2) | Containers & VMs (span 4) -->
<!-- Row 2: Scripts | Fallback | Partner | Containers & VMs -->
<div class="vv-card" id="vv-scripts-card" style="grid-column:span 1;">
<h3>Scripts</h3>
<h3>Scripts<a href="/Apps/plugin_userscripts" target="_blank" class="vv-card-cog" title="User Scripts">⚙</a></h3>
<div id="vv-scripts-body">Loading...</div>
</div>
@@ -44,18 +57,21 @@
</div>
<div class="vv-card" id="vv-docker-folders" style="grid-column:span 4;">
<h3>Containers and VMs</h3>
<h3>
<span>Containers &amp; VMs <span id="vv-docker-count" style="font-size:10px;color:#4a4a4a;font-weight:400;text-transform:none;letter-spacing:0;margin-left:2px;"></span></span>
<a href="/Docker" target="_blank" class="vv-card-cog" title="Docker">⚙</a>
</h3>
<div id="vv-docker-folders-body">Loading...</div>
</div>
<!-- Row 3: GPU | Transcode | Streams -->
<div class="vv-card" id="vv-gpu-card" style="grid-column:span 2;">
<h3>GPU</h3>
<h3>GPU<a href="/" target="_blank" class="vv-card-cog" title="Dashboard">⚙</a></h3>
<div id="vv-gpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-transcode" style="grid-column:span 2;">
<h3>Transcode System</h3>
<h3>Transcode</h3>
<div id="vv-transcode-body">Loading...</div>
</div>
@@ -64,19 +80,24 @@
<div id="vv-streams-body">Loading...</div>
</div>
<!-- Row 4: Parity (cols 1-2) | Pools (col 3, span 2) | Array (col 5, span 4) -->
<div class="vv-card" id="vv-parity-card" style="grid-column:1/span 2;">
<h3>Parity</h3>
<!-- Row 4: Watchdog | Parity | Pools | Array -->
<div class="vv-card" id="vv-watchdog-card" style="grid-column:span 1;">
<h3>Watchdog</h3>
<div id="vv-watchdog-body">Loading...</div>
</div>
<div class="vv-card" id="vv-parity-card" style="grid-column:span 1;">
<h3>Parity<a href="/Main" target="_blank" class="vv-card-cog" title="Array Management">⚙</a></h3>
<div id="vv-parity-body">Loading...</div>
</div>
<div class="vv-card" id="vv-storage-card" style="grid-column:3/span 2;">
<h3>Pools</h3>
<h3>Pools<a href="/Main" target="_blank" class="vv-card-cog" title="Array Management">⚙</a></h3>
<div id="vv-storage-body">Loading...</div>
</div>
<div class="vv-card" id="vv-array-card" style="grid-column:5/span 4;">
<h3>Array</h3>
<h3><span id="vv-array-title">Array</span><a href="/Main" target="_blank" class="vv-card-cog" title="Array Management">⚙</a></h3>
<div id="vv-array-body">Loading...</div>
</div>
@@ -112,6 +133,9 @@ let vvThresholds = {util_warn:70,util_crit:90,hdd_warn:45,hdd_crit:55,ssd
let vvNetRxHistory = [];
let vvNetTxHistory = [];
let vvPoolsOpen = {};
let vvPoolGroupOpen = {};
let vvLastStorageDisks = [];
// ── Canvas chart ──────────────────────────────────────────────────────────────
@@ -236,7 +260,7 @@ function vvRenderCpu(cpu) {
// Per-core vertical bars
if (cores.length) {
html += `<div class="vv-cpu-cores" style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;">`;
html += `<div class="vv-cpu-cores" style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;overflow:hidden;">`;
cores.forEach(c => {
const usePct = c.usage_pct ?? 0;
const hue = Math.round(120 * (1 - usePct / 100));
@@ -315,6 +339,23 @@ function vvRenderMemory(mem) {
+ vvMemRow('ZFS', mem.zfs_kb ?? 0, total, VV_MEM_COLORS.zfs)
+ vvMemRow('Docker', mem.docker_kb ?? 0, total, VV_MEM_COLORS.docker)
+ vvMemRow('Free', mem.free_kb ?? 0, total, VV_MEM_COLORS.free);
// Swap — only shown when swap is configured and has some usage
const swapTotal = mem.swap_total_kb ?? 0;
const swapUsed = mem.swap_used_kb ?? 0;
if (swapTotal > 0) {
const swapPct = Math.round(swapUsed / swapTotal * 100);
const swapColor = swapPct >= 50 ? '#f44336' : swapPct >= 20 ? '#ff9800' : '#607d8b';
html += `<div style="margin-top:6px;padding-top:6px;border-top:1px solid #1a1a1a;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:${swapColor};font-weight:500;">Swap</span>
<span style="color:#777;">${vvFmtGib(swapUsed)} / ${vvFmtGib(swapTotal)}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
<div style="width:${swapPct}%;height:100%;background:${swapColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
}
return html;
}
@@ -371,6 +412,50 @@ function vvScriptsFilterSet(type) {
vvRenderScripts();
}
// ── Disk / storage helpers (module-level so vvRenderPools can call them) ─────
function vvTempColor(tempC, transport) {
if (tempC === null) return '#444';
const isSsd = transport === 'nvme' || transport === 'ssd';
const warn = isSsd ? vvThresholds.ssd_warn : vvThresholds.hdd_warn;
const crit = isSsd ? vvThresholds.ssd_crit : vvThresholds.hdd_crit;
return tempC >= crit ? '#f44336' : tempC >= warn ? '#ff9800' : '#4caf50';
}
function vvFmt(v) { return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB'; }
function vvDiskRow(disk) {
const tempC = disk.temp;
const tempColor = vvTempColor(tempC, disk.transport);
const tempStr = tempC !== null ? `${tempC}°` : '—';
const isParity = disk.role === 'parity';
const nameColor = isParity ? '#6a8faf' : '#aaa';
const pct = disk.pct ?? 0;
const barColor = isParity ? '#1e3a5a'
: pct >= vvThresholds.util_crit ? '#f44336'
: pct >= vvThresholds.util_warn ? '#ff9800'
: '#4caf50';
const barWidth = isParity ? '100' : pct;
const spinLabel = (!isParity && !disk.mounted) ? `<span style="color:#555;font-size:9px;margin-left:4px;">↓</span>` : '';
const right = isParity
? `<span style="color:#444;font-size:10px;">${vvFmt(disk.size_gb)}</span>`
: `<span style="color:#555;font-size:10px;">${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}</span>`;
return `<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;">
<span style="color:${nameColor};">${disk.name}${spinLabel}</span>
${right}
<span style="color:${tempColor};font-size:10px;margin-left:6px;flex-shrink:0;">${tempStr}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${barWidth}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
}
function vvDiskCol(disks) {
return `<div style="flex:1;min-width:0;">${disks.map(vvDiskRow).join('')}</div>`;
}
// ── Poll ──────────────────────────────────────────────────────────────────────
function vvPollMonitor() {
@@ -378,6 +463,16 @@ function vvPollMonitor() {
.then(r => r.json())
.then(d => {
// ── API status banner ────────────────────────────────────────────────────
const apiStatus = d._api_status ?? {};
const banner = document.getElementById('vv-api-banner');
if (banner) {
const hasFallbacks = apiStatus.fallbacks && apiStatus.fallbacks.length > 0;
banner.style.display = hasFallbacks ? '' : 'none';
const listEl = document.getElementById('vv-api-fallback-list');
if (listEl && hasFallbacks) listEl.textContent = '(' + apiStatus.fallbacks.join(', ') + ')';
}
// ── Thresholds (from dynamix.cfg via backend) ────────────────────────────
if (d.thresholds) vvThresholds = d.thresholds;
@@ -390,64 +485,56 @@ function vvPollMonitor() {
const arrayColor = (sys.array_state === 'STARTED') ? '#4caf50' : '#f44336';
const ver = (sys.version || '').replace('version=','').replace(/"/g,'');
// Running container + VM counts for system card
const _runningCtrs = [...(d.docker_folders?.folders ?? []), {containers: d.docker_folders?.ungrouped ?? []}]
.flatMap(f => f.containers).filter(c => c.running).length;
const _runningVMs = (d.vms?.vms ?? []).filter(v => v.state === 'running').length;
const _threadInfo = sys.cpu_threads ? `${sys.cpu_cores}c / ${sys.cpu_threads}t` : '';
document.getElementById('vv-system-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px;">
<div>
<div style="font-size:14px;font-weight:700;color:#ddd;">${sys.name}</div>
<div style="font-size:11px;color:#666;margin-top:2px;">${sys.comment}</div>
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div style="min-width:0;">
<div style="font-size:14px;font-weight:700;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.name}</div>
<div style="font-size:10px;color:#555;margin-top:2px;">${sys.comment || '&nbsp;'}</div>
</div>
<div style="display:flex;flex-direction:column;gap:0;flex-shrink:0;margin-left:8px;align-items:flex-start;align-self:flex-start;">
<button onclick="vvArrayAction('stop')" class="vv-sys-btn" title="Stop Array" style="width:50px;min-width:0;padding:3px 0;margin:0;font-size:6px;line-height:1;display:block;box-sizing:border-box;overflow:hidden;">■</button>
<button onclick="vvArrayAction('shutdown')" class="vv-sys-btn" title="Shutdown" style="width:50px;min-width:0;padding:3px 0;margin:0;font-size:6px;line-height:1;display:block;box-sizing:border-box;overflow:hidden;">⏻</button>
<button onclick="vvArrayAction('restart')" class="vv-sys-btn" title="Restart" style="width:50px;min-width:0;padding:3px 0;margin:0;font-size:6px;line-height:1;display:block;box-sizing:border-box;overflow:hidden;">↺</button>
<div style="display:flex;align-items:flex-start;gap:6px;flex-shrink:0;margin-left:6px;">
<svg width="32" height="54" viewBox="0 0 38 64" fill="none" style="opacity:0.15;pointer-events:none;">
<rect x="1" y="1" width="36" height="62" rx="3" stroke="#aaa" stroke-width="1.2" fill="#111"/>
<rect x="1" y="1" width="36" height="10" rx="3" fill="#1c1c1c" stroke="#aaa" stroke-width="1.2"/>
<circle cx="19" cy="6" r="2.5" stroke="#ff9800" stroke-width="1" fill="none"/>
<line x1="19" y1="3.8" x2="19" y2="2.2" stroke="#ff9800" stroke-width="1"/>
<rect x="26" y="4" width="4" height="2" rx="0.5" fill="#555"/>
<rect x="3" y="14" width="16" height="44" rx="1" fill="#0d0d0d" stroke="#444" stroke-width="0.6"/>
<line x1="3" y1="18" x2="19" y2="18" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="22" x2="19" y2="22" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="26" x2="19" y2="26" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="30" x2="19" y2="30" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="34" x2="19" y2="34" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="38" x2="19" y2="38" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="42" x2="19" y2="42" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="46" x2="19" y2="46" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="50" x2="19" y2="50" stroke="#333" stroke-width="0.6"/>
<rect x="21" y="14" width="14" height="44" rx="1" fill="#08080f" stroke="#444" stroke-width="0.6" opacity="0.7"/>
<rect x="5" y="60" width="5" height="2" rx="1" fill="#333"/>
<rect x="28" y="60" width="5" height="2" rx="1" fill="#333"/>
</svg>
<a href="/" target="_blank" class="vv-card-cog" title="Dashboard" style="margin-top:1px;">⚙</a>
</div>
</div>
<div style="font-size:22px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div>
<div style="font-size:11px;color:#666;margin-bottom:12px;">${dateStr}, ${tz}</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;">
<span style="color:#555;">Model</span> <span style="color:#aaa;">${sys.cpu_model}</span>
<span style="color:#555;">Registration</span><span style="color:#aaa;">${sys.reg_type}</span>
<span style="color:#555;">Uptime</span> <span style="color:#aaa;">${sys.uptime}</span>
<span style="color:#555;">Array</span> <span style="color:${arrayColor};">${sys.array_state}</span>
<span style="color:#555;">Version</span> <span style="color:#555;">${ver}</span>
</div>
<svg width="38" height="64" viewBox="0 0 38 64" fill="none" xmlns="http://www.w3.org/2000/svg"
style="position:absolute;bottom:8px;right:8px;opacity:0.13;pointer-events:none;">
<!-- Case body -->
<rect x="1" y="1" width="36" height="62" rx="3" stroke="#aaa" stroke-width="1.2" fill="#111"/>
<!-- Top strip -->
<rect x="1" y="1" width="36" height="10" rx="3" fill="#1c1c1c" stroke="#aaa" stroke-width="1.2"/>
<!-- Power button -->
<circle cx="19" cy="6" r="2.5" stroke="#ff9800" stroke-width="1" fill="none"/>
<line x1="19" y1="3.8" x2="19" y2="2.2" stroke="#ff9800" stroke-width="1"/>
<!-- USB dots top -->
<rect x="26" y="4" width="4" height="2" rx="0.5" fill="#555"/>
<!-- Mesh front panel -->
<rect x="3" y="14" width="16" height="44" rx="1" fill="#0d0d0d" stroke="#444" stroke-width="0.6"/>
<!-- Mesh lines -->
<line x1="3" y1="17" x2="19" y2="17" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="20" x2="19" y2="20" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="23" x2="19" y2="23" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="26" x2="19" y2="26" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="29" x2="19" y2="29" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="32" x2="19" y2="32" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="35" x2="19" y2="35" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="38" x2="19" y2="38" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="41" x2="19" y2="41" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="44" x2="19" y2="44" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="47" x2="19" y2="47" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="50" x2="19" y2="50" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="53" x2="19" y2="53" stroke="#333" stroke-width="0.6"/>
<!-- Glass side panel -->
<rect x="21" y="14" width="14" height="44" rx="1" fill="#08080f" stroke="#444" stroke-width="0.6" opacity="0.7"/>
<!-- Bottom feet -->
<rect x="5" y="60" width="5" height="2" rx="1" fill="#333"/>
<rect x="28" y="60" width="5" height="2" rx="1" fill="#333"/>
</svg>`;
<div style="font-size:20px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div>
<div style="font-size:10px;color:#555;margin-bottom:10px;">${dateStr} &middot; ${tz}</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11px;">
<span style="color:#444;">Model</span> <span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.cpu_model}${_threadInfo ? ` <span style="color:#444;">(${_threadInfo})</span>` : ''}</span>
<span style="color:#444;">Array</span> <span style="color:${arrayColor};font-weight:600;">${sys.array_state}</span>
<span style="color:#444;">Uptime</span> <span style="color:#888;">${sys.uptime}</span>
<span style="color:#444;">Running</span> <span style="color:#888;">${_runningCtrs} ctr${_runningCtrs !== 1 ? 's' : ''}${_runningVMs > 0 ? ` · ${_runningVMs} VM` : ''}</span>
<span style="color:#444;">Version</span> <span style="color:#3a3a3a;">${ver}</span>
</div>`;
// ── Partner ──────────────────────────────────────────────────────────────
const pt = d.partner ?? {};
const ptHosts = pt.hosts ?? [];
const pt = d.partner ?? {};
const ptHosts = pt.hosts ?? [];
const ptRemote = d.remote_hosts ?? {};
const ptStatus = pt.enabled
? `<span style="color:#4caf50;">enabled</span> · sync every ${pt.sync_min}min`
: `<span style="color:#555;">disabled</span>`;
@@ -460,13 +547,52 @@ function vvPollMonitor() {
h.is_me ? `<span style="background:#1a3a1a;color:#4caf50;font-size:8px;padding:1px 5px;border-radius:3px;margin-left:4px;">US</span>` : '',
h.is_owner ? `<span style="background:#1a2a3a;color:#4a9eff;font-size:8px;padding:1px 5px;border-radius:3px;margin-left:4px;">OWNER</span>` : '',
].join('');
ptHtml += `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
<div>
<span style="font-size:10px;color:#555;margin-right:4px;">${h.id}</span>
<span style="font-size:12px;color:#ccc;font-weight:500;">${h.owner || h.hostname}</span>${tags}
<div style="font-size:10px;color:#555;margin-top:1px;">${h.hostname}</div>
// Remote stats from Unraid API (only available for non-self hosts with an API key)
const rs = !h.is_me ? (ptRemote[h.id] ?? null) : null;
let statsHtml = '';
if (rs && rs.available) {
const cpuAvail = rs.cpu_load > 0;
const ramAvail = rs.mem_used_pct > 0;
const cpuHue = cpuAvail ? Math.round(120 * (1 - rs.cpu_load / 100)) : 0;
const memHue = ramAvail ? Math.round(120 * (1 - rs.mem_used_pct / 100)) : 0;
const cpuStr = cpuAvail ? `<span style="color:hsl(${cpuHue},70%,45%);font-weight:600;">${rs.cpu_load}%</span>` : `<span style="color:#333;">—</span>`;
const ramStr = ramAvail ? `<span style="color:hsl(${memHue},70%,45%);font-weight:600;">${rs.mem_used_pct}%</span>` : `<span style="color:#333;">—</span>`;
const arrColor = rs.array_state === 'Started' || rs.array_state === 'STARTED' ? '#4caf50' : '#f44336';
const uptimeStr = rs.uptime && rs.uptime !== '—' ? rs.uptime : '—';
// Version mismatch warning — scripts will refuse sync operations until versions match
const myVer = (sys.version || '').replace('version=','').replace(/"/g,'').trim();
const remoteVer = (rs.version || '').trim();
const verMismatch = myVer && remoteVer && myVer !== remoteVer;
const verWarn = verMismatch
? `<div style="font-size:10px;color:#ff9800;margin-top:4px;padding:3px 6px;background:#1a1000;border:1px solid #3a2800;border-radius:3px;">
⚠ Version mismatch: local ${myVer} · remote ${remoteVer}<br>
<span style="color:#555;">Script sync ops are gated until versions match</span>
</div>` : '';
statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:10px;margin-top:5px;margin-bottom:2px;">
<span style="color:#444;">CPU</span>${cpuStr}
<span style="color:#444;">RAM</span>${ramStr}
<span style="color:#444;">Array</span>
<span style="color:${arrColor};font-weight:600;">${rs.array_state}</span>
<span style="color:#444;">Uptime</span>
<span style="color:#555;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${uptimeStr}</span>
</div>${verWarn}`;
} else if (rs && !rs.available) {
statsHtml = `<div style="font-size:10px;color:#444;margin-top:4px;">API unreachable</div>`;
}
ptHtml += `<div style="margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid #222;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<span style="font-size:10px;color:#555;margin-right:4px;">${h.id}</span>
<span style="font-size:12px;color:#ccc;font-weight:500;">${h.owner || h.hostname}</span>${tags}
<div style="font-size:10px;color:#555;margin-top:1px;">${h.hostname}</div>
</div>
<span style="color:${dot};font-size:10px;white-space:nowrap;">● ${label}</span>
</div>
<span style="color:${dot};font-size:10px;white-space:nowrap;">● ${label}</span>
${statsHtml}
</div>`;
});
document.getElementById('vv-partner-body').innerHTML = ptHtml;
@@ -564,10 +690,15 @@ function vvPollMonitor() {
document.getElementById('vv-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</p>';
}
// ── CPU title ────────────────────────────────────────────────────────────
const _cpuTitleEl = document.getElementById('vv-cpu-title');
if (_cpuTitleEl && sys.cpu_threads) _cpuTitleEl.textContent = `CPU · ${sys.cpu_cores}c/${sys.cpu_threads}t`;
// ── UPS / Power ─────────────────────────────────────────────────────────
const ups = d.ups ?? {};
if (ups.available) {
const onBatt = ups.status === 'ONBATT';
const statCls = onBatt ? 'vv-banner-err' : ups.status === 'ONLINE' ? 'vv-banner-ok' : 'vv-banner-warn';
const statColor = onBatt ? '#f44336' : ups.status === 'ONLINE' ? '#4caf50' : '#ff9800';
const loadPct = ups.load_pct ?? 0;
const loadHue = Math.round(120 * (1 - loadPct / 100));
@@ -579,34 +710,38 @@ function vvPollMonitor() {
const lineV = ups.line_v != null ? ups.line_v + ' V' : '—';
const outV = ups.output_v != null ? ups.output_v + ' V' : '—';
const xfers = ups.num_xfers ?? 0;
const batIcon = bPct >= 80 ? '▰▰▰▰' : bPct >= 60 ? '▰▰▰▱' : bPct >= 40 ? '▰▰▱▱' : bPct >= 20 ? '▰▱▱▱' : '▱▱▱▱';
document.getElementById('vv-ups-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="font-size:11px;color:#666;">${ups.model}</span>
<span style="font-size:11px;font-weight:600;color:${statColor};">${ups.status}</span>
`<div class="vv-banner ${statCls}" style="margin-bottom:8px;">
<span>${ups.status}${onBatt ? ' — ON BATTERY' : ''}</span>
<span style="font-size:11px;font-weight:400;opacity:0.8;">${ups.model}</span>
</div>
<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px;">
<span style="color:#666;">Load</span>
<span style="color:#999;">${loadPct.toFixed(1)}% · ${watts}</span>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;">
<div>
<div style="font-size:10px;color:#555;margin-bottom:3px;">Load</div>
<div style="font-size:15px;font-weight:600;color:hsl(${loadHue},70%,45%);line-height:1;">${loadPct.toFixed(0)}<span style="font-size:10px;font-weight:400;color:#555;">%</span></div>
<div style="font-size:10px;color:#444;margin-top:2px;">${watts}</div>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${loadPct}%;height:100%;background:hsl(${loadHue},70%,45%);border-radius:3px;transition:width 0.4s;"></div>
<div>
<div style="font-size:10px;color:#555;margin-bottom:3px;">Battery</div>
<div style="font-size:15px;font-weight:600;color:${bColor};line-height:1;">${bPct.toFixed(0)}<span style="font-size:10px;font-weight:400;color:#555;">%</span></div>
<div style="font-size:10px;color:#444;margin-top:2px;">${timeLeft}</div>
</div>
</div>
<div style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px;">
<span style="color:#666;">Battery</span>
<span style="color:#999;">${bPct.toFixed(1)}% · ${timeLeft}</span>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:8px;">
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
<div style="width:${loadPct}%;height:100%;background:hsl(${loadHue},70%,45%);transition:width 0.4s;"></div>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${bPct}%;height:100%;background:${bColor};border-radius:3px;transition:width 0.4s;"></div>
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
<div style="width:${bPct}%;height:100%;background:${bColor};transition:width 0.4s;"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;">
<span style="color:#555;">Line in</span> <span style="color:#888;">${lineV}</span>
<span style="color:#555;">Output</span> <span style="color:#888;">${outV}</span>
<span style="color:#555;">Transfers</span> <span style="color:${xfers > 0 ? '#ff9800' : '#555'};">${xfers}</span>
<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:3px 8px;font-size:10px;">
<span style="color:#444;">In</span> <span style="color:#777;">${lineV}</span>
<span style="color:#444;">Out</span> <span style="color:#777;">${outV}</span>
<span style="color:#444;">Xfers</span> <span style="color:${xfers > 0 ? '#ff9800' : '#444'};">${xfers}</span>
<span style="color:#444;">Test</span> <span style="color:#555;">${ups.selftest || '—'}</span>
</div>`;
} else {
document.getElementById('vv-ups-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No UPS detected</p>';
@@ -647,12 +782,16 @@ function vvPollMonitor() {
}
const exitColor = par.exit_label === 'Completed' ? '#4caf50' : par.exit_label === 'Aborted' ? '#ff9800' : '#f44336';
const errColor = (par.errors ?? 0) > 0 ? '#f44336' : '#555';
const errColor = (par.errors ?? 0) > 0 ? '#f44336' : '#444';
const speedStr = par.last_speed_mb ? ` · ${par.last_speed_mb} MB/s` : '';
const nextDate = vvFmtDate(par.next_ts);
const dueIn = vvDueIn(par.next_ts);
const parBannerCls = !valid ? 'vv-banner-err' : (par.errors ?? 0) > 0 ? 'vv-banner-warn' : 'vv-banner-ok';
let html = `<div style="font-size:13px;font-weight:600;color:${validColor};margin-bottom:10px;">${validLabel}</div>`;
let html = `<div class="vv-banner ${parBannerCls}">
<span>${valid ? '✓ Valid' : '✗ INVALID'}</span>
${(par.errors ?? 0) > 0 ? `<span style="font-size:11px;">${par.errors} error${par.errors !== 1 ? 's' : ''}</span>` : ''}
</div>`;
if (inProg) {
const pct = par.resync_pct ?? 0;
@@ -678,59 +817,170 @@ function vvPollMonitor() {
document.getElementById('vv-parity-body').innerHTML = html;
})();
// ── Storage helpers ──────────────────────────────────────────────────────
function vvTempColor(tempC, transport) {
if (tempC === null) return '#444';
const isSsd = transport === 'nvme' || transport === 'ssd';
const warn = isSsd ? vvThresholds.ssd_warn : vvThresholds.hdd_warn;
const crit = isSsd ? vvThresholds.ssd_crit : vvThresholds.hdd_crit;
return tempC >= crit ? '#f44336' : tempC >= warn ? '#ff9800' : '#4caf50';
}
function vvFmt(v) { return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB'; }
// ── Watchdog ─────────────────────────────────────────────────────────────
(function() {
const wd = d.watchdog ?? {};
const el = document.getElementById('vv-watchdog-body');
if (!el) return;
function vvDiskRow(disk) {
const tempC = disk.temp;
const tempColor = vvTempColor(tempC, disk.transport);
const tempStr = tempC !== null ? `${tempC}°` : '—';
const isParity = disk.role === 'parity';
const nameColor = isParity ? '#6a8faf' : '#aaa';
const pct = disk.pct ?? 0;
const barColor = isParity ? '#1e3a5a'
: pct >= vvThresholds.util_crit ? '#f44336'
: pct >= vvThresholds.util_warn ? '#ff9800'
: '#4caf50';
const barWidth = isParity ? '100' : pct;
const spinLabel = (!isParity && !disk.mounted) ? `<span style="color:#555;font-size:9px;margin-left:4px;">↓</span>` : '';
const right = isParity
? `<span style="color:#444;font-size:10px;">${vvFmt(disk.size_gb)}</span>`
: `<span style="color:#555;font-size:10px;">${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}</span>`;
return `<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;">
<span style="color:${nameColor};">${disk.name}${spinLabel}</span>
${right}
<span style="color:${tempColor};font-size:10px;margin-left:6px;flex-shrink:0;">${tempStr}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${barWidth}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
const healthy = wd.healthy ?? true;
const ctrStrikes = wd.ctr_strikes ?? {};
const ctrNames = Object.keys(ctrStrikes);
const rwLevel = wd.rw_level ?? 0;
const rwPaused = wd.rw_paused ?? [];
const rwStopped = wd.rw_stopped ?? [];
const daemonHit = (wd.daemon_strikes ?? 0) > 0;
const oom = wd.oom_count ?? 0;
const reboots = wd.reboots_12h ?? 0;
const restartCount = wd.restart_count ?? 0;
const restarts = wd.restarts_24h ?? [];
const stab = wd.stability ?? {};
const stabStrikes = stab.strikes ?? {};
const stabNames = Object.keys(stabStrikes);
const storWd = wd.storage_wd ?? {};
const growthStr = storWd.growth_strikes ?? {};
const logStr = storWd.log_strikes ?? {};
const storIssues = Object.keys(growthStr).length + Object.keys(logStr).length;
const netWd = wd.network_wd ?? {};
const npmStrikes = netWd.npm_strikes ?? 0;
const issueCount = ctrNames.length + stabNames.length + storIssues
+ (daemonHit ? 1 : 0) + (oom > 0 ? 1 : 0)
+ (reboots > 0 ? 1 : 0) + (npmStrikes > 0 ? 1 : 0);
const bannerCls = healthy ? 'vv-banner-ok' : (reboots || oom || daemonHit ? 'vv-banner-err' : 'vv-banner-warn');
const bannerTxt = healthy ? '✓ All clear' : `⚠ ${issueCount} issue${issueCount !== 1 ? 's' : ''}`;
let html = `<div class="vv-banner ${bannerCls}">${bannerTxt}`;
if (reboots > 0) html += `<span style="font-size:10px;">${reboots} reboot${reboots !== 1 ? 's' : ''}/12h</span>`;
html += `</div>`;
// ── Alerts (critical items) ──────────────────────────────────────────
if (rwLevel > 0) {
const rwColor = rwLevel >= 3 ? '#f44336' : rwLevel >= 2 ? '#ff9800' : '#fdd835';
const rwLabel = ['', 'Soft', 'Medium', 'Hard'][rwLevel] ?? `L${rwLevel}`;
html += `<div style="font-size:11px;color:${rwColor};margin-bottom:4px;">⚡ Resource mgr: ${rwLabel}`;
if (rwPaused.length) html += ` · ${rwPaused.length} paused`;
if (rwStopped.length) html += ` · ${rwStopped.length} stopped`;
html += `</div>`;
}
if (daemonHit) html += `<div style="font-size:11px;color:#f44336;margin-bottom:4px;">✗ Docker daemon strikes</div>`;
if (oom > 0) html += `<div style="font-size:11px;color:#f44336;margin-bottom:4px;">✗ OOM events: ${oom}</div>`;
// ── System stats grid ────────────────────────────────────────────────
function wdPct(v, warn, crit) {
return v >= crit ? '#f44336' : v >= warn ? '#ff9800' : '#4caf50';
}
const ramFree = stab.ram_free_gb ?? 0;
const ramColor = ramFree < 6 ? '#f44336' : ramFree < 12 ? '#ff9800' : '#4caf50';
const load = stab.load_1min ?? 0;
const loadColor = load > 6 ? '#f44336' : load > 3 ? '#ff9800' : '#4caf50';
const nicOk = (stab.nic_state ?? '') === 'up';
const sshdOk = stab.sshd_ok ?? true;
const zombies = stab.zombies ?? 0;
let statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 8px;font-size:11px;margin-top:8px;margin-bottom:6px;">
<span style="color:#444;">rootfs</span><span style="color:${wdPct(stab.rootfs_pct??0,75,90)};">${stab.rootfs_pct??0}%</span>
<span style="color:#444;">/var/log</span><span style="color:${wdPct(stab.log_pct??0,75,90)};">${stab.log_pct??0}%</span>
<span style="color:#444;">/tmp</span><span style="color:${wdPct(stab.tmp_pct??0,75,90)};">${stab.tmp_pct??0}%</span>
<span style="color:#444;">RAM free</span><span style="color:${ramColor};">${ramFree}GB</span>
<span style="color:#444;">Load</span><span style="color:${loadColor};">${load}</span>
${stab.cpu_temp != null ? `<span style="color:#444;">CPU</span><span style="color:${wdPct(stab.cpu_temp,75,90)};">${stab.cpu_temp}°C</span>` : ''}
<span style="color:#444;">Zombies</span><span style="color:${zombies>0?'#ff9800':'#4caf50'};">${zombies}</span>
<span style="color:#444;">${stab.nic??'nic'}</span><span style="color:${nicOk?'#4caf50':'#f44336'};">● ${stab.nic_state??'?'}</span>
<span style="color:#444;">sshd</span><span style="color:${sshdOk?'#4caf50':'#f44336'};">${sshdOk?'● ok':'✗ down'}</span>
<span style="color:#444;">NPM</span><span style="color:${npmStrikes>0?'#ff9800':'#4caf50'};">${npmStrikes>0?npmStrikes+'× strikes':'● ok'}</span>
</div>`;
}
html += statsHtml;
function vvDiskCol(disks) {
return `<div style="flex:1;min-width:0;">${disks.map(vvDiskRow).join('')}</div>`;
}
// ── Stability strikes (system watchdog) ──────────────────────────────
if (stabNames.length) {
html += `<div style="font-size:10px;color:#555;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Stability strikes</div>`;
stabNames.forEach(k => {
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:#ff9800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${k}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${stabStrikes[k]}×</span>
</div>`;
});
}
// ── Pools — single column ─────────────────────────────────────────────────
const storageDisks = d.storage ?? [];
if (storageDisks.length) {
document.getElementById('vv-storage-body').innerHTML = storageDisks.map(vvDiskRow).join('');
} else {
document.getElementById('vv-storage-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No pools found</p>';
}
// ── Storage watchdog strikes ─────────────────────────────────────────
if (storIssues > 0) {
html += `<div style="font-size:10px;color:#555;margin-top:${stabNames.length?6:0}px;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Storage watchdog</div>`;
Object.entries(growthStr).forEach(([k, v]) => {
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:#ff9800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">↑ ${k}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${v}×</span>
</div>`;
});
Object.entries(logStr).forEach(([k, v]) => {
const shortKey = k.length > 22 ? '…' + k.slice(-22) : k;
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:#ff9800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;" title="${k}">log ${shortKey}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${v}×</span>
</div>`;
});
}
// ── Array disks — min 3 columns, balanced left-to-right, grows as needed ───
// ── Container strikes ────────────────────────────────────────────────
if (ctrNames.length) {
html += `<div style="font-size:10px;color:#555;margin-top:${stabNames.length||storIssues?6:0}px;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Container strikes</div>`;
ctrNames.forEach(name => {
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:#f44336;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${name}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${ctrStrikes[name]}×</span>
</div>`;
});
}
// ── Recent restarts ──────────────────────────────────────────────────
if (restartCount > 0) {
const hasAbove = ctrNames.length || stabNames.length || storIssues;
html += `<div style="font-size:10px;color:#555;margin-top:${hasAbove?6:0}px;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Restarts 24h <span style="color:#ff9800;">${restartCount}</span></div>`;
const now = Math.floor(Date.now() / 1000);
restarts.forEach(r => {
const diff = now - r.ts;
const ago = diff < 3600 ? Math.floor(diff / 60) + 'm' : Math.floor(diff / 3600) + 'h';
html += `<div style="display:flex;justify-content:space-between;font-size:10px;color:#666;margin-bottom:2px;">
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${r.name}</span>
<span style="flex-shrink:0;margin-left:6px;color:#444;">${ago}</span>
</div>`;
});
}
el.innerHTML = html;
})();
// ── Pools ─────────────────────────────────────────────────────────────────
vvLastStorageDisks = d.storage ?? [];
document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks);
// ── Array disks — summary header + min 3 columns ─────────────────────────
const arrayDisks = d.array_disks ?? [];
if (arrayDisks.length) {
const dataDisks = arrayDisks.filter(d => d.role === 'data');
const okDisks = dataDisks.filter(d => d.status === 'DISK_OK').length;
const errDisks = dataDisks.length - okDisks;
const temps = arrayDisks.map(d => d.temp).filter(t => t != null);
const maxTemp = temps.length ? Math.max(...temps) : null;
const maxTempColor = maxTemp == null ? '#444' : maxTemp >= (vvThresholds.hdd_crit ?? 55) ? '#f44336' : maxTemp >= (vvThresholds.hdd_warn ?? 45) ? '#ff9800' : '#4caf50';
const totalUsedGb = dataDisks.reduce((s, d) => s + (d.used_gb ?? 0), 0);
const totalSizeGb = dataDisks.reduce((s, d) => s + (d.size_gb ?? 0), 0);
const arrPct = totalSizeGb > 0 ? Math.round(totalUsedGb / totalSizeGb * 100) : 0;
const arrPctColor = arrPct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : arrPct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50';
const titleEl = document.getElementById('vv-array-title');
if (titleEl) titleEl.innerHTML = `Array
<span style="font-size:10px;color:#444;font-weight:400;text-transform:none;letter-spacing:0;margin-left:6px;">
${okDisks}/${dataDisks.length} ok
${errDisks > 0 ? `<span style="color:#f44336;margin-left:4px;">· ${errDisks} err</span>` : ''}
· <span style="color:${arrPctColor};">${arrPct}%</span> used
${maxTemp != null ? `· <span style="color:${maxTempColor};">max ${maxTemp}°</span>` : ''}
</span>`;
const numCols = Math.max(3, Math.ceil(arrayDisks.length / 6));
const perCol = Math.ceil(arrayDisks.length / numCols);
const cols = [];
@@ -1013,6 +1263,126 @@ vvPollStreams();
setInterval(vvPollStreams, 12000);
setInterval(vvRenderStreams, 1000);
// ── Pools card ────────────────────────────────────────────────────────────────
function vvTogglePool(name) {
vvPoolsOpen[name] = !vvPoolsOpen[name];
document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks);
}
function vvTogglePoolGroup(groupName) {
vvPoolGroupOpen[groupName] = !vvPoolGroupOpen[groupName];
document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks);
}
function vvRenderPools(disks) {
if (!disks.length) return '<p style="color:#555;font-style:italic;font-size:12px;">No pools found</p>';
// Group drives by pool name
const poolMap = {};
disks.forEach(d => {
const n = d.name;
if (!poolMap[n]) poolMap[n] = [];
poolMap[n].push(d);
});
// Build super-groups: pools whose names share a common prefix + trailing digits
const superGroupMap = {}; // groupName -> [poolName, ...]
Object.keys(poolMap).forEach(poolName => {
const match = poolName.match(/^(.+?)(\d+)$/);
const gName = match ? match[1] : poolName;
if (!superGroupMap[gName]) superGroupMap[gName] = [];
superGroupMap[gName].push(poolName);
});
// Render a single pool row (with optional drive-expand toggle)
function renderPoolRow(poolName, drives) {
const isOpen = !!vvPoolsOpen[poolName];
const multi = drives.length > 1;
const totalGb = drives.reduce((s, d) => s + (d.size_gb ?? 0), 0);
const usedGb = drives.reduce((s, d) => s + (d.used_gb ?? 0), 0);
const pct = totalGb > 0 ? Math.round(usedGb / totalGb * 100) : 0;
const pctColor = pct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : pct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50';
const temps = drives.map(d => d.temp).filter(t => t != null);
const maxTemp = temps.length ? Math.max(...temps) : null;
const tempColor = maxTemp != null ? vvTempColor(maxTemp, drives[0].transport) : '#444';
const allOk = drives.every(d => d.status === 'DISK_OK' || !d.status);
const statusColor = allOk ? '#aaa' : '#f44336';
const sName = poolName.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
let html = `<div style="margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;
${multi ? 'cursor:pointer;' : ''}" ${multi ? `onclick="vvTogglePool('${sName}')"` : ''}>
<span style="color:${statusColor};display:flex;align-items:center;gap:5px;">
${multi ? `<span style="color:#555;font-size:9px;width:8px;">${isOpen ? '▾' : '▸'}</span>` : '<span style="width:8px;display:inline-block;"></span>'}
${poolName}
${multi ? `<span style="font-size:9px;color:#444;background:#1a1a1a;padding:0 4px;border-radius:2px;">${drives.length} drives</span>` : ''}
</span>
<span style="display:flex;align-items:center;gap:8px;">
${maxTemp != null ? `<span style="color:${tempColor};font-size:10px;">${maxTemp}°</span>` : ''}
<span style="color:#555;font-size:10px;">${vvFmt(usedGb)} / ${vvFmt(totalGb)}</span>
</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${pctColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
if (multi && isOpen) {
html += `<div style="padding-left:12px;border-left:1px solid #252525;margin-bottom:8px;margin-top:-4px;">`;
drives.forEach(d => { html += vvDiskRow({ ...d, name: d.device || d.name }); });
html += `</div>`;
}
return html;
}
let html = '';
Object.entries(superGroupMap).forEach(([groupName, poolNames]) => {
if (poolNames.length === 1) {
// Single pool — render directly, no super-group header
html += renderPoolRow(poolNames[0], poolMap[poolNames[0]]);
} else {
// Super-group: aggregate header + expandable list of individual pools
const isGroupOpen = !!vvPoolGroupOpen[groupName];
const sGroup = groupName.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const allDrives = poolNames.flatMap(n => poolMap[n]);
const totalGb = allDrives.reduce((s, d) => s + (d.size_gb ?? 0), 0);
const usedGb = allDrives.reduce((s, d) => s + (d.used_gb ?? 0), 0);
const pct = totalGb > 0 ? Math.round(usedGb / totalGb * 100) : 0;
const pctColor = pct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : pct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50';
const temps = allDrives.map(d => d.temp).filter(t => t != null);
const maxTemp = temps.length ? Math.max(...temps) : null;
const tempColor = maxTemp != null ? vvTempColor(maxTemp, allDrives[0].transport) : '#444';
html += `<div style="margin-bottom:${isGroupOpen ? '2' : '8'}px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;cursor:pointer;"
onclick="vvTogglePoolGroup('${sGroup}')">
<span style="color:#aaa;display:flex;align-items:center;gap:5px;">
<span style="color:#555;font-size:9px;width:8px;">${isGroupOpen ? '▾' : '▸'}</span>
${groupName}
<span style="font-size:9px;color:#444;background:#1a1a1a;padding:0 4px;border-radius:2px;">${poolNames.length} pools</span>
</span>
<span style="display:flex;align-items:center;gap:8px;">
${maxTemp != null ? `<span style="color:${tempColor};font-size:10px;">${maxTemp}°</span>` : ''}
<span style="color:#555;font-size:10px;">${vvFmt(usedGb)} / ${vvFmt(totalGb)}</span>
</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${pctColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
if (isGroupOpen) {
html += `<div style="padding-left:12px;border-left:1px solid #252525;margin-bottom:8px;margin-top:2px;">`;
poolNames.forEach(poolName => { html += renderPoolRow(poolName, poolMap[poolName]); });
html += `</div>`;
}
}
});
return html;
}
// ── System clock tick (updates time every 30s without a full poll) ────────────
function vvTickClock() {
const el = document.getElementById('vv-system-body');
@@ -1061,6 +1431,13 @@ function vvRenderDockerFolders(data) {
const el = document.getElementById('vv-docker-folders-body');
if (!el || !data) return;
// Update container count badge in header
const allCtrs = [...(data.folders ?? []).flatMap(f => f.containers), ...(data.ungrouped ?? [])];
const totalCtrs = allCtrs.length;
const runCtrs = allCtrs.filter(c => c.running).length;
const countEl = document.getElementById('vv-docker-count');
if (countEl && totalCtrs > 0) countEl.textContent = `${runCtrs}/${totalCtrs}`;
const osIcon = os => ({ windows:'🪟', macos:'🍎', bsd:'🦬' })[os] ?? '🐧';
const stateColor = s => ({ running:'#4caf50', paused:'#ff9800' })[s] ?? '#444';
const stateLabel = s => ({ running:'Running', paused:'Paused', 'shut off':'Off', crashed:'Crashed' })[s] ?? s;
@@ -1167,17 +1544,18 @@ function vvRenderDockerFolders(data) {
return;
}
// 2-column balanced split — single column on narrow screens
if (window.innerWidth <= 640) {
html += `<div class="vv-df-col" style="min-width:0;">${allFolders.map(renderFolder).join('')}</div>`;
// Column count: 3 big / 2 intermediate / 1 small
const _w = window.innerWidth;
const _cols = _w > 1400 ? 3 : _w > 640 ? 2 : 1;
if (_cols === 1) {
html += `<div class="vv-df-col">${allFolders.map(renderFolder).join('')}</div>`;
} else {
const half = Math.ceil(allFolders.length / 2);
const left = allFolders.slice(0, half);
const right = allFolders.slice(half);
html += `<div class="vv-df-cols">
<div class="vv-df-col">${left.map(renderFolder).join('')}</div>
<div class="vv-df-col">${right.map(renderFolder).join('')}</div>
</div>`;
const perCol = Math.ceil(allFolders.length / _cols);
const colDivs = Array.from({length: _cols}, (_, i) =>
`<div class="vv-df-col">${allFolders.slice(i * perCol, (i + 1) * perCol).map(renderFolder).join('')}</div>`
).join('');
html += `<div class="vv-df-cols">${colDivs}</div>`;
}
el.innerHTML = html;