Add media server cards, and stop re-counting whole libraries every minute

The counts need the entire library downloaded to compute six numbers — 16.0 MB
from Radarr and 5.5 MB from Sonarr, measured — and they were on the same
one-minute clock as queue depth and health, which pulled ~30 GB a day out of the
arrs to re-count records that had not changed. They now cache for fifteen
minutes against how often the numbers actually move: 7.6s to 3.0s per refresh.

Emby and Jellyfin get a card each: version, CPU, memory, uptime, streams, users
and transcodes, with update/restart flags. Two halves of one question — a server
answering happily at 145% CPU is a different situation from one at 8%, and
neither the app nor the container says so alone.
This commit is contained in:
Gmer4Lfe
2026-08-14 19:23:10 -04:00
parent e149046272
commit 24710af156
3 changed files with 255 additions and 25 deletions
+89
View File
@@ -105,6 +105,13 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
.vv-arr-pill.ok { background:#0d1f0d;color:#4caf50;border:1px solid #1a3a1a; }
.vv-arr-pill.err { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
.vv-arr-pill.off { background:#1e1e1e;color:#444;border:1px solid #2a2a2a; }
/* ── Media server filter ─────────────────────────────────── */
.vv-arr-mstab { background:#111;border:1px solid #242424;color:#555;font-size:10px;
padding:2px 10px;border-radius:3px;cursor:pointer;text-transform:uppercase;
letter-spacing:.05em; }
.vv-arr-mstab:hover { color:#bbb;border-color:#3a3a3a; }
.vv-arr-mstab.on { color:#4caf50;border-color:#2d4a2d;background:#0d1a0d; }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
@@ -386,6 +393,84 @@ function _dur(s) {
return sec + 's';
}
// ── Media servers ─────────────────────────────────────────────────────────────
// Two halves of one question. The application supplies its version, whether an update or a restart
// is waiting, and who is watching; the container supplies what that costs in CPU, memory and how
// long it has been up. A server answering happily at 145% CPU is a different situation from one
// answering happily at 8%, and neither half says so alone.
let _vvMsFilter = 'all';
function _mediaServersSection(servers) {
if (!servers || !servers.length) return '';
const types = [...new Set(servers.map(s => s.type))];
// The filter only earns its space when there is something to filter. One server means the
// buttons would be three ways of saying the same thing.
const tabs = types.length > 1
? `<div style="display:flex;gap:4px;margin-bottom:8px;">` +
[['all', 'All'], ...types.map(t => [t, t.charAt(0).toUpperCase() + t.slice(1)])].map(([k, lbl]) =>
`<button class="vv-arr-mstab${_vvMsFilter === k ? ' on' : ''}" data-mstab="${vvEscAttr(k)}">${vvEscHtml(lbl)}</button>`
).join('') + `</div>`
: '';
const shown = servers.filter(s => _vvMsFilter === 'all' || s.type === _vvMsFilter);
const cards = shown.map(s => {
const up = !!s.online;
const dot = !up ? '#c62828' : s.pending_restart || s.update_available ? '#ffb74d' : '#4caf50';
// Colour the CPU against cores, not against 100%: these are containers on a 16-core host, so
// 145% is busy rather than broken, and 1500% would be the number that matters.
const cpu = s.cpu_pct;
const cpuCol = cpu == null ? '#555' : cpu > 400 ? '#ef5350' : cpu > 150 ? '#ffb74d' : '#888';
const flags = [];
if (s.update_available) flags.push(['update available', '#ffb74d']);
if (s.pending_restart) flags.push(['restart pending', '#ffb74d']);
if (s.maintenance) flags.push(['maintenance mode', '#ef5350']);
const row = (l, v) => `<div class="vv-arr-meta"><span class="vv-arr-meta-lbl">${l}</span><span class="vv-arr-meta-val">${v}</span></div>`;
return `<div class="vv-arr-card">
<div class="vv-arr-hdr">
<span class="vv-arr-name">${vvEscHtml(s.name)}</span>
<span><span class="vv-arr-dot" style="background:${dot}"></span>
<span class="vv-arr-ver">${vvEscHtml(up ? (s.version || 'online') : 'offline')}</span></span>
</div>
${up ? `
<div class="vv-arr-stats">
<div class="vv-arr-stat"><div class="vv-arr-stat-n${s.sessions ? '' : ' dim'}">${_n(s.sessions)}</div><div class="vv-arr-stat-l">Streams</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n${s.users ? '' : ' dim'}">${_n(s.users)}</div><div class="vv-arr-stat-l">Users</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n${s.transcodes ? '' : ' dim'}">${_n(s.transcodes)}</div><div class="vv-arr-stat-l">Transcode</div></div>
</div>
${row('CPU', `<span style="color:${cpuCol}">${cpu == null ? '—' : cpu.toFixed(1) + '%'}</span>`)}
${row('Memory', vvEscHtml(s.mem_used || '—') + (s.mem_pct != null ? ` <span style="color:#3a3a3a">${s.mem_pct.toFixed(1)}%</span>` : ''))}
${row('Uptime', s.uptime != null ? _uptime(s.uptime) : '—')}
${s.os ? row('Host OS', vvEscHtml(s.os)) : ''}
${flags.length ? `<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:6px;">` +
flags.map(([t, c]) => `<span class="vv-arr-pill" style="color:${c};border:1px solid ${c}33;background:${c}14;">${t}</span>`).join('') +
`</div>` : ''}
<div style="font-size:9px;color:#2a2a2a;margin-top:6px;">checked ${_relTime(s.checked)}</div>
` : `<div style="font-size:11px;color:#444;padding:6px 0;">Did not answer — ${vvEscHtml(s.container || 'container')} may be down</div>`}
</div>`;
}).join('');
return `<div style="grid-column:1/-1;">
<div style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;">Media servers</div>
${tabs}
<div class="vv-arr-cards">${cards}</div>
</div>`;
}
// Delegated, because the row is rebuilt on every poll and per-button handlers would be rebound
// constantly. Re-renders from the payload already in hand rather than refetching — the filter is
// a view over data the page has, not a new question for the server.
document.addEventListener('click', ev => {
const b = ev.target.closest('.vv-arr-mstab');
if (!b) return;
_vvMsFilter = b.dataset.mstab || 'all';
if (_vvArrsLast) _render(_vvArrsLast);
});
// ── Media jobs ────────────────────────────────────────────────────────────────
// Play state sync, permissions and the cleaner. They work on the same files the arrs manage and
// appeared in no tab at all — the only way to know whether one had run was to open the Scheduler
@@ -473,7 +558,10 @@ function _settingsCard(s) {
}
// ── Main render ───────────────────────────────────────────────────────────────
let _vvArrsLast = null;
function _render(data) {
_vvArrsLast = data;
const nodes = data.nodes || [];
if (!nodes.length && !(data.sync?.last_run) && !(data.recovery?.last_run)) {
document.getElementById('vv-arrs-grid').innerHTML =
@@ -486,6 +574,7 @@ function _render(data) {
let html = '';
for (const node of nodes) html += _nodeSection(node);
html += _syncSection(data.sync || {}, data.recovery || {}, data.settings);
html += _mediaServersSection(data.media_servers);
html += _mediaJobsSection(data.media_jobs);
html += _settingsCard(data.settings);