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