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

601 lines
28 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" style="flex:1;min-width:220px;">
<h3>Fallback State</h3>
<div id="vv-fallback-body">Loading...</div>
</div>
<div class="vv-card" id="vv-partner" style="flex:1;min-width:220px;">
<h3>Partner</h3>
<div id="vv-partner-body">Loading...</div>
</div>
<div class="vv-card" id="vv-cpu" style="flex:1;min-width:220px;">
<h3>CPU</h3>
<div id="vv-cpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-memory" style="flex:1;min-width:220px;">
<h3>Memory</h3>
<div id="vv-memory-body">Loading...</div>
</div>
<div class="vv-card" id="vv-network" style="flex:1;min-width:220px;">
<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 vvGpuHistory = [];
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: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;
}
// ── Poll ──────────────────────────────────────────────────────────────────────
function vvPollMonitor() {
fetch('/plugins/varaverk/api/monitor.php')
.then(r => r.json())
.then(d => {
// ── Fallback state ──────────────────────────────────────────────────────
// ── Fallback state ──────────────────────────────────────────────────────
const fb = d.fallback ?? {};
const fbHosts = (d.partner ?? {}).hosts ?? [];
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];
// Host rows
let fbHtml = '';
fbHosts.forEach(h => {
const dot = h.online === null ? '#555' : h.online ? '#4caf50' : '#f44336';
const label = h.online === null ? 'unknown' : h.online ? 'online' : 'offline';
const meTag = h.is_me ? `<span style="background:#1a3a1a;color:#4caf50;font-size:8px;padding:1px 5px;border-radius:3px;margin-left:4px;">US</span>` : '';
fbHtml += `<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>${meTag}
<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>`;
});
// State line
fbHtml += `<div style="font-size:11px;color:${sColor};margin:8px 0;padding-top:8px;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;
fbHtml += ` · ${Math.floor(sec/3600)}h ${Math.floor((sec%3600)/60)}m`;
}
}
fbHtml += `</div>`;
// Active fallback containers
if (fbActive.length) {
fbActive.forEach(group => {
fbHtml += `<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;
fbHtml += `<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-fallback-body').innerHTML = fbHtml;
// ── Partner ─────────────────────────────────────────────────────────────
const pt = d.partner ?? {};
const ptHosts = pt.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:10px;">${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:8px;">
<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:#666;margin-top:1px;">${h.hostname}</div>
</div>
<div style="text-align:right;">
<span style="color:${dot};font-size:10px;">● ${label}</span>
</div>
</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>';
}
// ── 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>` +
`<canvas id="vv-gpu-canvas" style="width:100%;height:60px;display:block;"></canvas>`;
vvGpuHistory.push(utilPct);
if (vvGpuHistory.length > VV_HIST_MAX) vvGpuHistory.shift();
vvDrawChart(document.getElementById('vv-gpu-canvas'), vvGpuHistory, '#7e57c2', 'rgba(126,87,194,0.18)', true);
} 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>