Files
Varaverk/Plugin/usr/local/emhttp/plugins/varaverk/pages/monitor.php
T
Gmer4Lfe ab3e28531e 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
2026-05-24 10:52:42 -04:00

530 lines
24 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">
<div class="vv-row">
<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-cpu" style="flex:2;min-width:240px;">
<h3>CPU</h3>
<div id="vv-cpu-body">Loading...</div>
</div>
<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 class="vv-card" id="vv-transcode">
<h3>Transcode</h3>
<div id="vv-transcode-body">Loading...</div>
</div>
<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">
<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>
</div>
<script>
// ── 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 ?? {};
const state = (fb.state ?? 'UNKNOWN').toUpperCase();
const badge = document.getElementById('vv-fallback-state');
badge.textContent = state;
badge.className = 'vv-state-badge vv-state-' + state.toLowerCase();
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>`;
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-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</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;">
<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
? 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);
// ── 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>