@@ -1,57 +1,100 @@
< ? php require_once dirname ( __DIR__ ) . '/include/monitor.php' ; ?>
<div id="vv-monitor">
<div id="vv-monitor" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;" >
<div class="vv-row ">
<div class="vv-card" id="vv-system" style="flex:1;min-width:250px;">
<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="flex:1;min-width:250px ;">
<h3>Partner & Fallback</h3 >
<div class="vv-card" id="vv-partner" style="grid-column:span 1 ;">
<h3 style="display:flex;align-items:center;justify-content:space-between;" >
Partner & 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="flex:2;min-width:240px ;">
<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="flex:2;min-width:240px ;">
<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="flex:2;min-width:240px ;">
<div class="vv-card" id="vv-network" style="grid-column:span 2 ;">
<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;" >
<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">
<h3>Transcode</h3>
<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="flex:2;min-width:280px ;">
<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 vv-wide " id="vv-docker">
<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>
</div>
@@ -59,7 +102,7 @@
// ── Shared helpers ────────────────────────────────────────────────────────────
function vvMeter(label, pct, text) {
const color = pct >= 85 ? '#f44336' : pct >= 65 ? '#ff9800' : '#4caf50' ;
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>
@@ -73,7 +116,14 @@ function vvMeter(label, pct, text) {
// ── Rolling history ───────────────────────────────────────────────────────────
const VV_HIST_MAX = 24; // 24 × 5 s = 120 s
let vvCpuHistory = [];
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 = [];
@@ -203,8 +253,9 @@ function vvRenderCpu(cpu) {
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 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;">
@@ -219,9 +270,9 @@ function vvRenderCpu(cpu) {
// Freq legend
html += `<div style="display:flex;justify-content:space-between;font-size:9px;color:#444;margin-bottom:8px;">
<span style="color:hsl(2 20,70%,45%)">Low freq </span>
<span style="color:hsl(12 0,70%,45%)">Mi d</span>
<span style="color:hsl(0,70%,45%)">High freq </span>
<span style="color:hsl(1 20,70%,45%)">Low</span>
<span style="color:hsl(6 0,70%,45%)">Me d</span>
<span style="color:hsl(0,70%,45%)">High</span>
</div>`;
}
@@ -282,6 +333,56 @@ function vvRenderMemory(mem) {
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 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 (!running && !ok && !warn) 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' ? '!' : '?';
const color = s.status === 'running' ? '#4fc3f7' : s.status === 'ok' ? '#4caf50' : s.status === 'warn' ? '#ff9800' : '#555';
const name = s.name.length > 24 ? s.name.slice(0, 23) + '…' : s.name;
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}</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() {
@@ -289,6 +390,9 @@ function vvPollMonitor() {
.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();
@@ -304,10 +408,10 @@ function vvPollMonitor() {
<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;gap:6px ;flex-shrink:0;margin-left:8px;">
<button onclick="vvArrayAction('stop')" class="vv-sys-btn" title="Stop Array">■</button>
<button onclick="vvArrayAction('shutdown')" class="vv-sys-btn" title="Shutdown">⏻</button>
<button onclick="vvArrayAction('restart')" class="vv-sys-btn" title="Restart">↺</button>
<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>
@@ -318,7 +422,40 @@ function vvPollMonitor() {
<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>`;
</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 ?? {};
@@ -435,6 +572,183 @@ function vvPollMonitor() {
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 ?? [];
@@ -468,6 +782,10 @@ function vvPollMonitor() {
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) {
@@ -476,41 +794,90 @@ function vvPollMonitor() {
} 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';
// 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';
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)` ;
// 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>`;
}
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>` ;
// 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 ──────────────────────────────────────────────────────────────
@@ -528,6 +895,20 @@ function vvPollMonitor() {
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) {
@@ -539,63 +920,109 @@ function vvFmtSec(sec) {
: `${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 ? '#ff9800' : (s.is_tc ? '#ff9800' : '#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 => {
const s essions = d.sessions ?? [];
const n ames = d.server_names ?? [];
const el = document.getElementById('vv-streams-body') ;
vvLastS essions = d.sessions ?? [];
vvLastStreamN ames = d.server_names ?? [];
vvStreamServerCount = d.server_count ?? 0 ;
vvLastStreamPollAt = Math.floor(Date.now() / 1000);
if (d.server_count === 0) {
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;
}
// 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}`;
vvRenderStreams();
})
.catch(() => {});
}
vvPollStreams();
setInterval(vvPollStreams, 12000);
setInterval(vvRenderStreams, 1000);
// ── System clock tick (updates time every 30s without a full poll) ────────────
function vvTickClock() {