Files
Varaverk/Plugin/usr/local/emhttp/plugins/varaverk/pages/monitor.php
T

1054 lines
54 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php require_once dirname(__DIR__) . '/include/monitor.php'; ?>
<div id="vv-monitor" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div class="vv-card" id="vv-system" style="grid-column:span 1;position:relative;overflow:hidden;">
<div id="vv-system-body">Loading...</div>
</div>
<div class="vv-card" id="vv-partner" style="grid-column:span 1;">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
Partner &amp; Fallback
<svg width="32" height="18" viewBox="0 0 32 18" fill="none" xmlns="http://www.w3.org/2000/svg" style="opacity:0.45;flex-shrink:0;">
<!-- Server left -->
<rect x="0.5" y="2" width="9" height="14" rx="1.5" stroke="#888" stroke-width="1" fill="#1a1a1a"/>
<line x1="0.5" y1="5.5" x2="9.5" y2="5.5" stroke="#555" stroke-width="0.6"/>
<circle cx="2.5" cy="3.8" r="0.8" fill="#4caf50"/>
<line x1="2" y1="8.5" x2="8" y2="8.5" stroke="#333" stroke-width="0.6"/>
<line x1="2" y1="10.5" x2="8" y2="10.5" stroke="#333" stroke-width="0.6"/>
<line x1="2" y1="12.5" x2="6" y2="12.5" stroke="#333" stroke-width="0.6"/>
<!-- Arrows between -->
<line x1="10.5" y1="8" x2="21.5" y2="8" stroke="#555" stroke-width="0.8"/>
<polyline points="13,5.5 10.5,8 13,10.5" stroke="#555" stroke-width="0.8" fill="none"/>
<polyline points="19,5.5 21.5,8 19,10.5" stroke="#555" stroke-width="0.8" fill="none"/>
<!-- Server right -->
<rect x="22.5" y="2" width="9" height="14" rx="1.5" stroke="#888" stroke-width="1" fill="#1a1a1a"/>
<line x1="22.5" y1="5.5" x2="31.5" y2="5.5" stroke="#555" stroke-width="0.6"/>
<circle cx="24.5" cy="3.8" r="0.8" fill="#4caf50"/>
<line x1="24" y1="8.5" x2="30" y2="8.5" stroke="#333" stroke-width="0.6"/>
<line x1="24" y1="10.5" x2="30" y2="10.5" stroke="#333" stroke-width="0.6"/>
<line x1="24" y1="12.5" x2="28" y2="12.5" stroke="#333" stroke-width="0.6"/>
</svg>
</h3>
<div id="vv-partner-body">Loading...</div>
</div>
<div class="vv-card" id="vv-cpu" style="grid-column:span 2;">
<h3>CPU</h3>
<div id="vv-cpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-memory" style="grid-column:span 2;">
<h3>Memory</h3>
<div id="vv-memory-body">Loading...</div>
</div>
<div class="vv-card" id="vv-network" style="grid-column:span 2;">
<h3>Network</h3>
<div id="vv-network-body">Loading...</div>
</div>
<div class="vv-card" id="vv-ups-card" style="grid-column:span 1;">
<h3>Power</h3>
<div id="vv-ups-body">Loading...</div>
</div>
<div class="vv-card" id="vv-parity-card" style="grid-column:span 1;">
<h3>Parity</h3>
<div id="vv-parity-body">Loading...</div>
</div>
<div class="vv-card" id="vv-storage-card" style="grid-column:span 2;">
<h3>Pools</h3>
<div id="vv-storage-body">Loading...</div>
</div>
<div class="vv-card" id="vv-array-card" style="grid-column:span 4;">
<h3>Array</h3>
<div id="vv-array-body">Loading...</div>
</div>
<div class="vv-card" id="vv-scripts-card" style="grid-column:span 1;">
<h3>Scripts</h3>
<div id="vv-scripts-body">Loading...</div>
</div>
<div class="vv-card" id="vv-gpu-card" style="grid-column:span 1;">
<h3>GPU</h3>
<div id="vv-gpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-transcode" style="grid-column:span 2;">
<h3>Transcode System</h3>
<div id="vv-transcode-body">Loading...</div>
</div>
<div class="vv-card" id="vv-streams" style="grid-column:span 4;">
<h3>Streams</h3>
<div id="vv-streams-body">Loading...</div>
</div>
<div class="vv-card" id="vv-docker" style="grid-column:span 8;">
<h3>Containers</h3>
<table id="vv-docker-table">
<thead><tr><th>Name</th><th>Status</th><th>Image</th></tr></thead>
<tbody id="vv-docker-body"><tr><td colspan="3">Loading...</td></tr></tbody>
</table>
</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 = [];
// ── 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 style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;">`;
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 style="flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;min-width:10px;">
<div style="font-size:8px;color:#555;line-height:1;">${label}</div>
<div style="width:100%;height:46px;background:#1a1a1a;border-radius:2px;display:flex;align-items:flex-end;overflow:hidden;">
<div style="width:100%;height:${barH}px;background:${color};border-radius:2px 2px 0 0;transition:height 0.4s;"></div>
</div>
<div style="font-size:8px;color:#555;line-height:1;">${c.core}</div>
</div>`;
});
html += `</div>`;
// Freq legend
html += `<div style="display:flex;justify-content:space-between;font-size:9px;color:#444;margin-bottom:8px;">
<span style="color:hsl(120,70%,45%)">Low</span>
<span style="color:hsl(60,70%,45%)">Med</span>
<span style="color:hsl(0,70%,45%)">High</span>
</div>`;
}
// Canvas chart
html += `<canvas id="vv-cpu-canvas" style="width:100%;height:60px;display:block;"></canvas>`;
return html;
}
// ── Memory helpers ────────────────────────────────────────────────────────────
const VV_MEM_COLORS = {
system: '#5c6bc0',
vm: '#f57c00',
zfs: '#0097a7',
docker: '#388e3c',
free: '#37474f',
};
function vvFmtGib(kb) {
return (kb / 1048576).toFixed(1) + ' GiB';
}
function vvMemRow(label, kb, totalKb, color) {
const pct = totalKb > 0 ? Math.round(kb / totalKb * 100) : 0;
return `<div style="margin-bottom:5px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:${color};font-weight:500;">${label}</span>
<span style="color:#777;">${vvFmtGib(kb)}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${color};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
}
function vvRenderMemory(mem) {
const total = mem.total_kb ?? 1;
const usedKb = total - (mem.free_kb ?? 0);
const usedPct = Math.round(usedKb / total * 100);
const totalColor = usedPct >= 85 ? '#f44336' : usedPct >= 65 ? '#ff9800' : '#ccc';
const procs = mem.top_procs ?? [];
const procStrip = procs.map(p =>
`<span style="white-space:nowrap;">${p.name}&nbsp;<span style="color:#aaa;font-weight:600;">${vvFmtGib(p.kb)}</span></span>`
).join('<span style="color:#333;margin:0 5px;">·</span>');
let html = `<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px;">
<div style="font-size:14px;font-weight:600;color:${totalColor};white-space:nowrap;">
${vvFmtGib(usedKb)} <span style="font-size:11px;color:#555;font-weight:400;">/ ${vvFmtGib(total)}</span>
</div>
<div style="font-size:10px;color:#666;text-align:right;margin-left:10px;">${procStrip}</div>
</div>`;
html += vvMemRow('System', mem.system_kb ?? 0, total, VV_MEM_COLORS.system)
+ vvMemRow('VM', mem.vm_kb ?? 0, total, VV_MEM_COLORS.vm)
+ vvMemRow('ZFS', mem.zfs_kb ?? 0, total, VV_MEM_COLORS.zfs)
+ vvMemRow('Docker', mem.docker_kb ?? 0, total, VV_MEM_COLORS.docker)
+ vvMemRow('Free', mem.free_kb ?? 0, total, VV_MEM_COLORS.free);
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` : '';
listHtml += `<div style="display:flex;align-items:center;gap:5px;margin-bottom:4px;font-size:11px;">
<span style="color:${color};flex-shrink:0;width:10px;text-align:center;">${icon}</span>
<span style="color:#888;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${name}</span>
<span style="color:#444;font-size:10px;flex-shrink:0;">${ago}${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;">${listHtml}</div>`;
const el = document.getElementById('vv-scripts-body');
if (el) el.innerHTML = html;
}
function vvScriptsFilterSet(type) {
vvScriptsFilter = vvScriptsFilter === type ? null : type;
vvRenderScripts();
}
// ── Poll ──────────────────────────────────────────────────────────────────────
function vvPollMonitor() {
fetch('/plugins/varaverk/api/monitor.php')
.then(r => r.json())
.then(d => {
// ── 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,'');
document.getElementById('vv-system-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px;">
<div>
<div style="font-size:14px;font-weight:700;color:#ddd;">${sys.name}</div>
<div style="font-size:11px;color:#666;margin-top:2px;">${sys.comment}</div>
</div>
<div style="display:flex;flex-direction:column;gap:0;flex-shrink:0;margin-left:8px;align-items:flex-start;align-self:flex-start;">
<button onclick="vvArrayAction('stop')" class="vv-sys-btn" title="Stop Array" style="width:50px;min-width:0;padding:3px 0;margin:0;font-size:6px;line-height:1;display:block;box-sizing:border-box;overflow:hidden;">■</button>
<button onclick="vvArrayAction('shutdown')" class="vv-sys-btn" title="Shutdown" style="width:50px;min-width:0;padding:3px 0;margin:0;font-size:6px;line-height:1;display:block;box-sizing:border-box;overflow:hidden;">⏻</button>
<button onclick="vvArrayAction('restart')" class="vv-sys-btn" title="Restart" style="width:50px;min-width:0;padding:3px 0;margin:0;font-size:6px;line-height:1;display:block;box-sizing:border-box;overflow:hidden;">↺</button>
</div>
</div>
<div style="font-size:22px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div>
<div style="font-size:11px;color:#666;margin-bottom:12px;">${dateStr}, ${tz}</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;">
<span style="color:#555;">Model</span> <span style="color:#aaa;">${sys.cpu_model}</span>
<span style="color:#555;">Registration</span><span style="color:#aaa;">${sys.reg_type}</span>
<span style="color:#555;">Uptime</span> <span style="color:#aaa;">${sys.uptime}</span>
<span style="color:#555;">Array</span> <span style="color:${arrayColor};">${sys.array_state}</span>
<span style="color:#555;">Version</span> <span style="color:#555;">${ver}</span>
</div>
<svg width="38" height="64" viewBox="0 0 38 64" fill="none" xmlns="http://www.w3.org/2000/svg"
style="position:absolute;bottom:8px;right:8px;opacity:0.13;pointer-events:none;">
<!-- Case body -->
<rect x="1" y="1" width="36" height="62" rx="3" stroke="#aaa" stroke-width="1.2" fill="#111"/>
<!-- Top strip -->
<rect x="1" y="1" width="36" height="10" rx="3" fill="#1c1c1c" stroke="#aaa" stroke-width="1.2"/>
<!-- Power button -->
<circle cx="19" cy="6" r="2.5" stroke="#ff9800" stroke-width="1" fill="none"/>
<line x1="19" y1="3.8" x2="19" y2="2.2" stroke="#ff9800" stroke-width="1"/>
<!-- USB dots top -->
<rect x="26" y="4" width="4" height="2" rx="0.5" fill="#555"/>
<!-- Mesh front panel -->
<rect x="3" y="14" width="16" height="44" rx="1" fill="#0d0d0d" stroke="#444" stroke-width="0.6"/>
<!-- Mesh lines -->
<line x1="3" y1="17" x2="19" y2="17" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="20" x2="19" y2="20" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="23" x2="19" y2="23" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="26" x2="19" y2="26" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="29" x2="19" y2="29" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="32" x2="19" y2="32" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="35" x2="19" y2="35" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="38" x2="19" y2="38" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="41" x2="19" y2="41" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="44" x2="19" y2="44" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="47" x2="19" y2="47" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="50" x2="19" y2="50" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="53" x2="19" y2="53" stroke="#333" stroke-width="0.6"/>
<!-- Glass side panel -->
<rect x="21" y="14" width="14" height="44" rx="1" fill="#08080f" stroke="#444" stroke-width="0.6" opacity="0.7"/>
<!-- Bottom feet -->
<rect x="5" y="60" width="5" height="2" rx="1" fill="#333"/>
<rect x="28" y="60" width="5" height="2" rx="1" fill="#333"/>
</svg>`;
// ── Partner & Fallback ───────────────────────────────────────────────────
const pt = d.partner ?? {};
const ptHosts = pt.hosts ?? [];
const fb = d.fallback ?? {};
const fbActive = d.fallback_active ?? [];
const state = (fb.state ?? 'UNKNOWN').toUpperCase();
const stateMap = {
NORMAL: ['#4caf50', '✓ Nominal'],
FAILOVER: ['#f44336', '⚠ Failover active'],
NO_INTERNET: ['#ff9800', '⚡ Internet lost'],
DARK: ['#9e9e9e', '◌ Dark mode'],
UNKNOWN: ['#444', '— No state file'],
};
const [sColor, sLabel] = stateMap[state] ?? ['#444', state];
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('');
ptHtml += `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
<div>
<span style="font-size:10px;color:#555;margin-right:4px;">${h.id}</span>
<span style="font-size:12px;color:#ccc;font-weight:500;">${h.owner || h.hostname}</span>${tags}
<div style="font-size:10px;color:#555;margin-top:1px;">${h.hostname}</div>
</div>
<span style="color:${dot};font-size:10px;white-space:nowrap;">● ${label}</span>
</div>`;
});
ptHtml += `<div style="font-size:11px;color:${sColor};margin:6px 0;padding-top:6px;border-top:1px solid #2a2a2a;">${sLabel}`;
if (state === 'FAILOVER') {
const start = parseInt(fb.failover_start ?? 0);
if (start > 0) {
const sec = Math.floor(Date.now() / 1000) - start;
ptHtml += ` · ${Math.floor(sec/3600)}h ${Math.floor((sec%3600)/60)}m`;
}
}
ptHtml += `</div>`;
if (fbActive.length) {
fbActive.forEach(group => {
ptHtml += `<div style="font-size:10px;color:#888;margin-top:6px;margin-bottom:4px;">Running for ${group.hostname}</div>`;
group.containers.forEach(c => {
const img = c.image.includes('/') ? c.image.split('/').pop() : c.image;
ptHtml += `<div style="display:flex;justify-content:space-between;align-items:center;
background:#1a1a1a;border-radius:4px;padding:4px 8px;margin-bottom:4px;">
<span style="color:#ccc;font-size:11px;font-weight:500;">${c.name}</span>
<span style="color:#555;font-size:9px;margin-left:8px;white-space:nowrap;">${img}</span>
</div>`;
});
});
}
document.getElementById('vv-partner-body').innerHTML = ptHtml;
// ── CPU ─────────────────────────────────────────────────────────────────
const cpu = d.cpu ?? {};
document.getElementById('vv-cpu-body').innerHTML = vvRenderCpu(cpu);
vvCpuHistory.push(cpu.overall ?? 0);
if (vvCpuHistory.length > VV_HIST_MAX) vvCpuHistory.shift();
vvDrawChart(document.getElementById('vv-cpu-canvas'), vvCpuHistory, '#4caf50', 'rgba(76,175,80,0.18)', true);
// ── Memory ──────────────────────────────────────────────────────────────
const mem = d.mem ?? {};
document.getElementById('vv-memory-body').innerHTML = vvRenderMemory(mem);
// ── Network ─────────────────────────────────────────────────────────────
const net = d.net ?? {};
if (net.available) {
const rx = net.rx_bps ?? 0;
const tx = net.tx_bps ?? 0;
const linkMbps = net.speed_mbps ?? 0;
const linkLabel = linkMbps >= 1000 ? (linkMbps / 1000) + ' Gb/s' : linkMbps ? linkMbps + ' Mb/s' : '—';
const linkBps = linkMbps * 1e6;
vvNetRxHistory.push(rx);
vvNetTxHistory.push(tx);
if (vvNetRxHistory.length > VV_HIST_MAX) vvNetRxHistory.shift();
if (vvNetTxHistory.length > VV_HIST_MAX) vvNetTxHistory.shift();
const maxSeen = Math.max(...vvNetRxHistory, ...vvNetTxHistory, 1);
const maxBps = maxSeen * 1.25; // auto-scale with 25% headroom
const ipRows = [
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN&nbsp;&nbsp;</span>${net.local_ip}</div>` : '',
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT&nbsp;&nbsp;</span>${net.ext_ip}</div>` : '',
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS&nbsp;&nbsp;&nbsp;</span>${net.ts_ip}</div>` : '',
].filter(Boolean).join('');
document.getElementById('vv-network-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} &nbsp;·&nbsp; ${linkLabel}</div>
<div style="display:flex;gap:16px;font-size:13px;font-weight:600;">
<span><span style="color:#4caf50;font-size:9px;margin-right:4px;">━ IN (RX)</span><span style="color:#4caf50;">${vvFmtBps(rx)}</span></span>
<span><span style="color:#ff9800;font-size:9px;margin-right:4px;">━ OUT (TX)</span><span style="color:#ff9800;">${vvFmtBps(tx)}</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>';
}
// ── UPS / Power ─────────────────────────────────────────────────────────
const ups = d.ups ?? {};
if (ups.available) {
const onBatt = ups.status === 'ONBATT';
const statColor = onBatt ? '#f44336' : ups.status === 'ONLINE' ? '#4caf50' : '#ff9800';
const loadPct = ups.load_pct ?? 0;
const loadHue = Math.round(120 * (1 - loadPct / 100));
const bPct = ups.bcharge ?? 0;
const bHue = Math.round(120 * (bPct / 100));
const bColor = onBatt ? '#f44336' : `hsl(${bHue},70%,45%)`;
const timeLeft = ups.timeleft != null ? ups.timeleft.toFixed(1) + ' min' : '—';
const watts = ups.watts != null ? ups.watts + ' W' : '—';
const lineV = ups.line_v != null ? ups.line_v + ' V' : '—';
const outV = ups.output_v != null ? ups.output_v + ' V' : '—';
const xfers = ups.num_xfers ?? 0;
document.getElementById('vv-ups-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="font-size:11px;color:#666;">${ups.model}</span>
<span style="font-size:11px;font-weight:600;color:${statColor};">${ups.status}</span>
</div>
<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px;">
<span style="color:#666;">Load</span>
<span style="color:#999;">${loadPct.toFixed(1)}% · ${watts}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${loadPct}%;height:100%;background:hsl(${loadHue},70%,45%);border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>
<div style="margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px;">
<span style="color:#666;">Battery</span>
<span style="color:#999;">${bPct.toFixed(1)}% · ${timeLeft}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${bPct}%;height:100%;background:${bColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;">
<span style="color:#555;">Line in</span> <span style="color:#888;">${lineV}</span>
<span style="color:#555;">Output</span> <span style="color:#888;">${outV}</span>
<span style="color:#555;">Transfers</span> <span style="color:${xfers > 0 ? '#ff9800' : '#555'};">${xfers}</span>
</div>`;
} else {
document.getElementById('vv-ups-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No UPS detected</p>';
}
// ── Parity ──────────────────────────────────────────────────────────────
const par = d.parity ?? {};
(function() {
const valid = par.valid !== false;
const inProg = par.in_progress;
const validColor = valid ? '#4caf50' : '#f44336';
const validLabel = valid ? 'Parity is valid' : 'Parity is INVALID';
function vvRelTime(ts) {
if (!ts) return '';
const diff = Math.floor(Date.now() / 1000) - ts;
const d = Math.floor(diff / 86400), h = Math.floor((diff % 86400) / 3600), m = Math.floor((diff % 3600) / 60);
const parts = [];
if (d) parts.push(d + ' day' + (d !== 1 ? 's' : ''));
if (h) parts.push(h + ' hour' + (h !== 1 ? 's' : ''));
if (!d && m) parts.push(m + ' minute' + (m !== 1 ? 's' : ''));
return parts.join(', ') + ' ago';
}
function vvDueIn(ts) {
if (!ts) return '—';
const diff = ts - Math.floor(Date.now() / 1000);
if (diff <= 0) return 'overdue';
const d = Math.floor(diff / 86400), h = Math.floor((diff % 86400) / 3600), m = Math.floor((diff % 3600) / 60);
const parts = [];
if (d) parts.push(d + ' day' + (d !== 1 ? 's' : ''));
if (h) parts.push(h + ' hour' + (h !== 1 ? 's' : ''));
if (!d && m) parts.push(m + ' minute' + (m !== 1 ? 's' : ''));
return 'Due in: ' + parts.join(', ');
}
function vvFmtDate(ts) {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString([], {weekday:'short',day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'});
}
const exitColor = par.exit_label === 'Completed' ? '#4caf50' : par.exit_label === 'Aborted' ? '#ff9800' : '#f44336';
const errColor = (par.errors ?? 0) > 0 ? '#f44336' : '#555';
const speedStr = par.last_speed_mb ? ` · ${par.last_speed_mb} MB/s` : '';
const nextDate = vvFmtDate(par.next_ts);
const dueIn = vvDueIn(par.next_ts);
let html = `<div style="font-size:13px;font-weight:600;color:${validColor};margin-bottom:10px;">${validLabel}</div>`;
if (inProg) {
const pct = par.resync_pct ?? 0;
html += `<div style="font-size:11px;color:#aaa;margin-bottom:4px;">Check in progress — ${pct}%</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;margin-bottom:10px;">
<div style="width:${pct}%;height:100%;background:#4caf50;border-radius:3px;transition:width 2s;"></div>
</div>`;
}
html += `
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;">
<span style="color:#555;">Last check</span>
<span style="color:#888;">${par.last_date ?? '—'}${speedStr}</span>
<span style="color:#555;">Status</span>
<span style="color:${exitColor};">${par.exit_label ?? '—'}</span>
<span style="color:#555;">Errors</span>
<span style="color:${errColor};">${par.errors ?? 0}</span>
<span style="color:#555;">Next check</span>
<span style="color:#888;">${nextDate}</span>
</div>
<div style="font-size:10px;color:#555;margin-top:8px;">${dueIn}</div>`;
document.getElementById('vv-parity-body').innerHTML = html;
})();
// ── Storage helpers ──────────────────────────────────────────────────────
function vvTempColor(tempC, transport) {
if (tempC === null) return '#444';
const isSsd = transport === 'nvme' || transport === 'ssd';
const warn = isSsd ? vvThresholds.ssd_warn : vvThresholds.hdd_warn;
const crit = isSsd ? vvThresholds.ssd_crit : vvThresholds.hdd_crit;
return tempC >= crit ? '#f44336' : tempC >= warn ? '#ff9800' : '#4caf50';
}
function vvFmt(v) { return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB'; }
function vvDiskRow(disk) {
const tempC = disk.temp;
const tempColor = vvTempColor(tempC, disk.transport);
const tempStr = tempC !== null ? `${tempC}°` : '—';
const isParity = disk.role === 'parity';
const nameColor = isParity ? '#6a8faf' : '#aaa';
const pct = disk.pct ?? 0;
const barColor = isParity ? '#1e3a5a'
: pct >= vvThresholds.util_crit ? '#f44336'
: pct >= vvThresholds.util_warn ? '#ff9800'
: '#4caf50';
const barWidth = isParity ? '100' : pct;
const spinLabel = (!isParity && !disk.mounted) ? `<span style="color:#555;font-size:9px;margin-left:4px;">↓</span>` : '';
const right = isParity
? `<span style="color:#444;font-size:10px;">${vvFmt(disk.size_gb)}</span>`
: `<span style="color:#555;font-size:10px;">${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}</span>`;
return `<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;">
<span style="color:${nameColor};">${disk.name}${spinLabel}</span>
${right}
<span style="color:${tempColor};font-size:10px;margin-left:6px;flex-shrink:0;">${tempStr}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${barWidth}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
}
function vvDiskCol(disks) {
return `<div style="flex:1;min-width:0;">${disks.map(vvDiskRow).join('')}</div>`;
}
// ── Pools — single column ─────────────────────────────────────────────────
const storageDisks = d.storage ?? [];
if (storageDisks.length) {
document.getElementById('vv-storage-body').innerHTML = storageDisks.map(vvDiskRow).join('');
} else {
document.getElementById('vv-storage-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No pools found</p>';
}
// ── Array disks — min 3 columns, balanced left-to-right, grows as needed ───
const arrayDisks = d.array_disks ?? [];
if (arrayDisks.length) {
const numCols = Math.max(3, Math.ceil(arrayDisks.length / 6));
const perCol = Math.ceil(arrayDisks.length / numCols);
const cols = [];
for (let i = 0; i < numCols; i++) cols.push(arrayDisks.slice(i * perCol, (i + 1) * perCol));
document.getElementById('vv-array-body').innerHTML =
`<div style="display:flex;gap:10px;">${cols.map(vvDiskCol).join('')}</div>`;
} else {
document.getElementById('vv-array-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No array disks</p>';
}
// ── GPU ─────────────────────────────────────────────────────────────────
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';
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>`;
} 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) {
let rowsHtml = '';
activeSessions.forEach(s => {
const meth = s.method.replace('Transcode', 'TC').replace('Direct ', '');
rowsHtml += `<div style="display:flex;align-items:center;gap:5px;margin-bottom:4px;min-width:0;overflow:hidden;">
${vvSrvIcon(s.server_type, s.server)}
<span style="font-size:10px;color:#888;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${s.title}</span>
<span style="font-size:9px;color:#555;flex-shrink:0;">${typeLabel(s.type)}</span>
<span style="font-size:9px;color:#ff9800;flex-shrink:0;">${meth}</span>
</div>`;
});
sessHtml = `<div style="margin-top:8px;border-top:1px solid #2a2a2a;padding-top:6px;">
<div style="font-size:10px;color:#555;margin-bottom:4px;">Active transcodes <span style="color:#ff9800;font-weight:600;">${activeSessions.length}</span></div>
<div style="max-height:57px;overflow-y:auto;overflow-x:hidden;">${rowsHtml}</div>
</div>`;
}
const nvmeColor = ssd.available ? '#4caf50' : '#f44336';
document.getElementById('vv-transcode-body').innerHTML =
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="display:flex;gap:6px;align-items:center;">
<span style="background:${locColor}22;border:1px solid ${locColor};color:${locColor};
padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600;">● ${loc}</span>
<span style="background:${nvmeColor}22;border:1px solid ${nvmeColor};color:${nvmeColor};
padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600;">● NVMe</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}`;
}
// ── Docker ──────────────────────────────────────────────────────────────
const containers = d.containers ?? [];
const tbody = document.getElementById('vv-docker-body');
tbody.innerHTML = containers.length
? containers.map(c =>
`<tr><td>${c.name}</td><td class="vv-status-${c.status.startsWith('Up') ? 'up' : 'down'}">${c.status}</td><td>${c.image}</td></tr>`
).join('')
: '<tr><td colspan="3">No running containers</td></tr>';
})
.catch(() => {});
}
vvPollMonitor();
setInterval(vvPollMonitor, 5000);
// Pin pools card width to CPU card width across rows
function vvSyncCardWidths() {
const cpu = document.getElementById('vv-cpu');
const pools = document.getElementById('vv-storage-card');
if (!cpu || !pools) return;
const w = cpu.offsetWidth;
if (w === 0) return;
pools.style.flex = 'none';
pools.style.boxSizing = 'border-box';
pools.style.width = w + 'px';
}
new ResizeObserver(vvSyncCardWidths).observe(document.getElementById('vv-cpu'));
requestAnimationFrame(vvSyncCardWidths);
// ── Media streams (slower poll — media server API calls) ──────────────────────
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('');
if (sessions.length === 0) {
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>`
+ '<p class="vv-stream-empty">Nothing playing</p>';
return;
}
const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - vvLastStreamPollAt);
function vvStreamRow(s) {
const isLive = s.type === 'LiveTvProgram' || s.dur_sec === 0;
const inc = s.paused ? 0 : elapsed;
const curSec = Math.max(0, (s.pos_sec ?? 0) + inc);
const barColor = isLive ? '#1e3a5a' : (s.is_tc ? '#e65100' : '#4caf50');
const tcColor = s.paused ? '#fdd835' : '#555';
const icon = s.paused ? '⏸' : '▶';
const iconColor = s.paused ? '#fdd835' : isLive ? '#2196f3' : s.is_tc ? '#ff9800' : '#aaa';
let timeStr = '';
let pct = s.pct ?? 0;
if (isLive) {
pct = 75;
timeStr = `<span style="color:${tcColor};">${vvFmtSec(curSec)}</span>`;
} else if (s.dur_sec > 0) {
pct = Math.min(100, Math.round(curSec / s.dur_sec * 100));
timeStr = `<span style="color:${tcColor};">${vvFmtSec(curSec)} / ${vvFmtSec(s.dur_sec)}</span>`;
}
return `<div>
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:2px;">
<span style="color:${s.paused ? '#fdd835' : '#aaa'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">
<span style="color:${iconColor};">${icon}</span> ${s.title}</span>
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${s.server}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:#555;margin-bottom:3px;">
<span>${s.user}</span>
${timeStr}
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.9s linear;"></div>
</div>
</div>`;
}
// Left-fill columns of 3 — col 1 fills first, col 2 opens when col 1 hits 3
const shown = sessions.slice(0, 12);
const perCol = 3;
const sCols = [];
for (let i = 0; i < shown.length; i += perCol) sCols.push(shown.slice(i, i + perCol));
const vvStreamCol = col =>
`<div style="flex:1;min-width:0;">${col.map(s =>
`<div style="margin-bottom:8px;">${vvStreamRow(s)}</div>`
).join('')}</div>`;
const overflow = sessions.length > 12
? `<div style="font-size:10px;color:#555;margin-top:4px;">+${sessions.length - 12} more not shown</div>`
: '';
el.innerHTML = `<div class="vv-stream-servers">${badges}</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);
// ── 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);
// ── 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>