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
530 lines
24 KiB
PHP
530 lines
24 KiB
PHP
<?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 </span>${net.local_ip}</div>` : '',
|
||
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT </span>${net.ext_ip}</div>` : '',
|
||
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS </span>${net.ts_ip}</div>` : '',
|
||
].filter(Boolean).join('');
|
||
|
||
document.getElementById('vv-network-body').innerHTML =
|
||
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
|
||
<div>
|
||
<div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} · ${linkLabel}</div>
|
||
<div style="display:flex;gap:16px;font-size:13px;font-weight:600;">
|
||
<span 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>
|