varaverk: monitor overhaul — CPU/memory/network/streams cards; scheduler polish

Monitor tab:
- CPU card: per-core bars (freq-colored), overall usage bar, rolling history chart
- Memory card: breakdown by system/VM/ZFS/Docker/free with progress bars
- Network card: LAN + ext + Tailscale IP display, live RX/TX chart with auto-scale
- Streams card: Emby/Jellyfin/Plex sessions with playback bar and server badges
- GPU card: adds power draw, encode%, decode% meters
- Transcode card: ramdisk vs SSD location pill, session count, flip history
- Canvas chart helpers: vvDrawChart, vvDrawNetChart, vvMeter, vvFmtBps, vvFmtGib

api/monitor.php: adds cpu, mem, net to response payload
This commit is contained in:
Gmer4Lfe
2026-05-24 10:52:42 -04:00
parent f7bef55e51
commit ab3e28531e
7 changed files with 938 additions and 61 deletions
@@ -6,27 +6,41 @@
<div class="vv-card" id="vv-fallback">
<h3>Fallback State</h3>
<div class="vv-state-badge" id="vv-fallback-state">—</div>
<div id="vv-fallback-desc"></div>
</div>
<div class="vv-card" id="vv-resources">
<h3>Resources</h3>
<div id="vv-resource-body">Loading...</div>
<div class="vv-card" id="vv-cpu" style="flex:2;min-width:240px;">
<h3>CPU</h3>
<div id="vv-cpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-gpu-card">
<div class="vv-card" id="vv-memory" style="flex:2;min-width:240px;">
<h3>Memory</h3>
<div id="vv-memory-body">Loading...</div>
</div>
<div class="vv-card" id="vv-network" style="flex:2;min-width:240px;">
<h3>Network</h3>
<div id="vv-network-body">Loading...</div>
</div>
</div>
<div class="vv-row">
<div class="vv-card" id="vv-gpu-card" style="flex:2;min-width:240px;">
<h3>GPU</h3>
<div id="vv-gpu-body">Loading...</div>
</div>
</div>
<div class="vv-row">
<div class="vv-card vv-wide" id="vv-transcode">
<div class="vv-card" id="vv-transcode">
<h3>Transcode</h3>
<div id="vv-transcode-body">Loading...</div>
</div>
</div>
<div class="vv-row">
<div class="vv-card" id="vv-streams" style="flex:2;min-width:280px;">
<h3>Streams</h3>
<div id="vv-streams-body">Loading...</div>
</div>
<div class="vv-card vv-wide" id="vv-docker">
<h3>Containers</h3>
<table id="vv-docker-table">
@@ -39,36 +53,397 @@
</div>
<script>
// Poll monitor API every 5 seconds
// ── Shared helpers ────────────────────────────────────────────────────────────
function vvMeter(label, pct, text) {
const color = pct >= 85 ? '#f44336' : pct >= 65 ? '#ff9800' : '#4caf50';
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 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 color = vvCoreColor(c.freq_mhz, c.max_mhz, c.min_mhz);
const usePct = c.usage_pct ?? 0;
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(220,70%,45%)">Low freq</span>
<span style="color:hsl(120,70%,45%)">Mid</span>
<span style="color:hsl(0,70%,45%)">High freq</span>
</div>`;
}
// Canvas chart
html += `<canvas id="vv-cpu-canvas" style="width:100%;height:36px;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';
let html = `<div style="font-size:14px;font-weight:600;color:${totalColor};margin-bottom:10px;">
${vvFmtGib(usedKb)} <span style="font-size:11px;color:#555;font-weight:400;">/ ${vvFmtGib(total)}</span>
</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;
}
// ── Poll ──────────────────────────────────────────────────────────────────────
function vvPollMonitor() {
fetch('/plugins/varaverk/api/monitor.php')
.then(r => r.json())
.then(d => {
// Fallback state
const fb = d.fallback ?? {};
// ── Fallback state ──────────────────────────────────────────────────────
const fb = d.fallback ?? {};
const state = (fb.state ?? 'UNKNOWN').toUpperCase();
const badge = document.getElementById('vv-fallback-state');
badge.textContent = fb.state ?? '—';
badge.className = 'vv-state-badge vv-state-' + (fb.state ?? 'unknown').toLowerCase();
badge.textContent = state;
badge.className = 'vv-state-badge vv-state-' + state.toLowerCase();
// Resources
const res = d.resources ?? {};
document.getElementById('vv-resource-body').innerHTML =
`<p>RAM: ${res.ram_free_mb ?? '—'} MB free / ${res.ram_total_mb ?? '—'} MB</p>
<p>CPU: ${res.cpu_percent ?? '—'}%</p>
<p>Ramdisk: ${res.ramdisk?.used_mb ?? '—'} MB / ${res.ramdisk?.size_mb ?? '—'} MB</p>`;
const descMap = {
NORMAL: ['#4caf50', '✓', 'Nominal'],
FAILOVER: ['#f44336', '⚠', 'Running peer containers'],
NO_INTERNET: ['#ff9800', '⚡', 'Internet lost'],
DARK: ['#9e9e9e', '◌', 'Isolation mode'],
UNKNOWN: ['#555', '?', 'State unavailable'],
};
const [dColor, dIcon, dText] = descMap[state] ?? ['#555', '?', ''];
let descHtml = `<div style="font-size:12px;color:${dColor};margin-top:6px;">${dIcon} ${dText}</div>`;
// GPU
const gpu = d.gpu ?? {};
if (gpu.available) {
document.getElementById('vv-gpu-body').innerHTML =
`<p>${gpu.name}</p>
<p>VRAM: ${gpu.memory_used} / ${gpu.memory_total} MB</p>
<p>Util: ${gpu.utilization}% &nbsp; Temp: ${gpu.temperature}°C</p>`;
if (state === 'FAILOVER') {
const start = parseInt(fb.failover_start ?? 0);
if (start > 0) {
const sec = Math.floor(Date.now() / 1000) - start;
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60);
descHtml += `<div style="font-size:11px;color:#888;margin-top:3px;">Duration: ${h}h ${m}m</div>`;
}
const tiers = ['tier2_started','tier3_started','tier4_started']
.filter(k => fb[k] === 'true')
.map(k => k.replace('_started','').replace('tier','T'));
if (tiers.length)
descHtml += `<div style="font-size:11px;color:#f44336;margin-top:3px;">Active: ${tiers.join(' ')}</div>`;
}
document.getElementById('vv-fallback-desc').innerHTML = descHtml;
// ── 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 style="color:#4caf50;">↓ ${vvFmtBps(rx)}</span>
<span style="color:#ff9800;">↑ ${vvFmtBps(tx)}</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:52px;display:block;margin-bottom:4px;"></canvas>
<div style="display:flex;gap:12px;font-size:9px;color:#555;">
<span><span style="color:#4caf50;">━</span> IN (RX)</span>
<span><span style="color:#ff9800;">━</span> OUT (TX)</span>
</div>`;
vvDrawNetChart(document.getElementById('vv-net-canvas'), vvNetRxHistory, vvNetTxHistory, maxBps);
} else {
document.getElementById('vv-gpu-body').innerHTML = '<p>No GPU detected</p>';
document.getElementById('vv-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</p>';
}
// Docker
// ── 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;">
<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>';
}
// ── 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';
const rd = tc.ramdisk ?? {};
const usedMb = rd.used_mb ?? 0;
const sizeMb = rd.size_mb ?? 0;
const pct = sizeMb > 0 ? Math.round(usedMb / sizeMb * 100) : 0;
const barColor = pct > 85 ? '#f44336' : pct > 65 ? '#ff9800' : '#4caf50';
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';
}
const totalSess = (tc.ram_sessions ?? 0) + (tc.ssd_sessions ?? 0);
let sessStr = totalSess === 0 ? 'none' : totalSess + ' active';
if (tc.ram_sessions > 0 && tc.ssd_sessions > 0)
sessStr += ` (split: ${tc.ram_sessions} RAM / ${tc.ssd_sessions} SSD)`;
document.getElementById('vv-transcode-body').innerHTML = `
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="background:${locColor}22;border:1px solid ${locColor};color:${locColor};
padding:2px 10px;border-radius:12px;font-size:12px;font-weight:bold;">● ${loc}</span>
<span style="color:#888;font-size:12px;">Sessions: ${sessStr}</span>
<span style="color:#666;font-size:12px;margin-left:auto;">Flips this hour: ${tc.flip_count_hour ?? 0}</span>
</div>
<div style="margin-bottom:4px;">
<div style="display:flex;justify-content:space-between;font-size:11px;color:#666;margin-bottom:3px;">
<span>Ramdisk</span><span>${usedMb} MB / ${sizeMb} MB (${pct}%)</span>
</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.4s;"></div>
</div>
</div>
<div style="font-size:11px;color:#555;margin-top:6px;">Last flip: ${flipStr}</div>`;
}
// ── Docker ──────────────────────────────────────────────────────────────
const containers = d.containers ?? [];
const tbody = document.getElementById('vv-docker-body');
tbody.innerHTML = containers.length
@@ -77,9 +452,78 @@ function vvPollMonitor() {
).join('')
: '<tr><td colspan="3">No running containers</td></tr>';
})
.catch(() => {}); // silent on poll failure
.catch(() => {});
}
vvPollMonitor();
setInterval(vvPollMonitor, 5000);
// ── 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 vvPollStreams() {
fetch('/plugins/varaverk/api/media.php')
.then(r => r.json())
.then(d => {
const sessions = d.sessions ?? [];
const names = d.server_names ?? [];
const el = document.getElementById('vv-streams-body');
if (d.server_count === 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;
}
// Server badges in header
const badges = names.map(n =>
`<span class="vv-server-badge">${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 rows = sessions.map(s => {
const icon = s.paused ? '⏸' : '▶';
const barColor = s.is_tc ? '#ff9800' : '#4caf50';
const methColor = s.is_tc ? '#ff9800' : '#4caf50';
const timeStr = s.dur_sec > 0
? `${vvFmtSec(s.pos_sec)} / ${vvFmtSec(s.dur_sec)}`
: '';
return `<div class="vv-stream-row">
<div class="vv-stream-top">
<span class="vv-stream-icon">${icon}</span>
<span class="vv-stream-title" title="${s.title}">${s.title}</span>
<span class="vv-server-badge vv-server-badge-sm">${s.server}</span>
</div>
<div class="vv-stream-meta">
<span class="vv-stream-user">${s.user}</span>
<span class="vv-stream-client">${s.client}</span>
<span class="vv-stream-method" style="color:${methColor};">${s.method}</span>
${timeStr ? `<span class="vv-stream-time">${timeStr}</span>` : ''}
</div>
<div class="vv-stream-bar">
<div style="width:${s.pct}%;background:${barColor};"></div>
</div>
</div>`;
}).join('');
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>${rows}`;
})
.catch(() => {});
}
vvPollStreams();
setInterval(vvPollStreams, 12000);
</script>