2208 lines
119 KiB
PHP
2208 lines
119 KiB
PHP
<?php require_once dirname(__DIR__) . '/include/monitor.php'; ?>
|
||
<style>
|
||
@keyframes vvRsPulse {
|
||
0%,100% { opacity:.5; transform:scaleX(.9); }
|
||
50% { opacity:1; transform:scaleX(1.05); }
|
||
}
|
||
</style>
|
||
|
||
<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 & 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: Rsync | GPU | Transcode | Streams -->
|
||
<div class="vv-card" id="vv-rsync-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"><polyline points="2,4 10,4 8,2"/><polyline points="10,8 2,8 4,10"/></svg></span>
|
||
Rsync
|
||
</span>
|
||
</h3>
|
||
<div id="vv-rsync-body">Loading...</div>
|
||
</div>
|
||
|
||
<div class="vv-card" id="vv-gpu-card" style="grid-column:span 1;">
|
||
<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>
|
||
<span id="vv-pools-title">Pools</span>
|
||
</span>
|
||
<span id="vv-pools-io-total" style="font-size:9px;font-weight:400;color:#333;text-transform:none;letter-spacing:0;"></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>
|
||
<span id="vv-array-io-total" style="font-size:9px;font-weight:400;color:#333;text-transform:none;letter-spacing:0;"></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 vvDiskIo = {}; // {device: [readMBs, writeMBs]}
|
||
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} <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.arc_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 vvFmtRate(mbs) {
|
||
if (mbs >= 1000) return (mbs / 1024).toFixed(1) + ' GB/s';
|
||
if (mbs >= 100) return Math.round(mbs) + ' MB/s';
|
||
return mbs.toFixed(1) + ' MB/s';
|
||
}
|
||
function vvIoChip(dev) {
|
||
const io = vvDiskIo[dev];
|
||
if (!io) return '';
|
||
const r = io.r ?? 0, w = io.w ?? 0;
|
||
if (r < 0.05 && w < 0.05) return '';
|
||
const parts = [];
|
||
if (r >= 0.05) parts.push(`<span style="color:#3a7a3a;">↓${vvFmtRate(r)}</span>`);
|
||
if (w >= 0.05) parts.push(`<span style="color:#7a4a1a;">↑${vvFmtRate(w)}</span>`);
|
||
return `<span style="font-size:9px;margin-left:5px;">${parts.join(' ')}</span>`;
|
||
}
|
||
function vvIoSum(devices) {
|
||
let r = 0, w = 0;
|
||
devices.forEach(dev => { const io = vvDiskIo[dev]; if (io) { r += io.r ?? 0; w += io.w ?? 0; } });
|
||
return [r, w];
|
||
}
|
||
function vvIoTotalSum(devices) {
|
||
let tr = 0, tw = 0;
|
||
devices.forEach(dev => { const io = vvDiskIo[dev]; if (io) { tr += io.tr ?? 0; tw += io.tw ?? 0; } });
|
||
return [tr, tw];
|
||
}
|
||
function vvFmtGb(gb) {
|
||
if (gb >= 1024) return (gb / 1024).toFixed(1) + ' TB';
|
||
if (gb >= 1) return gb.toFixed(1) + ' GB';
|
||
return (gb * 1024).toFixed(0) + ' MB';
|
||
}
|
||
|
||
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};display:flex;align-items:center;">${disk.name}${spinLabel}${vvIoChip(disk.device)}</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 || ' '}</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} · ${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 title (core count + temp — watchdog data only comes from full poll) ──
|
||
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 ─────────────────────────────────────────────────────────────────
|
||
vvDiskIo = d.disk_io ?? {};
|
||
vvLastStorageDisks = d.storage ?? [];
|
||
(() => {
|
||
const disks = vvLastStorageDisks;
|
||
const titleEl = document.getElementById('vv-pools-title');
|
||
if (titleEl && disks.length) {
|
||
const okDisks = disks.filter(d => d.status === 'DISK_OK' || !d.status).length;
|
||
const totalGb = disks.reduce((s, d) => s + (d.size_gb ?? 0), 0);
|
||
const usedGb = disks.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 = disks.map(d => d.temp).filter(t => t != null);
|
||
const maxTemp = temps.length ? Math.max(...temps) : null;
|
||
const maxTempColor = maxTemp == null ? '#444'
|
||
: maxTemp >= (vvThresholds.ssd_crit ?? 70) ? '#f44336'
|
||
: maxTemp >= (vvThresholds.ssd_warn ?? 60) ? '#ff9800' : '#4caf50';
|
||
const [pIor, pIow] = vvIoSum(disks.map(d => d.device));
|
||
const ioParts = [];
|
||
if (pIor >= 0.05) ioParts.push(`<span style="color:#3a7a3a;">↓${vvFmtRate(pIor)}</span>`);
|
||
if (pIow >= 0.05) ioParts.push(`<span style="color:#7a4a1a;">↑${vvFmtRate(pIow)}</span>`);
|
||
const ioHtml = ioParts.length ? `· <span style="font-weight:400;">${ioParts.join(' ')}</span>` : '';
|
||
titleEl.innerHTML = `Pools
|
||
<span style="font-size:10px;color:#444;font-weight:400;text-transform:none;letter-spacing:0;margin-left:6px;">
|
||
${okDisks}/${disks.length} ok
|
||
· <span style="color:${pctColor};">${pct}%</span> used
|
||
${maxTemp != null ? `· <span style="color:${maxTempColor};">max ${maxTemp}°</span>` : ''}
|
||
${ioHtml}
|
||
</span>`;
|
||
const [ptr, ptw] = vvIoTotalSum(disks.map(d => d.device));
|
||
const totEl = document.getElementById('vv-pools-io-total');
|
||
if (totEl && (ptr > 0.001 || ptw > 0.001))
|
||
totEl.innerHTML = `<span style="color:#3a5a3a;">↓${vvFmtGb(ptr)}</span> <span style="color:#5a3a1a;">↑${vvFmtGb(ptw)}</span>`;
|
||
}
|
||
})();
|
||
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 [arrIor, arrIow] = vvIoSum(arrayDisks.map(d => d.device));
|
||
const arrIoParts = [];
|
||
if (arrIor >= 0.05) arrIoParts.push(`<span style="color:#3a7a3a;">↓${vvFmtRate(arrIor)}</span>`);
|
||
if (arrIow >= 0.05) arrIoParts.push(`<span style="color:#7a4a1a;">↑${vvFmtRate(arrIow)}</span>`);
|
||
const arrIoHtml = arrIoParts.length
|
||
? `· <span style="font-weight:400;">${arrIoParts.join(' ')}</span>` : '';
|
||
|
||
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>` : ''}
|
||
${arrIoHtml}
|
||
</span>`;
|
||
|
||
const [atr, atw] = vvIoTotalSum(arrayDisks.map(d => d.device));
|
||
const arrTotEl = document.getElementById('vv-array-io-total');
|
||
if (arrTotEl && (atr > 0.001 || atw > 0.001))
|
||
arrTotEl.innerHTML = `<span style="color:#3a5a3a;">↓${vvFmtGb(atr)}</span> <span style="color:#5a3a1a;">↑${vvFmtGb(atw)}</span>`;
|
||
|
||
const numCols = window.innerWidth <= 1024 ? 2 : 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');
|
||
})();
|
||
|
||
// ── Rsync ────────────────────────────────────────────────────────────────
|
||
(function() {
|
||
const rs = d.rsync ?? {};
|
||
const enabled = rs.enabled ?? true;
|
||
const windows = rs.windows ?? {};
|
||
const active = rs.active ?? [];
|
||
const lastSync = rs.last_sync ?? {};
|
||
const bwSummary = rs.bw_summary ?? {};
|
||
const now = Math.floor(Date.now() / 1000);
|
||
|
||
const el = document.getElementById('vv-rsync-body');
|
||
if (!el) return;
|
||
|
||
function _dur(s) {
|
||
if (!s) return '—';
|
||
if (s < 60) return s + 's';
|
||
if (s < 3600) return Math.floor(s/60) + 'm' + (s%60 ? String(s%60).padStart(2,'0')+'s' : '');
|
||
return Math.floor(s/3600) + 'h' + Math.floor((s%3600)/60) + 'm';
|
||
}
|
||
function _ago(diff) {
|
||
if (diff < 60) return diff + 's';
|
||
if (diff < 3600) return Math.floor(diff/60) + 'm';
|
||
if (diff < 86400) return Math.floor(diff/3600) + 'h';
|
||
return Math.floor(diff/86400) + 'd';
|
||
}
|
||
function _fmtBytes(b) {
|
||
if (!b) return '';
|
||
if (b >= 1073741824) return (b/1073741824).toFixed(1) + 'G';
|
||
if (b >= 1048576) return (b/1048576).toFixed(0) + 'M';
|
||
return (b/1024).toFixed(0) + 'K';
|
||
}
|
||
|
||
const gCol = enabled ? '#4caf50' : '#555';
|
||
|
||
// ── Header: gate dot + window badges ──────────────────────────────────
|
||
let html = `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
||
<span style="font-size:10px;font-weight:600;color:${gCol};">● ${enabled ? 'ENABLED' : 'DISABLED'}</span>
|
||
<div style="display:flex;gap:3px;">`;
|
||
[['C','critical'],['D','daily'],['I','intermediate'],['W','weekly']].forEach(([s,k]) => {
|
||
const on = windows[k] ?? false;
|
||
const run = active.some(a => a.profile?.includes(k));
|
||
const bg = run ? '#1a2a0a' : on ? '#0f1a0f' : '#111';
|
||
const brd = run ? '#3a6a1a' : on ? '#1a3a1a' : '#222';
|
||
const col = run ? '#8bc34a' : on ? '#4caf50' : '#333';
|
||
html += `<span title="${k}" style="font-size:9px;padding:2px 5px;border-radius:2px;
|
||
background:${bg};border:1px solid ${brd};color:${col};font-weight:600;">${s}</span>`;
|
||
});
|
||
html += `</div></div>`;
|
||
|
||
// ── Profile activity bars (7 days) ────────────────────────────────────
|
||
const profEntries = Object.entries(bwSummary);
|
||
if (profEntries.length) {
|
||
const maxRuns = Math.max(...profEntries.map(([,v]) => v.runs), 1);
|
||
const colors = ['#4caf50','#4a9eff','#ffb74d','#ce93d8','#ef5350','#4dd0e1'];
|
||
html += `<div style="margin-bottom:6px;">
|
||
<div style="font-size:9px;color:#2a2a2a;text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px;">7-day profile activity</div>`;
|
||
profEntries.forEach(([name, v], i) => {
|
||
const pct = Math.round(v.runs / maxRuns * 100);
|
||
const col = colors[i % colors.length];
|
||
const bytes = v.bytes > 0 ? _fmtBytes(v.bytes) : '';
|
||
const short = name.length > 18 ? name.slice(0,16) + '…' : name;
|
||
html += `<div style="margin-bottom:4px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:1px;">
|
||
<span style="font-size:9px;color:#555;overflow:hidden;text-overflow:ellipsis;
|
||
white-space:nowrap;max-width:110px;" title="${name}">${short}</span>
|
||
<span style="font-size:9px;color:#2a2a2a;flex-shrink:0;margin-left:4px;">
|
||
${v.runs}×${bytes ? ' · ' + bytes : ''}</span>
|
||
</div>
|
||
<div style="height:3px;background:#111;border-radius:2px;overflow:hidden;">
|
||
<div style="height:100%;width:${pct}%;background:${col};border-radius:2px;opacity:.7;"></div>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
}
|
||
|
||
// ── Active syncs ───────────────────────────────────────────────────────
|
||
if (active.length) {
|
||
active.forEach(a => {
|
||
const sec = a.elapsed ?? 0;
|
||
html += `<div style="background:#0d1a0a;border:1px solid #1a3a0a;border-radius:3px;
|
||
padding:4px 8px;margin-bottom:5px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;">
|
||
<span style="font-size:10px;color:#8bc34a;font-weight:600;">⟳ ${a.profile}</span>
|
||
<span style="font-size:10px;color:#6a8a4a;">${_dur(sec)}</span>
|
||
</div>
|
||
<div style="height:3px;background:#0a0a0a;border-radius:2px;overflow:hidden;">
|
||
<div style="height:100%;background:#3a6a1a;border-radius:2px;
|
||
animation:vvRsPulse 1.4s ease-in-out infinite;width:60%;"></div>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
}
|
||
|
||
// ── Tier last-run rows with recency bars ───────────────────────────────
|
||
const tierMeta = {
|
||
critical: {label:'Critical', cadenceSec: 30*60},
|
||
daily: {label:'Daily', cadenceSec: 24*3600},
|
||
intermediate: {label:'Interm', cadenceSec: 4*3600},
|
||
weekly: {label:'Weekly', cadenceSec: 7*86400},
|
||
};
|
||
html += `<div style="border-top:1px solid #1a1a1a;padding-top:6px;">`;
|
||
Object.entries(tierMeta).forEach(([key, meta]) => {
|
||
const s = lastSync[key];
|
||
const ts = s?.ts ?? 0;
|
||
const ok = s?.status === 'ok' || s?.status === 'success';
|
||
const warn = s?.status === 'warn';
|
||
const run = s?.status === 'running';
|
||
const err = s && !ok && !warn && !run && ts > 0;
|
||
const col = run ? '#4a9eff' : ok ? '#4caf50' : warn ? '#ff9800' : err ? '#f44336' : '#2a2a2a';
|
||
const icon = run ? '⟳' : ok ? '✓' : warn ? '!' : err ? '✗' : '—';
|
||
const diff = ts ? now - ts : null;
|
||
const pct = ts ? Math.min(100, Math.round((now - ts) / meta.cadenceSec * 100)) : 0;
|
||
const barC = pct >= 100 ? '#3a1a1a' : pct >= 75 ? '#2a1a0a' : '#0a1a0a';
|
||
const fillC= pct >= 100 ? '#f44336' : pct >= 75 ? '#ff9800' : '#2a6a2a';
|
||
|
||
html += `<div style="margin-bottom:4px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:2px;">
|
||
<span style="font-size:10px;color:#555;width:46px;flex-shrink:0;">${meta.label}</span>
|
||
<span style="font-size:9px;color:#333;flex:1;text-align:right;margin-right:6px;">
|
||
${s?.duration ? _dur(s.duration) : ''}</span>
|
||
<span style="font-size:9px;color:#2a2a2a;width:28px;text-align:right;margin-right:5px;">
|
||
${diff !== null ? _ago(diff) : '—'}</span>
|
||
<span style="font-size:10px;color:${col};width:10px;text-align:right;">${icon}</span>
|
||
</div>
|
||
<div style="height:2px;background:${barC};border-radius:1px;overflow:hidden;">
|
||
<div style="height:100%;width:${pct}%;background:${fillC};border-radius:1px;"></div>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
|
||
el.innerHTML = html;
|
||
|
||
const card = document.getElementById('vv-rsync-card');
|
||
if (card) {
|
||
card.classList.remove('vv-accent-ok','vv-accent-warn','vv-accent-err');
|
||
if (!enabled) card.classList.add('vv-accent-err');
|
||
else if (active.length) card.classList.add('vv-accent-ok');
|
||
}
|
||
})();
|
||
|
||
// ── 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 mid = Math.ceil(activeSessions.length / 2);
|
||
const colA = activeSessions.slice(0, mid).map(vvTcRow).join('');
|
||
const colB = activeSessions.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>` : '';
|
||
sessHtml = `<div style="margin-top:8px;border-top:1px solid #2a2a2a;padding-top:6px;">
|
||
<div style="font-size:10px;color:#555;margin-bottom:4px;">Active transcodes <span style="color:#ff9800;font-weight:600;">${activeSessions.length}</span></div>
|
||
<div style="max-height:44px;overflow-y:auto;overflow-x:hidden;scrollbar-width:none;-ms-overflow-style:none;">
|
||
<div style="display:flex;gap:0;">
|
||
<div style="flex:1;min-width:0;">${colA}</div>${colBDiv}
|
||
</div>
|
||
</div>
|
||
</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);
|
||
|
||
// ── Fast poll: CPU, memory, network — 1-second live updates ──────────────────
|
||
function vvPollFast() {
|
||
fetch('/plugins/varaverk/api/monitor_fast.php')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
// 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
|
||
document.getElementById('vv-memory-body').innerHTML = vvRenderMemory(d.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' : '—';
|
||
|
||
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;
|
||
const peakRx = Math.max(...vvNetRxHistory, 0);
|
||
const peakTx = Math.max(...vvNetTxHistory, 0);
|
||
|
||
const ipRows = [
|
||
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN </span>${net.local_ip}</div>` : '',
|
||
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT </span>${net.ext_ip}</div>` : '',
|
||
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS </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} · ${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>';
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
vvPollFast();
|
||
setInterval(vvPollFast, 1000);
|
||
|
||
// 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"><div class="vv-stream-left">${badges}</div></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">
|
||
<div class="vv-stream-left">${badges}${mtypeSection}</div>
|
||
<div class="vv-stream-right">${deviceSection}${resSection}${codecSection}</div>
|
||
</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,"\\'");
|
||
const [pIor, pIow] = vvIoSum(drives.map(d => d.device));
|
||
const poolIoHtml = (() => {
|
||
const parts = [];
|
||
if (pIor >= 0.05) parts.push(`<span style="color:#3a7a3a;">↓${vvFmtRate(pIor)}</span>`);
|
||
if (pIow >= 0.05) parts.push(`<span style="color:#7a4a1a;">↑${vvFmtRate(pIow)}</span>`);
|
||
return parts.length ? `<span style="font-size:9px;margin-left:5px;">${parts.join(' ')}</span>` : '';
|
||
})();
|
||
|
||
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>` : ''}
|
||
${poolIoHtml}
|
||
</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>
|