Files
Varaverk/Plugin/unraid/pages/monitor.php
T
Gmer4LfeandClaude Sonnet 4.6 67e274fd4e ui: transcode active sessions — static 2×2 balanced grid, no scroll
Splits up to 4 sessions into two balanced columns (ceil/floor split),
separated by a subtle divider. A '+N more' line appears beyond 4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:16:46 -04:00

1953 lines
106 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php require_once dirname(__DIR__) . '/include/monitor.php'; ?>
<div id="vv-api-banner" style="display:none;border-radius:4px;padding:5px 10px;margin-bottom:8px;font-size:11px;"></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;">
<div id="vv-system-body">Loading...</div>
</div>
<div class="vv-card" id="vv-ups-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<svg width="9" height="14" viewBox="0 0 11 18" fill="none" style="opacity:0.4;flex-shrink:0;">
<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>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><rect x="3" y="3" width="6" height="6" rx="0.8"/><line x1="4.5" y1="3" x2="4.5" y2="1.2"/><line x1="7.5" y1="3" x2="7.5" y2="1.2"/><line x1="4.5" y1="9" x2="4.5" y2="10.8"/><line x1="7.5" y1="9" x2="7.5" y2="10.8"/><line x1="3" y1="4.5" x2="1.2" y2="4.5"/><line x1="3" y1="7.5" x2="1.2" y2="7.5"/><line x1="9" y1="4.5" x2="10.8" y2="4.5"/><line x1="9" y1="7.5" x2="10.8" y2="7.5"/></svg></span>
<span id="vv-cpu-title">CPU</span>
</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>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="8" viewBox="0 0 14 8" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><rect x="0.6" y="2.5" width="12.8" height="3" rx="0.5"/><line x1="3" y1="2.5" x2="3" y2="0.8"/><line x1="5.5" y1="2.5" x2="5.5" y2="0.8"/><line x1="8" y1="2.5" x2="8" y2="0.8"/><line x1="10.5" y1="2.5" x2="10.5" y2="0.8"/><line x1="3" y1="5.5" x2="3" y2="7.2"/><line x1="5.5" y1="5.5" x2="5.5" y2="7.2"/><line x1="8" y1="5.5" x2="8" y2="7.2"/><line x1="10.5" y1="5.5" x2="10.5" y2="7.2"/></svg></span>
Memory
</span>
<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>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="13" height="12" viewBox="0 0 13 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><rect x="4.5" y="0.5" width="4" height="3" rx="0.7"/><rect x="0.5" y="8" width="4" height="3" rx="0.7"/><rect x="8.5" y="8" width="4" height="3" rx="0.7"/><line x1="6.5" y1="3.5" x2="6.5" y2="6"/><line x1="6.5" y1="6" x2="2.5" y2="6"/><line x1="2.5" y1="6" x2="2.5" y2="8"/><line x1="6.5" y1="6" x2="10.5" y2="6"/><line x1="10.5" y1="6" x2="10.5" y2="8"/></svg></span>
Network
</span>
<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 | Partner | Containers & VMs -->
<div class="vv-card" id="vv-scripts-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="1" width="10" height="10" rx="1.5"/><line x1="1" y1="4.5" x2="11" y2="4.5"/><polyline points="3.5,7 5,8 3.5,9"/><line x1="6" y1="9" x2="8.5" y2="9"/></svg></span>
Scripts
</span>
<a href="/Apps/plugin_userscripts" target="_blank" class="vv-card-cog" title="User Scripts">⚙</a>
</h3>
<div id="vv-scripts-body">Loading...</div>
</div>
<div class="vv-card" id="vv-fallback" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="10" height="12" viewBox="0 0 10 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><path d="M5 0.8L9.2 2.8V6.2C9.2 9 5 11.2 5 11.2C5 11.2 0.8 9 0.8 6.2V2.8Z"/></svg></span>
Fallback
</span>
</h3>
<div id="vv-fallback-body">Loading...</div>
</div>
<div class="vv-card" id="vv-partner" style="grid-column:span 2;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><rect x="0.5" y="1.5" width="4" height="7" rx="0.8"/><rect x="9.5" y="1.5" width="4" height="7" rx="0.8"/><line x1="4.5" y1="5" x2="9.5" y2="5"/><circle cx="7" cy="5" r="1" fill="#888" stroke="none"/></svg></span>
Partner
</span>
<img src="/plugins/varaverk/icons/varaverk.png" style="width:16px;height:16px;object-fit:contain;opacity:0.2;flex-shrink:0;" title="Varaverk">
</h3>
<div id="vv-partner-body">Loading...</div>
</div>
<div class="vv-card" id="vv-docker-folders" style="grid-column:span 4;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="0.5" width="12" height="2.5" rx="0.5"/><rect x="1" y="3.8" width="12" height="2.5" rx="0.5"/><rect x="1" y="7" width="12" height="2.5" rx="0.5"/></svg></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>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="15" height="10" viewBox="0 0 16 10" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="0.8" y="0.8" width="14.4" height="7" rx="1.2"/><rect x="2.5" y="2.5" width="3.5" height="3.5" rx="0.5"/><line x1="8" y1="3" x2="13" y2="3"/><line x1="8" y1="5" x2="11" y2="5"/><rect x="3" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/><rect x="6.5" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/><rect x="10" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/></svg></span>
GPU
</span>
<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>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="12" viewBox="0 0 14 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="0.5" y="1" width="4" height="10" rx="0.8"/><line x1="0.5" y1="3.5" x2="2.3" y2="3.5"/><line x1="0.5" y1="8.5" x2="2.3" y2="8.5"/><line x1="2.7" y1="3.5" x2="4.5" y2="3.5"/><line x1="2.7" y1="8.5" x2="4.5" y2="8.5"/><line x1="6" y1="6" x2="9.5" y2="6"/><polyline points="8.2,4.3 10.2,6 8.2,7.7"/><rect x="11" y="1" width="2.5" height="10" rx="0.5"/></svg></span>
Transcode
</span>
</h3>
<div id="vv-transcode-body">Loading...</div>
</div>
<div class="vv-card" id="vv-streams" style="grid-column:span 4;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><polygon points="1.5,1 1.5,11 10,6"/></svg></span>
Streams
</span>
</h3>
<div id="vv-streams-body">Loading...</div>
</div>
<!-- Row 4: Watchdog | Parity | Pools | Array -->
<div class="vv-card" id="vv-watchdog-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="9" viewBox="0 0 14 8" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><path d="M1 4C3.2 1 5 0.5 7 0.5C9 0.5 10.8 1 13 4C10.8 7 9 7.5 7 7.5C5 7.5 3.2 7 1 4Z"/><circle cx="7" cy="4" r="1.5"/></svg></span>
Watchdog
</span>
</h3>
<div id="vv-watchdog-body">Loading...</div>
</div>
<div class="vv-card" id="vv-parity-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="5"/><polyline points="3.5,6 5.5,8 8.5,4"/></svg></span>
Parity
</span>
<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>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><ellipse cx="6" cy="3" rx="4.5" ry="1.5"/><line x1="1.5" y1="3" x2="1.5" y2="9"/><line x1="10.5" y1="3" x2="10.5" y2="9"/><ellipse cx="6" cy="9" rx="4.5" ry="1.5"/></svg></span>
Pools
</span>
<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>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="12" viewBox="0 0 14 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><rect x="0.7" y="0.7" width="12.6" height="3" rx="0.8"/><rect x="0.7" y="4.5" width="12.6" height="3" rx="0.8"/><rect x="0.7" y="8.3" width="12.6" height="3" rx="0.8"/><circle cx="11.5" cy="2.2" r="0.8" fill="#888" stroke="none"/><circle cx="11.5" cy="6" r="0.8" fill="#888" stroke="none"/><circle cx="11.5" cy="9.8" r="0.8" fill="#888" stroke="none"/></svg></span>
<span id="vv-array-title">Array</span>
</span>
<a href="/Main" target="_blank" class="vv-card-cog" title="Array Management">⚙</a>
</h3>
<div id="vv-array-body">Loading...</div>
</div>
</div>
<script>
// ── Shared helpers ────────────────────────────────────────────────────────────
function vvMeter(label, pct, text) {
const color = `hsl(${Math.round(120 * (1 - pct / 100))},70%,45%)`;
return `<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;font-size:11px;color:#666;margin-bottom:3px;">
<span>${label}</span><span style="color:#999;">${text}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${color};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
}
// ── Rolling history ───────────────────────────────────────────────────────────
const VV_HIST_MAX = 24; // 24 × 5 s = 120 s
let vvCpuHistory = [];
let vvLastSessions = [];
let vvLastStreamPollAt = 0;
let vvLastStreamNames = [];
let vvStreamServerCount = 0;
let vvScriptsFilter = null;
let vvLastScripts = {};
let vvThresholds = {util_warn:70,util_crit:90,hdd_warn:45,hdd_crit:55,ssd_warn:60,ssd_crit:70};
let vvNetRxHistory = [];
let vvNetTxHistory = [];
let vvPoolsOpen = {};
let vvPoolGroupOpen = {};
let vvLastStorageDisks = [];
// ── Canvas chart ──────────────────────────────────────────────────────────────
function vvDrawChart(canvas, data, lineColor, fillColor, grid = false) {
if (!canvas) return;
const W = canvas.offsetWidth || 200;
const H = canvas.offsetHeight || 36;
if (canvas.width !== W) canvas.width = W;
if (canvas.height !== H) canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
// grid lines
if (grid) {
ctx.save();
ctx.setLineDash([2, 3]);
ctx.lineWidth = 0.5;
[25, 50, 75].forEach(pct => {
const y = H - (pct / 100) * (H - 2) - 1;
ctx.strokeStyle = '#2e2e2e';
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(W, y);
ctx.stroke();
ctx.fillStyle = '#3a3a3a';
ctx.font = '7px monospace';
ctx.textAlign = 'left';
ctx.fillText(pct + '%', 2, y - 2);
});
ctx.restore();
}
if (data.length < 2) return;
const step = W / (VV_HIST_MAX - 1);
const pts = data.map((v, i) => [i * step, H - (v / 100) * (H - 2) - 1]);
// fill
ctx.beginPath();
ctx.moveTo(0, H);
pts.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.lineTo((data.length - 1) * step, H);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
// line
ctx.beginPath();
pts.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y));
ctx.strokeStyle = lineColor;
ctx.lineWidth = 1.5;
ctx.stroke();
}
// ── Network helpers ───────────────────────────────────────────────────────────
function vvFmtBps(bps) {
if (bps >= 1e9) return (bps / 1e9).toFixed(2) + ' Gb/s';
if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' Mb/s';
if (bps >= 1e3) return (bps / 1e3).toFixed(0) + ' Kb/s';
return bps + ' B/s';
}
function vvDrawNetChart(canvas, rxData, txData, maxBps) {
if (!canvas) return;
const W = canvas.offsetWidth || 200;
const H = canvas.offsetHeight || 52;
if (canvas.width !== W) canvas.width = W;
if (canvas.height !== H) canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
const scale = maxBps > 0 ? maxBps : 1;
const toY = v => H - (v / scale) * (H - 2) - 1;
const step = W / (VV_HIST_MAX - 1);
// grid — labels show actual bandwidth at each line
ctx.save();
ctx.setLineDash([2, 3]);
ctx.lineWidth = 0.5;
[0.25, 0.5, 0.75].forEach(frac => {
const y = H - frac * (H - 2) - 1;
ctx.strokeStyle = '#2e2e2e';
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
ctx.fillStyle = '#3a3a3a'; ctx.font = '7px monospace'; ctx.textAlign = 'left';
ctx.fillText(vvFmtBps(maxBps * frac), 2, y - 2);
});
ctx.restore();
const drawLine = (data, lineColor, fillColor) => {
if (data.length < 2) return;
const pts = data.map((v, i) => [i * step, toY(v)]);
ctx.beginPath(); ctx.moveTo(0, H);
pts.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.lineTo((data.length - 1) * step, H);
ctx.closePath(); ctx.fillStyle = fillColor; ctx.fill();
ctx.beginPath();
pts.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y));
ctx.strokeStyle = lineColor; ctx.lineWidth = 1.5; ctx.stroke();
};
drawLine(txData, '#ff9800', 'rgba(255,152,0,0.10)');
drawLine(rxData, '#4caf50', 'rgba(76,175,80,0.12)');
}
// ── CPU helpers ───────────────────────────────────────────────────────────────
function vvCoreColor(freqMhz, maxMhz, minMhz) {
if (!freqMhz || !maxMhz || maxMhz === minMhz) return '#4caf50';
const t = Math.max(0, Math.min(1, (freqMhz - minMhz) / (maxMhz - minMhz)));
// blue(220°) → green(120°) → red(0°) as t goes 0→1
const hue = Math.round((1 - t) * 220);
return `hsl(${hue},70%,45%)`;
}
function vvRenderCpu(cpu) {
const overall = cpu.overall ?? 0;
const cores = cpu.cores ?? [];
// Overall usage bar
let html = vvMeter('Overall', overall, overall + '%');
// 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;overflow:hidden;">`;
cores.forEach(c => {
const usePct = c.usage_pct ?? 0;
const hue = Math.round(120 * (1 - usePct / 100));
const color = `hsl(${hue},70%,45%)`;
const barH = Math.max(2, Math.round(usePct * 0.46)); // max ~46px at 100%
const label = c.freq_mhz ? (c.freq_mhz >= 1000 ? (c.freq_mhz/1000).toFixed(1)+'G' : c.freq_mhz+'M') : '';
html += `<div class="vv-cpu-core" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;min-width:10px;">
<div style="font-size:8px;color:#555;line-height:1;">${label}</div>
<div style="width:100%;height:46px;background:#1a1a1a;border-radius:2px;display:flex;align-items:flex-end;overflow:hidden;">
<div style="width:100%;height:${barH}px;background:${color};border-radius:2px 2px 0 0;transition:height 0.4s;"></div>
</div>
<div style="font-size:8px;color:#555;line-height:1;">${c.core}</div>
</div>`;
});
html += `</div>`;
// Freq legend
html += `<div style="display:flex;justify-content:space-between;font-size:9px;color:#444;margin-bottom:8px;">
<span style="color:hsl(120,70%,45%)">Low</span>
<span style="color:hsl(60,70%,45%)">Med</span>
<span style="color:hsl(0,70%,45%)">High</span>
</div>`;
}
// Canvas chart
html += `<canvas id="vv-cpu-canvas" style="width:100%;height:60px;display:block;"></canvas>`;
return html;
}
// ── Memory helpers ────────────────────────────────────────────────────────────
const VV_MEM_COLORS = {
system: '#5c6bc0',
vm: '#f57c00',
zfs: '#0097a7',
docker: '#388e3c',
free: '#37474f',
};
function vvFmtGib(kb) {
return (kb / 1048576).toFixed(1) + ' GiB';
}
function vvMemRow(label, kb, totalKb, color) {
const pct = totalKb > 0 ? Math.round(kb / totalKb * 100) : 0;
return `<div style="margin-bottom:5px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:${color};font-weight:500;">${label}</span>
<span style="color:#777;">${vvFmtGib(kb)}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${color};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
}
function vvRenderMemory(mem) {
const total = mem.total_kb ?? 1;
const usedKb = total - (mem.free_kb ?? 0);
const usedPct = Math.round(usedKb / total * 100);
const totalColor = usedPct >= 85 ? '#f44336' : usedPct >= 65 ? '#ff9800' : '#ccc';
const procs = mem.top_procs ?? [];
const procStrip = procs.map(p =>
`<span style="white-space:nowrap;">${p.name}&nbsp;<span style="color:#aaa;font-weight:600;">${vvFmtGib(p.kb)}</span></span>`
).join('<span style="color:#333;margin:0 5px;">·</span>');
let html = `<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px;">
<div style="font-size:14px;font-weight:600;color:${totalColor};white-space:nowrap;">
${vvFmtGib(usedKb)} <span style="font-size:11px;color:#555;font-weight:400;">/ ${vvFmtGib(total)}</span>
</div>
<div style="font-size:10px;color:#666;text-align:right;margin-left:10px;overflow:hidden;min-width:0;white-space:nowrap;text-overflow:ellipsis;">${procStrip}</div>
</div>`;
html += vvMemRow('System', mem.system_kb ?? 0, total, VV_MEM_COLORS.system)
+ vvMemRow('VM', mem.vm_kb ?? 0, total, VV_MEM_COLORS.vm)
+ 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;
}
// ── Scripts render (filter-aware) ─────────────────────────────────────────────
function vvRenderScripts() {
const sc = vvLastScripts;
const scList = sc.scripts ?? [];
const running = sc.running_count ?? 0;
const ok = sc.ok_count ?? 0;
const warn = sc.warn_count ?? 0;
const errors = sc.error_count ?? 0;
const now = Math.floor(Date.now() / 1000);
function vvScriptPill(type, count, color, bg, border, icon) {
const active = vvScriptsFilter === type;
return `<span onclick="vvScriptsFilterSet('${type}')"
style="font-size:10px;color:${color};background:${bg};border:1px solid ${active ? color : border};
padding:1px 7px;border-radius:10px;cursor:pointer;font-weight:${active ? '700' : '400'};">${icon} ${count} ${type}</span>`;
}
let html = `<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px;">`;
if (running > 0) html += vvScriptPill('running', running, '#4fc3f7', '#0a2233', '#1e4060', '●');
if (ok > 0) html += vvScriptPill('ok', ok, '#4caf50', '#0a1f0a', '#1a3a1a', '✓');
if (warn > 0) html += vvScriptPill('warn', warn, '#ff9800', '#1f1200', '#3a2200', '!');
if (errors > 0) html += vvScriptPill('error', errors, '#f44336', '#2a0a0a', '#5a1a1a', '✗');
if (!running && !ok && !warn && !errors) html += `<span style="font-size:10px;color:#555;">No recent runs</span>`;
html += `</div>`;
const filtered = vvScriptsFilter ? scList.filter(s => s.status === vvScriptsFilter) : scList;
let listHtml = '';
filtered.forEach(s => {
const diff = now - (s.last_ts ?? now);
const ago = diff < 60 ? diff + 's' : diff < 3600 ? Math.floor(diff / 60) + 'm' : diff < 86400 ? Math.floor(diff / 3600) + 'h' : Math.floor(diff / 86400) + 'd';
const icon = s.status === 'running' ? '●' : s.status === 'ok' ? '✓' : s.status === 'warn' ? '!' : s.status === 'error' ? '✗' : '?';
const color = s.status === 'running' ? '#4fc3f7' : s.status === 'ok' ? '#4caf50' : s.status === 'warn' ? '#ff9800' : s.status === 'error' ? '#f44336' : '#555';
const name = s.name.length > 24 ? s.name.slice(0, 23) + '…' : s.name;
const dur = s.duration != null ? ` ${s.duration}s` : '';
const sid = (s.id || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'");
listHtml += `<div onclick="vvOpenScript('${sid}')" title="Open ${name} in Scheduler"
style="display:flex;align-items:center;gap:5px;margin-bottom:4px;font-size:11px;cursor:pointer;"
onmouseover="this.style.background='#161616'" onmouseout="this.style.background=''">
<span style="color:${color};flex-shrink:0;width:10px;text-align:center;">${icon}</span>
<span style="color:#888;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${name}</span>
<span style="color:#444;font-size:10px;flex-shrink:0;">${ago}${dur}</span>
</div>`;
});
if (!filtered.length) listHtml = `<p style="color:#555;font-style:italic;font-size:12px;">${vvScriptsFilter ? 'None in this group' : 'No script logs found'}</p>`;
html += `<div style="max-height:133px;overflow-y:auto;scrollbar-width:none;-ms-overflow-style:none;">${listHtml}</div>`;
const el = document.getElementById('vv-scripts-body');
if (el) el.innerHTML = html;
}
function vvScriptsFilterSet(type) {
vvScriptsFilter = vvScriptsFilter === type ? null : type;
vvRenderScripts();
}
// Deep-link a script row to the Scheduler tab (opens its settings/log panel there).
function vvOpenScript(id) {
if (!id) { window.location.href = '?tab=scheduler'; return; }
window.location.href = '?tab=scheduler&vv_script=' + encodeURIComponent(id);
}
// ── 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() {
fetch('/plugins/varaverk/api/monitor.php')
.then(r => r.json())
.then(d => {
// ── API status banner ────────────────────────────────────────────────────
const apiStatus = d._api_status ?? {};
const banner = document.getElementById('vv-api-banner');
if (banner) {
const fallbacks = apiStatus.fallbacks ?? [];
if (fallbacks.length === 0) {
banner.style.display = 'none';
} else if (apiStatus.key_missing) {
// Key not configured — informational, not alarming
Object.assign(banner.style, {display:'', background:'#111', border:'1px solid #2a2a2a', color:'#555'});
banner.textContent = 'API key not configured — add HOST' +
(typeof vvLocalHostConf !== 'undefined' ? vvLocalHostConf.replace(/\D/g,'') : '1') +
'_UNRAID_API_KEY in Scheduler → host conf to enable enhanced monitoring.';
} else {
// Key present but API unreachable — genuine problem
Object.assign(banner.style, {display:'', background:'#1a1200', border:'1px solid #3a2800', color:'#ff9800'});
banner.innerHTML = '⚠ Unraid API unreachable — using local reads. Check API key in host conf.' +
(fallbacks.length ? ' <span style="color:#666">(' + fallbacks.join(', ') + ')</span>' : '');
}
}
// ── Thresholds (from dynamix.cfg via backend) ────────────────────────────
if (d.thresholds) vvThresholds = d.thresholds;
// ── System ──────────────────────────────────────────────────────────────
const sys = d.system ?? {};
const now = new Date();
const timeStr = now.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const dateStr = now.toLocaleDateString([], {weekday:'short', day:'numeric', month:'long', year:'numeric'});
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone.split('/').pop().replace('_',' ');
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` : '';
// Load average (1/5/15m) — colour by load[0] vs core count
const _load = Array.isArray(sys.load_avg) ? sys.load_avg : null;
const _cores = sys.cpu_cores || 0;
const _loadColor = _load && _cores
? (_load[0] > _cores * 2 ? '#f44336' : _load[0] > _cores ? '#ff9800' : '#888') : '#888';
const _loadStr = _load
? `${_load[0].toFixed(2)} <span style="color:#444;">·</span> ${_load[1].toFixed(2)} <span style="color:#444;">·</span> ${_load[2].toFixed(2)}`
: '—';
const _coreMeta = _threadInfo ? ` <span style="color:#444;">(${_threadInfo})</span>` : '';
document.getElementById('vv-system-body').innerHTML =
`<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;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: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}${_coreMeta}</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;">Load</span> <span style="color:${_loadColor};">${_loadStr}</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 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>`;
let ptHtml = `<div style="font-size:10px;color:#666;margin-bottom:8px;">${ptStatus}</div>`;
ptHosts.forEach(h => {
const dot = h.online === null ? '#555' : h.online ? '#4caf50' : '#f44336';
const label = h.online === null ? 'unknown' : h.online ? 'online' : 'offline';
const tags = [
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('');
// Onboard phase badge for remote hosts not yet fully onboarded
const phase = h.onboard_phase ?? null;
const onboardBadge = (!h.is_me && phase !== null && phase < 2)
? (phase === 1
? `<div style="font-size:9px;color:#ff9800;margin-top:3px;padding:2px 5px;background:#1a1200;border:1px solid #3a2800;border-radius:3px;display:inline-block;">⏳ Awaiting onboard</div>`
: `<div style="font-size:9px;color:#444;margin-top:3px;padding:2px 5px;background:#111;border:1px solid #222;border-radius:3px;display:inline-block;">○ Not provisioned</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}%${rs.cpu_threads ? `<span style="color:#333;font-weight:400;"> · ${rs.cpu_threads}t</span>` : ''}</span>` : `<span style="color:#333;">—</span>`;
const ramStr = ramAvail ? `<span style="color:hsl(${memHue},70%,45%);font-weight:600;">${rs.mem_used_pct}%${rs.mem_total_gb ? `<span style="color:#333;font-weight:400;"> · ${rs.mem_total_gb}G</span>` : ''}</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>` : '';
const verRow = remoteVer ? `<span style="color:#444;">unRAID</span><span style="color:#3a3a3a;grid-column:span 3;">${remoteVer}</span>` : '';
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>
${verRow}
</div>${verWarn}`;
} else if (rs && rs.no_api_key) {
statsHtml = `<div style="font-size:10px;color:#444;margin-top:4px;">API key not configured — complete Onboard to enable</div>`;
} 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>
${onboardBadge}
</div>
<span style="color:${dot};font-size:10px;white-space:nowrap;">● ${label}</span>
</div>
${statsHtml}
</div>`;
});
document.getElementById('vv-partner-body').innerHTML = ptHtml;
(function() {
const c = document.getElementById('vv-partner');
if (!c) return;
const remotes = ptHosts.filter(h => !h.is_me);
c.classList.remove('vv-accent-ok','vv-accent-warn');
if (remotes.length) {
if (remotes.some(h => h.online)) c.classList.add('vv-accent-ok');
else c.classList.add('vv-accent-warn');
}
})();
// ── Fallback ──────────────────────────────────────────────────────────────
(function() {
const fb = d.fallback ?? {};
const fbActive = d.fallback_active ?? [];
const state = (fb.state ?? 'UNKNOWN').toUpperCase();
const enabled = fb.enabled ?? false;
const strikes = fb.handback_strikes ?? 0;
const maxStrikes = fb.handback_strikes_required ?? 3;
const ptSuspended = fb.partnership_suspended ?? false;
const partnerLostAt = fb.partner_lost_at ?? 0;
const suspendAfter = fb.partnership_suspend_after ?? 120;
const interval = fb.check_interval ?? 30;
const ptRequired = fb.partnership_required ?? false;
const stateMap = {
NORMAL: ['#4caf50', '✓ Nominal'],
FALLBACK: ['#f44336', '⚠ Fallback active'],
NO_INTERNET: ['#ff9800', '⚡ Internet lost'],
DARK: ['#9e9e9e', '◌ Dark mode'],
UNKNOWN: ['#444', '— No state file'],
};
const [sColor, sLabel] = stateMap[state] ?? ['#444', state];
// ── Disabled ──────────────────────────────────────────────────────────
if (!enabled) {
document.getElementById('vv-fallback-body').innerHTML =
`<div style="font-size:11px;color:#444;margin-bottom:6px;">○ Disabled</div>
<div style="font-size:10px;color:#333;">Set FALLBACK_ENABLED=true in master.conf to activate</div>`;
return;
}
// ── Suspended (partnership gate) ───────────────────────────────────────
if (ptSuspended) {
const lostMin = partnerLostAt > 0 ? Math.floor((Date.now()/1000 - partnerLostAt) / 60) : '?';
document.getElementById('vv-fallback-body').innerHTML =
`<div style="font-size:11px;color:#555;margin-bottom:6px;">⊘ Suspended</div>
<div style="font-size:10px;color:#444;margin-bottom:3px;">Partnership inactive · ${lostMin}m elapsed</div>
<div style="font-size:10px;color:#333;">Resumes when partnership becomes active</div>`;
return;
}
let fbHtml = '';
// ── State label + outage duration ──────────────────────────────────────
let stateExtra = '';
if (state === 'FALLBACK' || state === 'DARK') {
const start = parseInt(fb.failover_start ?? 0);
if (start > 0) {
const sec = Math.floor(Date.now() / 1000) - start;
stateExtra = ` · ${Math.floor(sec/3600)}h ${Math.floor((sec%3600)/60)}m`;
}
}
fbHtml += `<div style="font-size:11px;color:${sColor};font-weight:500;margin-bottom:6px;">${sLabel}${stateExtra}</div>`;
// ── Handback strike progress ───────────────────────────────────────────
if (state === 'FALLBACK' && strikes > 0) {
const dots = Array.from({length: maxStrikes}, (_,i) =>
`<span style="color:${i < strikes ? '#4caf50' : '#2a2a2a'};font-size:14px;line-height:1;">●</span>`
).join('');
fbHtml += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
<span style="font-size:10px;color:#555;">Handback</span>
<span style="display:flex;gap:3px;">${dots}</span>
<span style="font-size:10px;color:#555;">${strikes}/${maxStrikes}</span>
</div>`;
}
// ── Tier badges (FAILOVER only) ────────────────────────────────────────
if (state === 'FALLBACK') {
const t2 = fb.tier2_started === 'true';
const t3 = fb.tier3_started === 'true';
const t4 = fb.tier4_started === 'true';
const tier = (n, on) => `<span style="font-size:9px;padding:1px 6px;border-radius:3px;
background:${on ? '#1a2a1a' : '#1a1a1a'};
border:1px solid ${on ? '#2a5a2a' : '#252525'};
color:${on ? '#4caf50' : '#333'};">T${n}</span>`;
fbHtml += `<div style="display:flex;gap:4px;margin-bottom:8px;">
${tier(1,true)} ${tier(2,t2)} ${tier(3,t3)} ${tier(4,t4)}
</div>`;
}
// ── Active containers ──────────────────────────────────────────────────
if (fbActive.length) {
fbActive.forEach(group => {
fbHtml += `<div style="font-size:10px;color:#555;margin-bottom:4px;letter-spacing:.03em;">
COVERING ${group.hostname}
</div>`;
group.containers.forEach(c => {
const img = c.image.includes('/') ? c.image.split('/').pop() : c.image;
fbHtml += `<div style="display:flex;justify-content:space-between;align-items:center;
background:#1a1a1a;border-radius:4px;padding:4px 8px;margin-bottom:3px;">
<span style="color:#ccc;font-size:11px;font-weight:500;">${c.name}</span>
<span style="color:#444;font-size:9px;margin-left:8px;white-space:nowrap;">${img}</span>
</div>`;
});
});
}
// ── NORMAL quiet state ─────────────────────────────────────────────────
if (state === 'NORMAL' && !fbActive.length) {
let meta = `monitoring · ${interval}s`;
if (ptRequired) meta += ' · partnership gated';
fbHtml += `<div style="font-size:10px;color:#2a2a2a;">${meta}</div>`;
}
// ── NO_INTERNET / DARK detail ──────────────────────────────────────────
if (state === 'NO_INTERNET') {
fbHtml += `<div style="font-size:10px;color:#555;">DDNS stopped · awaiting recovery</div>`;
} else if (state === 'DARK') {
fbHtml += `<div style="font-size:10px;color:#555;">Remote + internet both down</div>`;
}
document.getElementById('vv-fallback-body').innerHTML = fbHtml;
// Status accent border
const fbCard = document.getElementById('vv-fallback');
if (fbCard) {
fbCard.classList.remove('vv-accent-ok','vv-accent-warn','vv-accent-err');
if (!enabled || ptSuspended) fbCard.classList.add('vv-accent-warn');
else if (state === 'NORMAL') fbCard.classList.add('vv-accent-ok');
else if (state === 'FALLBACK') fbCard.classList.add('vv-accent-err');
else if (state !== 'UNKNOWN') fbCard.classList.add('vv-accent-warn');
}
})();
// ── CPU ─────────────────────────────────────────────────────────────────
const cpu = d.cpu ?? {};
document.getElementById('vv-cpu-body').innerHTML = vvRenderCpu(cpu);
vvCpuHistory.push(cpu.overall ?? 0);
if (vvCpuHistory.length > VV_HIST_MAX) vvCpuHistory.shift();
vvDrawChart(document.getElementById('vv-cpu-canvas'), vvCpuHistory, '#4caf50', 'rgba(76,175,80,0.18)', true);
// ── Memory ──────────────────────────────────────────────────────────────
const mem = d.mem ?? {};
document.getElementById('vv-memory-body').innerHTML = vvRenderMemory(mem);
// ── Network ─────────────────────────────────────────────────────────────
const net = d.net ?? {};
if (net.available) {
const rx = net.rx_bps ?? 0;
const tx = net.tx_bps ?? 0;
const linkMbps = net.speed_mbps ?? 0;
const linkLabel = linkMbps >= 1000 ? (linkMbps / 1000) + ' Gb/s' : linkMbps ? linkMbps + ' Mb/s' : '—';
const linkBps = linkMbps * 1e6;
vvNetRxHistory.push(rx);
vvNetTxHistory.push(tx);
if (vvNetRxHistory.length > VV_HIST_MAX) vvNetRxHistory.shift();
if (vvNetTxHistory.length > VV_HIST_MAX) vvNetTxHistory.shift();
const maxSeen = Math.max(...vvNetRxHistory, ...vvNetTxHistory, 1);
const maxBps = maxSeen * 1.25; // auto-scale with 25% headroom
const peakRx = Math.max(...vvNetRxHistory, 0); // window peak (last 2 min)
const peakTx = Math.max(...vvNetTxHistory, 0);
const ipRows = [
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN&nbsp;&nbsp;</span>${net.local_ip}</div>` : '',
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT&nbsp;&nbsp;</span>${net.ext_ip}</div>` : '',
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS&nbsp;&nbsp;&nbsp;</span>${net.ts_ip}</div>` : '',
].filter(Boolean).join('');
document.getElementById('vv-network-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} &nbsp;·&nbsp; ${linkLabel}</div>
<div style="display:flex;gap:16px;font-size:13px;font-weight:600;">
<span><span style="color:#4caf50;font-size:9px;margin-right:4px;">━ IN (RX)</span><span style="color:#4caf50;">${vvFmtBps(rx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakRx)}</span></span>
<span><span style="color:#ff9800;font-size:9px;margin-right:4px;">━ OUT (TX)</span><span style="color:#ff9800;">${vvFmtBps(tx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakTx)}</span></span>
</div>
</div>
<div style="text-align:right;font-size:11px;color:#aaa;line-height:1.6;">${ipRows}</div>
</div>
<canvas id="vv-net-canvas" style="width:100%;height:110px;display:block;"></canvas>`;
vvDrawNetChart(document.getElementById('vv-net-canvas'), vvNetRxHistory, vvNetTxHistory, maxBps);
} else {
document.getElementById('vv-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</p>';
}
// ── CPU title (core count + temp to its right) ────────────────────────────
const _cpuTitleEl = document.getElementById('vv-cpu-title');
if (_cpuTitleEl) {
const _cpuTemp = d.watchdog?.stability?.cpu_temp ?? null;
const _tColor = _cpuTemp == null ? '#888' : _cpuTemp >= 88 ? '#f44336' : _cpuTemp >= 75 ? '#ff9800' : '#4caf50';
const _coreLbl = sys.cpu_threads ? `CPU · ${sys.cpu_cores}c/${sys.cpu_threads}t` : 'CPU';
_cpuTitleEl.innerHTML = _coreLbl
+ (_cpuTemp != null ? ` <span style="color:${_tColor};font-weight:400;">${_cpuTemp}°</span>` : '');
}
// ── 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));
const bPct = ups.bcharge ?? 0;
const bHue = Math.round(120 * (bPct / 100));
const bColor = onBatt ? '#f44336' : `hsl(${bHue},70%,45%)`;
const timeLeft = ups.timeleft != null ? ups.timeleft.toFixed(1) + ' min' : '—';
const watts = ups.watts != null ? ups.watts + ' W' : '—';
const lineV = ups.line_v != null ? ups.line_v + ' V' : '—';
const outV = ups.output_v != null ? ups.output_v + ' V' : '—';
const xfers = ups.num_xfers ?? 0;
const batIcon = bPct >= 80 ? '▰▰▰▰' : bPct >= 60 ? '▰▰▰▱' : bPct >= 40 ? '▰▰▱▱' : bPct >= 20 ? '▰▱▱▱' : '▱▱▱▱';
document.getElementById('vv-ups-body').innerHTML =
`<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="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>
<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="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: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 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>';
}
// ── Parity ──────────────────────────────────────────────────────────────
const par = d.parity ?? {};
(function() {
const valid = par.valid !== false;
const inProg = par.in_progress;
const validColor = valid ? '#4caf50' : '#f44336';
const validLabel = valid ? 'Parity is valid' : 'Parity is INVALID';
function vvRelTime(ts) {
if (!ts) return '';
const diff = Math.floor(Date.now() / 1000) - ts;
const d = Math.floor(diff / 86400), h = Math.floor((diff % 86400) / 3600), m = Math.floor((diff % 3600) / 60);
const parts = [];
if (d) parts.push(d + ' day' + (d !== 1 ? 's' : ''));
if (h) parts.push(h + ' hour' + (h !== 1 ? 's' : ''));
if (!d && m) parts.push(m + ' minute' + (m !== 1 ? 's' : ''));
return parts.join(', ') + ' ago';
}
function vvDueIn(ts) {
if (!ts) return '—';
const diff = ts - Math.floor(Date.now() / 1000);
if (diff <= 0) return 'overdue';
const d = Math.floor(diff / 86400), h = Math.floor((diff % 86400) / 3600), m = Math.floor((diff % 3600) / 60);
const parts = [];
if (d) parts.push(d + ' day' + (d !== 1 ? 's' : ''));
if (h) parts.push(h + ' hour' + (h !== 1 ? 's' : ''));
if (!d && m) parts.push(m + ' minute' + (m !== 1 ? 's' : ''));
return 'Due in: ' + parts.join(', ');
}
function vvFmtDate(ts) {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString([], {weekday:'short',day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'});
}
const exitColor = par.exit_label === 'Completed' ? '#4caf50' : par.exit_label === 'Aborted' ? '#ff9800' : '#f44336';
const errColor = (par.errors ?? 0) > 0 ? '#f44336' : '#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 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;
html += `<div style="font-size:11px;color:#aaa;margin-bottom:4px;">Check in progress — ${pct}%</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;margin-bottom:10px;">
<div style="width:${pct}%;height:100%;background:#4caf50;border-radius:3px;transition:width 2s;"></div>
</div>`;
}
html += `
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;">
<span style="color:#555;">Last check</span>
<span style="color:#888;">${par.last_date ?? '—'}${speedStr}</span>
<span style="color:#555;">Status</span>
<span style="color:${exitColor};">${par.exit_label ?? '—'}</span>
<span style="color:#555;">Errors</span>
<span style="color:${errColor};">${par.errors ?? 0}</span>
<span style="color:#555;">Next check</span>
<span style="color:#888;">${nextDate}</span>
</div>
<div style="font-size:10px;color:#555;margin-top:8px;">${dueIn}</div>`;
document.getElementById('vv-parity-body').innerHTML = html;
})();
// ── Watchdog ─────────────────────────────────────────────────────────────
(function() {
const wd = d.watchdog ?? {};
const el = document.getElementById('vv-watchdog-body');
if (!el) return;
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;
const cpuRow = stab.cpu_temp != null
? `<span style="color:#444;">CPU</span><span style="color:${wdPct(stab.cpu_temp,75,90)};">${stab.cpu_temp}°C</span>` : '';
let statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr 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>
${cpuRow}
<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;
// ── 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>`;
});
}
// ── 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>`;
});
}
// ── 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;
const wdCard = document.getElementById('vv-watchdog-card');
if (wdCard) {
wdCard.classList.remove('vv-accent-ok','vv-accent-warn','vv-accent-err');
if (healthy) wdCard.classList.add('vv-accent-ok');
else if (reboots > 0 || oom > 0 || daemonHit) wdCard.classList.add('vv-accent-err');
else wdCard.classList.add('vv-accent-warn');
}
})();
// ── 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 = [];
for (let i = 0; i < numCols; i++) cols.push(arrayDisks.slice(i * perCol, (i + 1) * perCol));
document.getElementById('vv-array-body').innerHTML =
`<div style="display:flex;gap:10px;">${cols.map(vvDiskCol).join('')}</div>`;
} else {
document.getElementById('vv-array-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No array disks</p>';
}
(function() {
const c = document.getElementById('vv-array-card');
if (!c) return;
c.classList.remove('vv-accent-ok','vv-accent-err');
if (sys.array_state === 'STARTED') c.classList.add('vv-accent-ok');
else if (sys.array_state && sys.array_state !== 'UNKNOWN') c.classList.add('vv-accent-err');
})();
// ── GPU ─────────────────────────────────────────────────────────────────
const gpu = d.gpu ?? {};
const gpuProcs = d.gpu_procs ?? [];
if (gpu.available) {
const vramPct = gpu.memory_total > 0 ? Math.round(gpu.memory_used / gpu.memory_total * 100) : 0;
const utilPct = gpu.utilization ?? 0;
const temp = gpu.temperature ?? 0;
const tempColor = temp >= 85 ? '#f44336' : temp >= 70 ? '#ff9800' : '#4caf50';
const powerStr = gpu.power_w != null ? gpu.power_w + ' W' : '—';
const procCount = gpuProcs.length;
const procColor = procCount > 0 ? '#4caf50' : '#555';
// Process list — which apps are actually using the GPU (name + VRAM)
let gpuProcHtml = '';
if (procCount > 0) {
const rows = gpuProcs.map(p => {
const pname = (p.name || '').split('/').pop() || p.name || 'proc';
return `<div style="display:flex;justify-content:space-between;font-size:10px;margin-bottom:2px;">
<span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${pname}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${p.memory_mb} MB</span>
</div>`;
}).join('');
gpuProcHtml = `<div style="margin-top:6px;border-top:1px solid #2a2a2a;padding-top:6px;">
<div style="font-size:10px;color:#555;margin-bottom:3px;">GPU processes</div>
<div style="max-height:60px;overflow-y:auto;scrollbar-width:none;-ms-overflow-style:none;">${rows}</div>
</div>`;
}
document.getElementById('vv-gpu-body').innerHTML =
// header row: name + process count pill
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="font-size:11px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${gpu.name}</div>
<div style="margin-left:8px;background:${procColor}22;border:1px solid ${procColor};color:${procColor};
padding:1px 8px;border-radius:10px;font-size:11px;white-space:nowrap;">${procCount} proc${procCount !== 1 ? 's' : ''}</div>
</div>` +
vvMeter('VRAM', vramPct, `${gpu.memory_used} / ${gpu.memory_total} MB`) +
vvMeter('GPU', utilPct, `${utilPct}%`) +
vvMeter('Encode', gpu.enc_pct ?? 0, `${gpu.enc_pct ?? 0}%`) +
vvMeter('Decode', gpu.dec_pct ?? 0, `${gpu.dec_pct ?? 0}%`) +
`<div style="display:flex;justify-content:space-between;font-size:11px;margin-top:6px;margin-bottom:8px;">
<span style="color:#666;">Temp</span>
<span style="color:${tempColor};font-weight:bold;">${temp}°C</span>
<span style="color:#666;margin-left:12px;">Power</span>
<span style="color:#aaa;font-weight:bold;">${powerStr}</span>
</div>` + gpuProcHtml;
} else {
document.getElementById('vv-gpu-body').innerHTML = '<p style="color:#555;font-style:italic">No GPU detected</p>';
}
// ── Scripts ─────────────────────────────────────────────────────────────
vvLastScripts = d.scripts ?? {};
vvRenderScripts();
// ── Transcode ───────────────────────────────────────────────────────────
const tc = d.transcode ?? {};
if (!tc.available) {
document.getElementById('vv-transcode-body').innerHTML =
'<p style="color:#555;font-style:italic">No transcode state — ramdisk_setup.sh not yet run.</p>';
} else {
const loc = tc.is_ramdisk ? 'Ramdisk' : 'SSD';
const locColor = tc.is_ramdisk ? '#4caf50' : '#ff9800';
// RAM disk bar
const rd = tc.ramdisk ?? {};
const rdUsed = rd.used_mb ?? 0;
const rdSize = rd.size_mb ?? 0;
const rdPct = rdSize > 0 ? Math.round(rdUsed / rdSize * 100) : 0;
const rdHue = Math.round(120 * (1 - rdPct / 100));
// SSD bar
const ssd = tc.ssd ?? {};
const ssdUsed = ssd.used_mb ?? 0;
const ssdSize = ssd.size_mb ?? 0;
const ssdPct = ssdSize > 0 ? Math.round(ssdUsed / ssdSize * 100) : 0;
const ssdHue = Math.round(120 * (1 - ssdPct / 100));
// Flip info
const ago = tc.last_flip_ago;
let flipStr = 'never';
if (ago !== null && ago !== undefined) {
if (ago < 60) flipStr = ago + 's ago';
else if (ago < 3600) flipStr = Math.floor(ago / 60) + 'm ago';
else flipStr = Math.floor(ago / 3600) + 'h ' + Math.floor((ago % 3600) / 60) + 'm ago';
}
// Server icon helper
function vvSrvIcon(type, name) {
const cfg = { emby: ['#4caf50','#fff','E'], jellyfin: ['#00A4DC','#fff','JF'], plex: ['#E5A00D','#000','P'] };
const [bg, fg, lbl] = cfg[type] ?? ['#555','#fff', (type[0] || '?').toUpperCase()];
return `<span title="${name}" style="display:inline-flex;align-items:center;justify-content:center;
width:18px;height:18px;background:${bg};border-radius:3px;font-size:8px;font-weight:bold;
color:${fg};flex-shrink:0;">${lbl}</span>`;
}
// Active sessions from shared streams data
const activeSessions = vvLastSessions.filter(s => s.is_tc);
const typeLabel = t => ({ LiveTvProgram:'Live TV', Movie:'Movie', Episode:'TV', Audio:'Music' }[t] ?? t);
let sessHtml = '';
if (activeSessions.length) {
function vvTcRow(s) {
const meth = s.method.replace('Transcode', 'TC').replace('Direct ', '');
return `<div style="display:flex;align-items:center;gap:5px;margin-bottom:4px;min-width:0;overflow:hidden;">
${vvSrvIcon(s.server_type, s.server)}
<span style="font-size:10px;color:#888;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${s.title}</span>
<span style="font-size:9px;color:#555;flex-shrink:0;">${typeLabel(s.type)}</span>
<span style="font-size:9px;color:#ff9800;flex-shrink:0;">${meth}</span>
</div>`;
}
const shown = activeSessions.slice(0, 4);
const mid = Math.ceil(shown.length / 2);
const colA = shown.slice(0, mid).map(vvTcRow).join('');
const colB = shown.slice(mid).map(vvTcRow).join('');
const colBDiv = colB ? `<div style="flex:1;min-width:0;border-left:1px solid #222;padding-left:8px;">${colB}</div>` : '';
const overflow = activeSessions.length > 4
? `<div style="font-size:9px;color:#555;margin-top:2px;">+${activeSessions.length - 4} more</div>`
: '';
sessHtml = `<div style="margin-top:8px;border-top:1px solid #2a2a2a;padding-top:6px;">
<div style="font-size:10px;color:#555;margin-bottom:4px;">Active transcodes <span style="color:#ff9800;font-weight:600;">${activeSessions.length}</span></div>
<div style="display:flex;gap:0;">
<div style="flex:1;min-width:0;">${colA}</div>${colBDiv}
</div>${overflow}
</div>`;
}
const nvmeColor = ssd.available ? '#4caf50' : '#f44336';
const rdFreed = tc.last_rd_freed ?? null;
const ssdFreed = tc.last_ssd_freed ?? null;
const rdCleanTip = rdFreed ? `<span style="font-size:9px;color:#555;margin-left:4px;">↓${rdFreed}</span>` : '';
const ssdCleanTip = ssdFreed ? `<span style="font-size:9px;color:#555;margin-left:4px;">↓${ssdFreed}</span>` : '';
document.getElementById('vv-transcode-body').innerHTML =
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;background:${locColor}22;border:1px solid ${locColor};color:${locColor};
padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600;">● ${loc}${rdCleanTip}</span>
<span style="display:inline-flex;align-items:center;background:${nvmeColor}22;border:1px solid ${nvmeColor};color:${nvmeColor};
padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600;">● NVMe${ssdCleanTip}</span>
</div>
<span style="font-size:10px;color:#555;">Flips: ${tc.flip_count_hour ?? 0}/hr · ${flipStr}</span>
</div>
<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px;">
<span style="color:#666;">Ramdisk</span>
<span style="color:#555;font-size:10px;">${rdUsed} / ${rdSize} MB</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${rdPct}%;height:100%;background:hsl(${rdHue},70%,45%);border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>
<div style="margin-bottom:4px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px;">
<span style="color:#666;">SSD Fallback</span>
<span style="color:#555;font-size:10px;">${ssd.available ? ssdUsed + ' / ' + ssdSize + ' MB' : '—'}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${ssdPct}%;height:100%;background:hsl(${ssdHue},70%,45%);border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>${sessHtml}`;
}
// ── Containers and VMs ──────────────────────────────────────────────────
const dfData = d.docker_folders ?? { available: false, folders: [], ungrouped: [] };
dfData.vms = d.vms ?? { available: false, vms: [] };
vvRenderDockerFolders(dfData);
})
.catch(() => {});
}
vvPollMonitor();
setInterval(vvPollMonitor, 2000);
// Pin pools card width to CPU card width across rows
function vvSyncCardWidths() {
const cpu = document.getElementById('vv-cpu');
const pools = document.getElementById('vv-storage-card');
if (!cpu || !pools) return;
const w = cpu.offsetWidth;
if (w === 0) return;
pools.style.flex = 'none';
pools.style.boxSizing = 'border-box';
pools.style.width = w + 'px';
}
new ResizeObserver(vvSyncCardWidths).observe(document.getElementById('vv-cpu'));
requestAnimationFrame(vvSyncCardWidths);
// ── Media streams (slower poll — media server API calls) ──────────────────────
// Map a session's client string to a human-readable device category
function vvDeviceType(client) {
const c = (client || '').toLowerCase();
// Order matters: more specific patterns first
if (/android\s*tv|androidtv|shield|nvidia\s*shield|android.*tv|tv.*android/.test(c)) return 'Android TV';
if (/amazon|fire\s*tv|firetv|fire\s*stick|firestick/.test(c)) return 'Fire TV';
if (/tvos|apple\s*tv/.test(c)) return 'Apple TV';
if (/infuse/.test(c)) return 'Apple TV';
if (/android|mobile/.test(c)) return 'Android';
if (/ios|iphone|ipad/.test(c)) return 'iOS';
if (/roku/.test(c)) return 'Roku';
if (/webos|lg\s*tv|lgtv|lg /.test(c)) return 'LG';
if (/samsung/.test(c)) return 'Samsung';
if (/google\s*tv|googletv|chromecast/.test(c)) return 'Google TV';
if (/kodi/.test(c)) return 'Kodi';
if (/web|chrome|firefox|safari|browser|edge/.test(c)) return 'Browser';
if (/desktop|theater|media\s*player|windows|linux|mac\s*os|macos/.test(c)) return 'Desktop';
return null; // unknown — don't show
}
function vvFmtSec(sec) {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
return h > 0
? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
: `${m}:${String(s).padStart(2,'0')}`;
}
function vvRenderStreams() {
if (vvStreamServerCount === 0) return;
const sessions = vvLastSessions;
const names = vvLastStreamNames;
const el = document.getElementById('vv-streams-body');
if (!el) return;
// Per-server counts from full session list
const serverCounts = {};
sessions.forEach(s => serverCounts[s.server] = (serverCounts[s.server] || 0) + 1);
const badges = names.map(n => {
const cnt = serverCounts[n] ?? 0;
return cnt > 0
? `<span class="vv-server-badge">${n} <b style="color:#ccc;">${cnt}</b></span>`
: `<span class="vv-server-badge" style="color:#444;">${n}</span>`;
}).join('');
// Per-chip shade: alternate bg brightness within a group to visually separate chips
// shades[0] = base bg, shades[1] = slightly lighter, cycling per index
function vvChipShade(cls, i) {
const shades = {
'vv-chip-device': ['#0d1e2e', '#112436'],
'vv-chip-res': ['#0b1e1c', '#0e2422'],
'vv-chip-codec': ['#201500', '#261900'],
'vv-chip-mtype': ['#1a0d2e', '#1f1136'],
};
const bg = (shades[cls] ?? ['',''])[i % 2];
return bg ? ` style="background:${bg};"` : '';
}
function vvChip(cls, label, i) {
return `<span class="${cls}"${vvChipShade(cls, i)}>${label}</span>`;
}
// Device type summary — e.g. "3 Android 1 iOS 2 Roku"
const devCounts = {};
sessions.forEach(s => {
const t = vvDeviceType(s.client);
if (t) devCounts[t] = (devCounts[t] || 0) + 1;
});
const deviceBar = Object.entries(devCounts)
.sort((a, b) => b[1] - a[1])
.map(([t, n], i) => vvChip('vv-chip-device', `${n} ${t}`, i))
.join('');
const deviceSection = deviceBar
? `<span class="vv-chip-group-device"><span class="vv-device-sep">·</span>${deviceBar}</span>`
: '';
// Resolution summary — e.g. "2 4K 3 1080p 1 720p"
function vvResLabel(h) {
if (h >= 2160) return '4K';
if (h >= 1080) return '1080p';
if (h >= 720) return '720p';
if (h >= 480) return '480p';
return null;
}
const resCounts = {};
sessions.forEach(s => {
const r = vvResLabel(s.height || 0);
if (r) resCounts[r] = (resCounts[r] || 0) + 1;
});
const resOrder = ['4K', '1080p', '720p', '480p'];
const resBar = resOrder.filter(r => resCounts[r])
.map((r, i) => vvChip('vv-chip-res', `${resCounts[r]} ${r}`, i))
.join('');
const resSection = resBar ? `<span class="vv-chip-group-res"><span class="vv-device-sep">·</span>${resBar}</span>` : '';
// Codec summary — e.g. "4 H.264 2 HEVC"
function vvCodecLabel(c) {
if (!c) return null;
const m = { h264: 'H.264', hevc: 'HEVC', av1: 'AV1', vp9: 'VP9', mpeg4: 'MPEG-4', mpeg2video: 'MPEG-2' };
return m[c] || c.toUpperCase();
}
const codecCounts = {};
sessions.forEach(s => {
const c = vvCodecLabel(s.codec || '');
if (c) codecCounts[c] = (codecCounts[c] || 0) + 1;
});
const codecBar = Object.entries(codecCounts)
.sort((a, b) => b[1] - a[1])
.map(([c, n], i) => vvChip('vv-chip-codec', `${n} ${c}`, i))
.join('');
const codecSection = codecBar ? `<span class="vv-chip-group-codec"><span class="vv-device-sep">·</span>${codecBar}</span>` : '';
// Media type summary — Movies, Shows, Music, Live
function vvMediaType(t) {
if (!t) return null;
const lc = t.toLowerCase();
if (lc === 'movie') return 'Movies';
if (lc === 'episode') return 'Shows';
if (lc === 'audio' || lc === 'track' || lc === 'musicvideo') return 'Music';
if (lc === 'livetvprogram' || lc === 'tvchannel' || lc === 'livetv' || lc === 'recording') return 'Live';
return null;
}
const mtypeCounts = {};
sessions.forEach(s => {
const mt = vvMediaType(s.type || '');
if (mt) mtypeCounts[mt] = (mtypeCounts[mt] || 0) + 1;
});
const mtypeOrder = ['Movies', 'Shows', 'Music', 'Live'];
const mtypeBar = mtypeOrder.filter(mt => mtypeCounts[mt])
.map((mt, i) => vvChip('vv-chip-mtype', `${mtypeCounts[mt]} ${mt}`, i))
.join('');
const mtypeSection = mtypeBar ? `<span class="vv-device-sep">·</span>${mtypeBar}` : '';
if (sessions.length === 0) {
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>`
+ '<p class="vv-stream-empty">Nothing playing</p>';
return;
}
const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - vvLastStreamPollAt);
function vvStreamRow(s) {
const isLive = s.type === 'LiveTvProgram' || s.dur_sec === 0;
const inc = s.paused ? 0 : elapsed;
const curSec = Math.max(0, (s.pos_sec ?? 0) + inc);
const barColor = isLive ? '#1e3a5a' : (s.is_tc ? '#e65100' : '#4caf50');
const tcColor = s.paused ? '#fdd835' : '#555';
const icon = s.paused ? '⏸' : '▶';
const iconColor = s.paused ? '#fdd835' : isLive ? '#2196f3' : s.is_tc ? '#ff9800' : '#aaa';
let timeStr = '';
let pct = s.pct ?? 0;
if (isLive) {
pct = 75;
timeStr = `<span style="color:${tcColor};">${vvFmtSec(curSec)}</span>`;
} else if (s.dur_sec > 0) {
pct = Math.min(100, Math.round(curSec / s.dur_sec * 100));
timeStr = `<span style="color:${tcColor};">${vvFmtSec(curSec)} / ${vvFmtSec(s.dur_sec)}</span>`;
}
return `<div>
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:2px;">
<span style="color:${s.paused ? '#fdd835' : '#aaa'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">
<span style="color:${iconColor};">${icon}</span> ${s.title}</span>
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${s.server}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:#555;margin-bottom:3px;">
<span>${s.user}</span>
${timeStr}
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.9s linear;"></div>
</div>
</div>`;
}
// Left-fill columns of 3 — col 1 fills first, col 2 opens when col 1 hits 3
const shown = sessions.slice(0, 12);
const perCol = 3;
const sCols = [];
for (let i = 0; i < shown.length; i += perCol) sCols.push(shown.slice(i, i + perCol));
const vvStreamCol = col =>
`<div style="flex:1;min-width:0;">${col.map(s =>
`<div style="margin-bottom:8px;">${vvStreamRow(s)}</div>`
).join('')}</div>`;
const overflow = sessions.length > 12
? `<div style="font-size:10px;color:#555;margin-top:4px;">+${sessions.length - 12} more not shown</div>`
: '';
el.innerHTML = `<div class="vv-stream-servers">${badges}${mtypeSection}${deviceSection}${resSection}${codecSection}</div>
<div style="display:flex;gap:10px;">${sCols.map(vvStreamCol).join('')}</div>${overflow}`;
}
function vvPollStreams() {
fetch('/plugins/varaverk/api/media.php')
.then(r => r.json())
.then(d => {
vvLastSessions = d.sessions ?? [];
vvLastStreamNames = d.server_names ?? [];
vvStreamServerCount = d.server_count ?? 0;
vvLastStreamPollAt = Math.floor(Date.now() / 1000);
const el = document.getElementById('vv-streams-body');
if (vvStreamServerCount === 0) {
el.innerHTML = '<p class="vv-stream-empty">No media servers detected.<br>'
+ '<span>Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.</span></p>';
return;
}
vvRenderStreams();
})
.catch(() => {});
}
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');
if (!el) return;
const now = new Date();
const timeStr = now.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const t = el.querySelector('.vv-clock');
if (t) t.textContent = timeStr;
}
setInterval(vvTickClock, 30000);
// ── Containers and VMs card ───────────────────────────────────────────────────
let vvDfFolderOpen = {};
let vvDfActive = null;
let vvDfData = null;
function vvToggleFolder(id) {
vvDfFolderOpen[id] = !vvDfFolderOpen[id];
vvRenderDockerFolders(vvDfData);
}
function vvToggleContainer(name) {
vvDfActive = vvDfActive === name ? null : name;
vvRenderDockerFolders(vvDfData);
}
function vvDockerAction(action, name, webui) {
if (action === 'webui') { window.open(webui, '_blank'); return; }
if (action === 'edit') {
window.location.href = '/Docker?action=template&xmlTemplate=' +
encodeURIComponent('/boot/config/plugins/dockerMan/templates-user/my-' + name + '.xml') + '&update=true';
return;
}
const fd = new FormData();
fd.set('action', action);
fd.set('name', name);
fetch('/plugins/varaverk/api/docker_action.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(() => { vvDfActive = null; setTimeout(vvPollMonitor, 1500); })
.catch(() => {});
}
function vvRenderDockerFolders(data) {
vvDfData = 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;
// ── VMs section ─────────────────────────────────────────────────────────────
let html = '';
const vms = data.vms?.vms ?? [];
if (!vms.length) {
html += '<div class="vv-df-empty">No VMs configured</div>';
} else {
vms.forEach(vm => {
const sc = stateColor(vm.state);
const pulse = vm.state === 'running' ? 'animation:vv-pulse-dot 1s ease-in-out infinite;' : '';
const parts = [];
if (vm.vcpus) parts.push(vm.vcpus + ' vCPU');
if (vm.mem_mb) parts.push(vm.mem_mb >= 1024 ? (vm.mem_mb/1024).toFixed(0)+' GB' : vm.mem_mb+' MB');
const meta = parts.length ? `<span class="vv-df-vm-meta"> · ${parts.join(' · ')}</span>` : '';
html += `<div class="vv-df-vm-row">
<span class="vv-df-vm-icon">${osIcon(vm.os)}</span>
<span style="width:7px;height:7px;border-radius:50%;background:${sc};flex-shrink:0;${pulse}"></span>
<span class="vv-df-cname">${vm.name}</span>
<span style="font-size:11px;font-weight:600;color:${sc};flex-shrink:0;">${stateLabel(vm.state)}</span>
${meta}
</div>`;
});
}
if (!data.available) {
html += '<div class="vv-df-empty">Docker not available</div>';
el.innerHTML = html;
return;
}
function renderContainer(c) {
const dot = c.running ? '#4caf50' : '#555';
const pulse = c.running ? 'animation:vv-pulse-dot 1s ease-in-out infinite;' : '';
const active = vvDfActive === c.name;
const sShort = c.status ? c.status.replace(/^Up\s+/, '').split(' ').slice(0,2).join(' ') : '—';
const sn = c.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const sw = (c.webui||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'");
let actionBar = '';
if (active) {
const ta = c.running ? 'stop' : 'start';
const tl = c.running ? '⏹ Stop' : '▶ Start';
const tc = c.running ? '#f44336' : '#4caf50';
const ws = c.webui ? '' : 'opacity:0.3;pointer-events:none;';
actionBar = `<div class="vv-df-actions">
<button onclick="event.stopPropagation();vvDockerAction('${ta}','${sn}','')"
class="vv-btn-sm" style="border-color:${tc};color:${tc};">${tl}</button>
<button onclick="event.stopPropagation();vvDockerAction('webui','${sn}','${sw}')"
class="vv-btn-sm vv-run-btn" style="${ws}">🌐 WebUI</button>
<button onclick="event.stopPropagation();vvDockerAction('edit','${sn}','')"
class="vv-btn-sm vv-edit-btn">✎ Edit</button>
</div>`;
}
return `<div class="vv-df-container${active ? ' vv-df-active' : ''}"
onclick="event.stopPropagation();vvToggleContainer('${sn}')">
<span class="vv-df-dot" style="background:${dot};${pulse}"></span>
<span class="vv-df-cname">${c.name}</span>
<span class="vv-df-status">${sShort}</span>
</div>${actionBar}`;
}
function renderFolder(f) {
const open = !!vvDfFolderOpen[f.id];
const total = f.containers.length;
const running = f.containers.filter(c => c.running).length;
const bColor = running === total ? '#4caf50' : running === 0 ? '#555' : '#ff9800';
const badge = `<span style="font-size:10px;color:${bColor};flex-shrink:0;margin-left:auto;">${running}/${total}</span>`;
const sid = f.id.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
let iconHtml = '';
if (f.isEmoji) {
iconHtml = `<span style="font-size:11px;flex-shrink:0;">${f.icon}</span>`;
} else if (f.icon) {
iconHtml = `<img src="${f.icon}" style="width:13px;height:13px;object-fit:contain;border-radius:2px;flex-shrink:0;" onerror="this.style.display='none'">`;
}
let out = `<div class="vv-df-folder">
<div class="vv-df-folder-hdr" onclick="vvToggleFolder('${sid}')">
<span class="vv-df-chevron">${open ? '▾' : '▸'}</span>
${iconHtml}
<span class="vv-df-fname">${f.name}</span>
${badge}
</div>`;
if (open) {
out += '<div class="vv-df-folder-body">';
f.containers.forEach(c => { out += renderContainer(c); });
out += '</div>';
}
out += '</div>';
return out;
}
// Build folder list — ungrouped gets a 📁 emoji icon
const allFolders = (data.folders ?? []).map(f => ({ ...f, isEmoji: false }));
const ug = data.ungrouped ?? [];
if (ug.length) allFolders.push({ id:'__ungrouped__', name:'Ungrouped', icon:'📁', isEmoji:true, containers:ug });
if (!allFolders.length) {
html += '<div class="vv-df-empty">No containers found</div>';
el.innerHTML = html;
return;
}
// 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 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;
}
// Close action bar when clicking outside the card
document.addEventListener('click', () => {
if (vvDfActive !== null) { vvDfActive = null; vvRenderDockerFolders(vvDfData); }
});
// ── Array actions ─────────────────────────────────────────────────────────────
function vvArrayAction(action) {
const labels = {stop: 'Stop Array', shutdown: 'Shutdown', restart: 'Restart'};
if (!confirm(`${labels[action] ?? action} — are you sure?`)) return;
fetch('/plugins/varaverk/api/system.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action}),
}).then(r => r.json()).then(d => {
if (!d.ok) alert('Error: ' + (d.error ?? 'unknown'));
}).catch(() => alert('Request failed'));
}
</script>