Arrs page overhaul: big stats, disk bars, queue badges, health strips, settings card
This commit is contained in:
@@ -7,4 +7,23 @@ if ($_vv_cached !== null) { echo json_encode($_vv_cached); exit; }
|
||||
unset($_vv_cached);
|
||||
|
||||
require_once dirname(__DIR__) . '/include/arrs.php';
|
||||
echo json_encode(vv_arrs_all());
|
||||
|
||||
$data = vv_arrs_all();
|
||||
$vars = vv_conf_vars();
|
||||
$myHost = vv_detect_host();
|
||||
$myId = strtoupper($myHost);
|
||||
|
||||
$data['settings'] = [
|
||||
'arr_sync_enabled' => ($vars['ARR_SYNC_ENABLED'] ?? 'true') !== 'false',
|
||||
'sonarr_recovery' => ($vars[$myId . '_SONARR_RECOVERY'] ?? 'true') !== 'false',
|
||||
'radarr_recovery' => ($vars[$myId . '_RADARR_RECOVERY'] ?? 'true') !== 'false',
|
||||
'lidarr_recovery' => ($vars[$myId . '_LIDARR_RECOVERY'] ?? 'true') !== 'false',
|
||||
'recovery_age_hours' => (int)($vars['ARR_IMPORT_RECOVERY_AGE'] ?? 6),
|
||||
'sonarr_port' => (int)($vars['ARR_SYNC_SONARR_PORT'] ?? 8989),
|
||||
'radarr_port' => (int)($vars['ARR_SYNC_RADARR_PORT'] ?? 7878),
|
||||
'lidarr_port' => (int)($vars['ARR_SYNC_LIDARR_PORT'] ?? 8686),
|
||||
'my_host' => $myHost,
|
||||
'my_id' => $myId,
|
||||
];
|
||||
|
||||
echo json_encode($data);
|
||||
|
||||
+364
-164
@@ -1,26 +1,87 @@
|
||||
<style>
|
||||
.vv-arr-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:10px;min-width:0; }
|
||||
.vv-arr-head { display:flex;justify-content:space-between;align-items:center;margin-bottom:8px; }
|
||||
.vv-arr-name { font-size:12px;font-weight:bold;text-transform:uppercase;color:#888;letter-spacing:.05em; }
|
||||
.vv-arr-status{ display:flex;align-items:center;gap:5px; }
|
||||
.vv-arr-dot { width:7px;height:7px;border-radius:50%;flex-shrink:0; }
|
||||
.vv-arr-ver { font-size:10px;color:#555; }
|
||||
.vv-arr-row { display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin:2px 0; }
|
||||
.vv-arr-lbl { font-size:11px;color:#555;white-space:nowrap; }
|
||||
.vv-arr-val { font-size:12px;color:#bbb;text-align:right; }
|
||||
.vv-arr-sep { border:none;border-top:1px solid #222;margin:6px 0; }
|
||||
.vv-arr-sub { font-size:10px;color:#444;margin:1px 0 1px 2px; }
|
||||
.vv-arr-sub.warn { color:#e57; }
|
||||
.vv-arr-path { font-size:10px;color:#393939;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:4px; }
|
||||
/* ── Cards ───────────────────────────────────────────────── */
|
||||
.vv-arr-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:12px 14px;min-width:0; }
|
||||
.vv-arr-sep { border:none;border-top:1px solid #1e1e1e;margin:8px 0; }
|
||||
|
||||
/* ── Card header ─────────────────────────────────────────── */
|
||||
.vv-arr-hdr { display:flex;justify-content:space-between;align-items:center;margin-bottom:10px; }
|
||||
.vv-arr-name { font-size:12px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.06em; }
|
||||
.vv-arr-dot { width:7px;height:7px;border-radius:50%;flex-shrink:0;margin-right:4px;display:inline-block; }
|
||||
.vv-arr-ver { font-size:10px;color:#444; }
|
||||
|
||||
/* ── Big stats ───────────────────────────────────────────── */
|
||||
.vv-arr-stats { display:flex;gap:0;margin-bottom:10px; }
|
||||
.vv-arr-stat { flex:1;text-align:center;padding:0 4px; }
|
||||
.vv-arr-stat + .vv-arr-stat { border-left:1px solid #1e1e1e; }
|
||||
.vv-arr-stat-n{ font-size:18px;font-weight:700;color:#ccc;line-height:1.1; }
|
||||
.vv-arr-stat-l{ font-size:9px;color:#444;text-transform:uppercase;letter-spacing:.06em;margin-top:1px; }
|
||||
.vv-arr-stat-n.dim { color:#333; }
|
||||
|
||||
/* ── Disk bar ────────────────────────────────────────────── */
|
||||
.vv-arr-disk { margin-bottom:8px; }
|
||||
.vv-arr-disk-bar { height:3px;background:#1a1a1a;border-radius:2px;overflow:hidden;margin-top:3px; }
|
||||
.vv-arr-disk-fill{ height:100%;border-radius:2px;transition:width .3s; }
|
||||
.vv-arr-disk-lbl { display:flex;justify-content:space-between;align-items:baseline; }
|
||||
|
||||
/* ── Queue badges ────────────────────────────────────────── */
|
||||
.vv-arr-q { display:flex;gap:6px;align-items:center;flex-wrap:wrap; }
|
||||
.vv-arr-qbadge{ font-size:10px;padding:1px 7px;border-radius:3px;font-weight:600; }
|
||||
.vv-arr-qbadge.dl { background:#0a1a2a;color:#4a9eff;border:1px solid #1a3a5a; }
|
||||
.vv-arr-qbadge.warn { background:#1f1500;color:#ffb74d;border:1px solid #3a2800; }
|
||||
.vv-arr-qbadge.err { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
|
||||
.vv-arr-qbadge.idle { background:#111;color:#333;border:1px solid #1e1e1e; }
|
||||
|
||||
/* ── Health ──────────────────────────────────────────────── */
|
||||
.vv-arr-health{ background:#1a0a0a;border-left:2px solid #ef5350;border-radius:0 3px 3px 0;
|
||||
padding:5px 8px;margin-top:6px;font-size:10px;color:#c66;line-height:1.5; }
|
||||
.vv-arr-health.warn { background:#1a1000;border-color:#ff9800;color:#c96; }
|
||||
|
||||
/* ── Cleanup / Discovery rows ────────────────────────────── */
|
||||
.vv-arr-meta { display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin:3px 0; }
|
||||
.vv-arr-meta-lbl{ font-size:10px;color:#444; }
|
||||
.vv-arr-meta-val{ font-size:10px;color:#666;text-align:right; }
|
||||
.vv-arr-meta-sub{ font-size:9px;color:#333;margin-top:0px; }
|
||||
.vv-arr-meta-sub.warn { color:#8b4; }
|
||||
|
||||
/* ── Root path ───────────────────────────────────────────── */
|
||||
.vv-arr-root { font-size:10px;color:#2a2a2a;white-space:nowrap;overflow:hidden;
|
||||
text-overflow:ellipsis;margin-top:8px;font-family:monospace; }
|
||||
|
||||
/* ── Node section ────────────────────────────────────────── */
|
||||
.vv-arr-node { grid-column:1/-1; }
|
||||
.vv-arr-node-head { display:flex;align-items:baseline;gap:8px;margin-bottom:10px; }
|
||||
.vv-arr-node-title{ font-size:12px;font-weight:bold;color:#666;letter-spacing:.06em;text-transform:uppercase; }
|
||||
.vv-arr-node-host { font-size:11px;color:#3a3a3a; }
|
||||
.vv-arr-cards { display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px; }
|
||||
.vv-arr-stat-row { display:flex;gap:16px;flex-wrap:wrap; }
|
||||
.vv-arr-stat { display:flex;flex-direction:column;gap:1px; }
|
||||
.vv-arr-stat-val { font-size:15px;font-weight:bold;color:#ccc; }
|
||||
.vv-arr-stat-lbl { font-size:10px;color:#555; }
|
||||
.vv-arr-node-hdr { display:flex;align-items:baseline;gap:10px;margin-bottom:10px;
|
||||
padding-bottom:7px;border-bottom:1px solid #1e1e1e; }
|
||||
.vv-arr-node-id { font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.08em; }
|
||||
.vv-arr-node-host { font-size:12px;color:#888; }
|
||||
.vv-arr-node-tag { font-size:9px;color:#2a3a2a;background:#0d180d;border:1px solid #1a3a1a;
|
||||
border-radius:3px;padding:1px 6px;margin-left:2px; }
|
||||
.vv-arr-cards { display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px; }
|
||||
|
||||
/* ── Sync + Recovery cards ───────────────────────────────── */
|
||||
.vv-arr-sr-grid { grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;gap:10px; }
|
||||
@media (max-width:700px) { .vv-arr-sr-grid { grid-template-columns:1fr; } }
|
||||
.vv-arr-sr-num { font-size:20px;font-weight:700;color:#ccc;line-height:1; }
|
||||
.vv-arr-sr-lbl { font-size:9px;color:#444;text-transform:uppercase;letter-spacing:.06em;margin-top:2px; }
|
||||
.vv-arr-sr-row { display:flex;gap:16px;flex-wrap:wrap; }
|
||||
.vv-arr-sr-item { display:flex;flex-direction:column;gap:1px; }
|
||||
|
||||
/* ── Settings card ───────────────────────────────────────── */
|
||||
.vv-arr-set-card { grid-column:1/-1; }
|
||||
.vv-arr-tog-wrap { display:inline-flex;align-items:center;gap:8px;cursor:pointer;user-select:none; }
|
||||
.vv-arr-tog-track{ width:28px;height:16px;border-radius:8px;background:#1e1e1e;border:1px solid #2a2a2a;
|
||||
position:relative;transition:background .15s,border-color .15s;flex-shrink:0; }
|
||||
.vv-arr-tog-track.on { background:#1a3a1a;border-color:#2d5a2d; }
|
||||
.vv-arr-tog-track::after { content:'';position:absolute;top:2px;left:2px;width:10px;height:10px;
|
||||
border-radius:50%;background:#444;transition:left .15s,background .15s; }
|
||||
.vv-arr-tog-track.on::after { left:14px;background:#4caf50; }
|
||||
.vv-arr-tog-lbl { font-size:11px;color:#666; }
|
||||
.vv-arr-set-inp { background:#0d0d0d;border:1px solid #252525;border-radius:3px;color:#888;
|
||||
font-size:11px;padding:3px 7px;outline:none;width:60px; }
|
||||
.vv-arr-set-inp:focus { border-color:#444; }
|
||||
.vv-arr-pill { display:inline-block;font-size:9px;padding:1px 7px;border-radius:3px;font-weight:700; }
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
|
||||
@@ -29,188 +90,293 @@
|
||||
</div>
|
||||
|
||||
<div id="vv-arrs-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
|
||||
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
|
||||
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:24px 0;text-align:center;">Loading…</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
|
||||
function _relTime(ts) {
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function _rel(ts) {
|
||||
if (!ts) return '—';
|
||||
const d = Math.floor(Date.now() / 1000) - ts;
|
||||
if (d < 60) return 'just now';
|
||||
if (d < 3600) return Math.floor(d / 60) + 'm ago';
|
||||
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
|
||||
if (d < 172800) return 'yesterday';
|
||||
return Math.floor(d / 86400) + 'd ago';
|
||||
if (d < 60) return 'just now';
|
||||
if (d < 3600) return Math.floor(d/60) + 'm ago';
|
||||
if (d < 86400) return Math.floor(d/3600) + 'h ago';
|
||||
if (d < 172800)return 'yesterday';
|
||||
return Math.floor(d/86400) + 'd ago';
|
||||
}
|
||||
|
||||
function _n(v) { return v == null ? '—' : Number(v).toLocaleString(); }
|
||||
|
||||
function _bytes(b) {
|
||||
function _n(v) { return v == null ? '—' : Number(v).toLocaleString(); }
|
||||
function _gb(b) { if (!b) return '—'; return (b / 1073741824).toFixed(1) + ' GB'; }
|
||||
function _tb(b) { if (!b) return '—'; return (b / 1099511627776).toFixed(2) + ' TB'; }
|
||||
function _sz(b) {
|
||||
if (!b) return '—';
|
||||
const u = ['B','KB','MB','GB','TB']; let i = 0;
|
||||
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
|
||||
return b.toFixed(1) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function _row(lbl, val) {
|
||||
return `<div class="vv-arr-row"><span class="vv-arr-lbl">${lbl}</span><span class="vv-arr-val">${val}</span></div>`;
|
||||
if (b >= 1099511627776) return (b/1099511627776).toFixed(1) + ' TB';
|
||||
if (b >= 1073741824) return (b/1073741824).toFixed(1) + ' GB';
|
||||
return (b/1048576).toFixed(0) + ' MB';
|
||||
}
|
||||
|
||||
// ── Arr card ──────────────────────────────────────────────────────────────────
|
||||
function _arrCard(arr) {
|
||||
const names = {sonarr:'Sonarr', radarr:'Radarr', lidarr:'Lidarr'};
|
||||
const name = names[arr.type] || arr.type;
|
||||
const online = arr.online;
|
||||
const LABELS = { sonarr:'Sonarr', radarr:'Radarr', lidarr:'Lidarr' };
|
||||
const name = LABELS[arr.type] || arr.type;
|
||||
const online = !!arr.online;
|
||||
const dotCol = online ? '#4caf50' : (arr.remote ? '#444' : '#c62828');
|
||||
const verStr = online ? (arr.version || 'online') : (arr.remote ? 'not reachable' : 'offline');
|
||||
|
||||
let body = '';
|
||||
// ── Offline / remote state ────────────────────────────────────────────────
|
||||
if (!online) {
|
||||
const msg = arr.remote ? 'not available from this host' : 'offline';
|
||||
return `<div class="vv-arr-card" style="opacity:.6;">
|
||||
<div class="vv-arr-hdr">
|
||||
<span class="vv-arr-name">${name}</span>
|
||||
<span><span class="vv-arr-dot" style="background:${dotCol}"></span>
|
||||
<span class="vv-arr-ver">${msg}</span></span>
|
||||
</div>
|
||||
${arr.root ? `<div class="vv-arr-root">${arr.root}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (online) {
|
||||
// Library
|
||||
if (arr.type === 'sonarr') {
|
||||
body += _row('Series', _n(arr.total));
|
||||
body += _row('Monitored', _n(arr.monitored));
|
||||
body += _row('Episodes', _n(arr.episodes));
|
||||
} else if (arr.type === 'radarr') {
|
||||
body += _row('Movies', _n(arr.total));
|
||||
body += _row('Monitored', _n(arr.monitored));
|
||||
body += _row('Files', _n(arr.files));
|
||||
} else if (arr.type === 'lidarr') {
|
||||
body += _row('Artists', _n(arr.total));
|
||||
body += _row('Monitored', _n(arr.monitored));
|
||||
if (arr.albums) body += _row('Albums', _n(arr.albums));
|
||||
}
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||
let stats = '';
|
||||
if (arr.type === 'sonarr') {
|
||||
stats = `
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.total)}</div><div class="vv-arr-stat-l">Series</div></div>
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.monitored)}</div><div class="vv-arr-stat-l">Monitored</div></div>
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.episodes)}</div><div class="vv-arr-stat-l">Episodes</div></div>`;
|
||||
} else if (arr.type === 'radarr') {
|
||||
const pct = arr.total ? Math.round((arr.files||0)/arr.total*100) : 0;
|
||||
stats = `
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.total)}</div><div class="vv-arr-stat-l">Movies</div></div>
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.files)}</div><div class="vv-arr-stat-l">Have file</div></div>
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n ${pct<50?'dim':''}">${pct}%</div><div class="vv-arr-stat-l">Complete</div></div>`;
|
||||
} else if (arr.type === 'lidarr') {
|
||||
stats = `
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.total)}</div><div class="vv-arr-stat-l">Artists</div></div>
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.monitored)}</div><div class="vv-arr-stat-l">Monitored</div></div>
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.albums)}</div><div class="vv-arr-stat-l">Albums</div></div>`;
|
||||
}
|
||||
|
||||
// Disk — show largest entry
|
||||
if (arr.disk && arr.disk.length) {
|
||||
const d = arr.disk.reduce((a,b) => (b.totalSpace||0) > (a.totalSpace||0) ? b : a);
|
||||
if (d.freeSpace && d.totalSpace) {
|
||||
body += _row('Free', _bytes(d.freeSpace) + ' / ' + _bytes(d.totalSpace));
|
||||
}
|
||||
}
|
||||
|
||||
// Queue
|
||||
const q = arr.queue || {};
|
||||
const qDl = q.dl || 0;
|
||||
const qWrn = q.warn || 0;
|
||||
const qErr = q.err || 0;
|
||||
const qTot = qDl + qWrn + qErr;
|
||||
const qCol = qErr ? '#f44' : (qWrn ? '#ff9800' : '#bbb');
|
||||
body += `<hr class="vv-arr-sep">`;
|
||||
const qStr = qTot === 0
|
||||
? `<span style="color:#3a3a3a">idle</span>`
|
||||
: `<span style="color:${qCol}">${qDl}↓ ${qWrn}⚠ ${qErr}✗</span>`;
|
||||
body += _row('Queue', qStr);
|
||||
|
||||
// Health
|
||||
if (arr.health && arr.health.length) {
|
||||
const col = arr.health.some(h => h.type === 'error') ? '#f44' : '#ff9800';
|
||||
body += `<div class="vv-arr-sub warn" style="color:${col}">` +
|
||||
arr.health.slice(0,2).map(h => h.message || h.type).join('<br>') + '</div>';
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
const cl = arr.cleanup || {};
|
||||
if (cl.last_run) {
|
||||
body += `<hr class="vv-arr-sep">`;
|
||||
const clOk = cl.status === 'ok'
|
||||
? '<span style="color:#4caf50">✓</span>'
|
||||
: '<span style="color:#f44">✗</span>';
|
||||
body += _row('Cleanup', _relTime(cl.last_run) + ' ' + clOk);
|
||||
if (cl.tracked != null) {
|
||||
body += `<div class="vv-arr-sub">${_n(cl.tracked)} files · ${_n(cl.total)} items</div>`;
|
||||
const orph = cl.orphans || 0, junk = cl.junk || 0;
|
||||
if (orph || junk) {
|
||||
body += `<div class="vv-arr-sub warn">${orph} orphans · ${junk} junk</div>`;
|
||||
} else {
|
||||
body += `<div class="vv-arr-sub">0 orphans · 0 junk</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery
|
||||
const disc = arr.discovery || {};
|
||||
if (disc.last_run) {
|
||||
if (!cl.last_run) body += `<hr class="vv-arr-sep">`;
|
||||
const added = disc.added != null ? ` <span style="color:#6fcf97">+${disc.added}</span>` : '';
|
||||
body += _row('Discovery', _relTime(disc.last_run) + added);
|
||||
// ── Disk bar ──────────────────────────────────────────────────────────────
|
||||
let disk = '';
|
||||
if (arr.disk?.length) {
|
||||
const d = arr.disk.reduce((a,b) => (b.totalSpace||0) > (a.totalSpace||0) ? b : a);
|
||||
const free = d.freeSpace, tot = d.totalSpace;
|
||||
if (free && tot) {
|
||||
const usedPct = Math.round((1 - free/tot)*100);
|
||||
const col = usedPct > 90 ? '#ef5350' : usedPct > 75 ? '#ffb74d' : '#4caf50';
|
||||
disk = `<div class="vv-arr-disk">
|
||||
<div class="vv-arr-disk-lbl">
|
||||
<span style="font-size:10px;color:#555;">${_sz(free)} free</span>
|
||||
<span style="font-size:10px;color:#333;">${_sz(tot)} · ${usedPct}%</span>
|
||||
</div>
|
||||
<div class="vv-arr-disk-bar">
|
||||
<div class="vv-arr-disk-fill" style="width:${usedPct}%;background:${col};"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
body += `<div class="vv-arr-path">${arr.root || '—'}</div>`;
|
||||
// ── Queue ─────────────────────────────────────────────────────────────────
|
||||
const q = arr.queue || {};
|
||||
const qDl = q.dl || 0, qWrn = q.warn || 0, qErr = q.err || 0;
|
||||
const qTot = qDl + qWrn + qErr;
|
||||
let qHtml;
|
||||
if (qTot === 0) {
|
||||
qHtml = `<span class="vv-arr-qbadge idle">idle</span>`;
|
||||
} else {
|
||||
qHtml = '';
|
||||
if (qDl) qHtml += `<span class="vv-arr-qbadge dl">${qDl} downloading</span>`;
|
||||
if (qWrn) qHtml += `<span class="vv-arr-qbadge warn">${qWrn} warning</span>`;
|
||||
if (qErr) qHtml += `<span class="vv-arr-qbadge err">${qErr} error</span>`;
|
||||
}
|
||||
|
||||
// ── Health ────────────────────────────────────────────────────────────────
|
||||
let health = '';
|
||||
if (arr.health?.length) {
|
||||
const hasErr = arr.health.some(h => h.type === 'error');
|
||||
const cls = hasErr ? 'vv-arr-health' : 'vv-arr-health warn';
|
||||
const msgs = arr.health.slice(0,3).map(h => h.message || h.type).join('<br>');
|
||||
health = `<div class="${cls}">${msgs}</div>`;
|
||||
}
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
let cleanup = '';
|
||||
const cl = arr.cleanup || {};
|
||||
if (cl.last_run) {
|
||||
const st = cl.status === 'ok' ? '<span style="color:#4caf50">✓</span>' : '<span style="color:#ef5350">✗</span>';
|
||||
const orph = cl.orphans || 0, junk = cl.junk || 0;
|
||||
const orCol = (orph || junk) ? 'color:#8b4a2a' : 'color:#333';
|
||||
cleanup = `
|
||||
<div class="vv-arr-meta">
|
||||
<span class="vv-arr-meta-lbl">Cleanup</span>
|
||||
<span class="vv-arr-meta-val">${_rel(cl.last_run)} ${st}</span>
|
||||
</div>
|
||||
${cl.tracked != null ? `<div class="vv-arr-meta-sub" style="${orCol}">
|
||||
${_n(cl.tracked)} files · ${orph} orphans${junk ? ' · ' + junk + ' junk' : ''}
|
||||
</div>` : ''}`;
|
||||
}
|
||||
|
||||
// ── Discovery ─────────────────────────────────────────────────────────────
|
||||
let disc = '';
|
||||
const dsc = arr.discovery || {};
|
||||
if (dsc.last_run) {
|
||||
const added = dsc.added != null ? ` <span style="color:#6fcf97">+${dsc.added}</span>` : '';
|
||||
disc = `<div class="vv-arr-meta">
|
||||
<span class="vv-arr-meta-lbl">Discovery</span>
|
||||
<span class="vv-arr-meta-val">${_rel(dsc.last_run)}${added}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const hasMeta = cleanup || disc;
|
||||
|
||||
return `<div class="vv-arr-card">
|
||||
<div class="vv-arr-head">
|
||||
<div class="vv-arr-hdr">
|
||||
<span class="vv-arr-name">${name}</span>
|
||||
<span class="vv-arr-status">
|
||||
<span>
|
||||
<span class="vv-arr-dot" style="background:${dotCol}"></span>
|
||||
<span class="vv-arr-ver">${verStr}</span>
|
||||
<span class="vv-arr-ver">${arr.version || 'online'}</span>
|
||||
</span>
|
||||
</div>
|
||||
${body}
|
||||
<div class="vv-arr-stats">${stats}</div>
|
||||
${disk}
|
||||
<hr class="vv-arr-sep" style="margin:6px 0 8px;">
|
||||
<div class="vv-arr-q">${qHtml}</div>
|
||||
${health}
|
||||
${hasMeta ? `<hr class="vv-arr-sep" style="margin:8px 0 4px;">${cleanup}${disc}` : ''}
|
||||
<div class="vv-arr-root">${arr.root || ''}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Node section ──────────────────────────────────────────────────────────────
|
||||
function _nodeSection(node, ownerHost) {
|
||||
const isOwner = node.host === ownerHost;
|
||||
const badge = isOwner ? ' <span style="color:#3a4a3a;font-size:10px;">owner</span>' : '';
|
||||
const isLocal = node.local;
|
||||
const tag = isLocal ? '<span class="vv-arr-node-tag">local</span>' : '';
|
||||
const cards = node.arrs.map(_arrCard).join('');
|
||||
return `<div class="vv-card vv-arr-node">
|
||||
<div class="vv-arr-node-head">
|
||||
<span class="vv-arr-node-title">${node.host.toUpperCase()}</span>
|
||||
<span class="vv-arr-node-host">${node.name}${badge}</span>
|
||||
return `<div class="vv-arr-node">
|
||||
<div class="vv-arr-node-hdr">
|
||||
<span class="vv-arr-node-id">${node.host.toUpperCase()}</span>
|
||||
<span class="vv-arr-node-host">${node.name}</span>${tag}
|
||||
</div>
|
||||
<div class="vv-arr-cards">${cards}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _syncCard(sync) {
|
||||
let body = '';
|
||||
if (!sync.last_run) {
|
||||
body = '<div style="color:#3a3a3a;font-size:12px;">Never run</div>';
|
||||
} else {
|
||||
const ok = sync.status === 'ok'
|
||||
? '<span style="color:#4caf50">✓</span>'
|
||||
: '<span style="color:#f44">✗</span>';
|
||||
body += _row('Last run', _relTime(sync.last_run) + ' ' + ok);
|
||||
if (sync.added != null) body += _row('Added', `<span style="color:#6fcf97">+${_n(sync.added)}</span>`);
|
||||
if (sync.nodes != null) body += _row('Nodes', _n(sync.nodes));
|
||||
}
|
||||
if (sync.blocklist_count != null) {
|
||||
body += `<hr class="vv-arr-sep">`;
|
||||
body += _row('Blocklist', _n(sync.blocklist_count) + ' entries');
|
||||
}
|
||||
return `<div class="vv-card" style="grid-column:span 4;">
|
||||
<h3>Arrs Sync</h3>${body}</div>`;
|
||||
// ── Sync + Recovery row ───────────────────────────────────────────────────────
|
||||
function _syncSection(sync, recovery, settings) {
|
||||
const _card = (title, data, items, extra) => {
|
||||
const ok = data.last_run && data.status === 'ok';
|
||||
const bad = data.last_run && data.status !== 'ok';
|
||||
const pillCls = !data.last_run ? 'off' : ok ? 'ok' : 'err';
|
||||
const pillTxt = !data.last_run ? 'never run' : ok ? 'ok' : data.status || 'error';
|
||||
let body = '';
|
||||
if (data.last_run) {
|
||||
body += `<div style="font-size:11px;color:#555;margin-bottom:10px;">${_rel(data.last_run)}</div>`;
|
||||
}
|
||||
body += `<div class="vv-arr-sr-row">${items}</div>`;
|
||||
if (extra) body += extra;
|
||||
return `<div class="vv-arr-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<span style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.07em;">${title}</span>
|
||||
<span class="vv-arr-pill ${pillCls}">${pillTxt}</span>
|
||||
</div>
|
||||
${body}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const _item = (val, lbl) => `<div class="vv-arr-sr-item">
|
||||
<div class="vv-arr-sr-num">${val}</div>
|
||||
<div class="vv-arr-sr-lbl">${lbl}</div>
|
||||
</div>`;
|
||||
|
||||
const syncEnabled = settings?.arr_sync_enabled !== false;
|
||||
const syncExtra = !syncEnabled
|
||||
? `<div style="font-size:10px;color:#8b4a2a;margin-top:8px;">ARR_SYNC_ENABLED=false — sync disabled</div>` : '';
|
||||
|
||||
const syncItems = sync.last_run
|
||||
? _item(`<span style="color:#6fcf97">+${_n(sync.added)}</span>`, 'Added') +
|
||||
(sync.nodes != null ? _item(_n(sync.nodes), 'Nodes') : '') +
|
||||
(sync.blocklist_count != null ? _item(_n(sync.blocklist_count), 'Blocklist') : '')
|
||||
: `<span style="color:#333;font-size:11px;">No run history</span>`;
|
||||
|
||||
const recItems = recovery.last_run
|
||||
? _item(_n(recovery.fixed), 'Fixed') + _item(_n(recovery.searched), 'Re-searched')
|
||||
: `<span style="color:#333;font-size:11px;">No run history</span>`;
|
||||
|
||||
return `<div class="vv-arr-sr-grid">
|
||||
${_card('Arr Sync', sync, syncItems, syncExtra)}
|
||||
${_card('Failed / Stalled Recovery', recovery, recItems, '')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _recoveryCard(rec) {
|
||||
let body = '';
|
||||
if (!rec.last_run) {
|
||||
body = '<div style="color:#3a3a3a;font-size:12px;">Never run</div>';
|
||||
} else {
|
||||
const ok = rec.status === 'ok'
|
||||
? '<span style="color:#4caf50">✓</span>'
|
||||
: '<span style="color:#f44">✗</span>';
|
||||
body += _row('Last run', _relTime(rec.last_run) + ' ' + ok);
|
||||
body += _row('Fixed', _n(rec.fixed));
|
||||
body += _row('Re-searched',_n(rec.searched));
|
||||
}
|
||||
return `<div class="vv-card" style="grid-column:span 4;">
|
||||
<h3>Failed / Stalled Recovery</h3>${body}</div>`;
|
||||
// ── Settings card ─────────────────────────────────────────────────────────────
|
||||
function _settingsCard(s) {
|
||||
if (!s) return '';
|
||||
const myHost = s.my_host || 'host1';
|
||||
const myId = s.my_id || 'HOST1';
|
||||
|
||||
const _tog = (id, on, key, file, label) =>
|
||||
`<label class="vv-arr-tog-wrap">
|
||||
<div class="vv-arr-tog-track${on?' on':''}" id="vv-arr-tog-${id}"
|
||||
onclick="event.stopPropagation();vvArrToggle(this,'${key}','${file}')"></div>
|
||||
<span class="vv-arr-tog-lbl">${label}</span>
|
||||
</label>`;
|
||||
|
||||
const syncTog = _tog('sync', s.arr_sync_enabled,
|
||||
'ARR_SYNC_ENABLED', 'master.conf', 'Arr sync enabled');
|
||||
|
||||
const recTogs = [
|
||||
s.sonarr_recovery !== undefined
|
||||
? _tog('sonarr-rec', s.sonarr_recovery, `${myId}_SONARR_RECOVERY`, `${myHost}.conf`, 'Sonarr') : '',
|
||||
s.radarr_recovery !== undefined
|
||||
? _tog('radarr-rec', s.radarr_recovery, `${myId}_RADARR_RECOVERY`, `${myHost}.conf`, 'Radarr') : '',
|
||||
s.lidarr_recovery !== undefined
|
||||
? _tog('lidarr-rec', s.lidarr_recovery, `${myId}_LIDARR_RECOVERY`, `${myHost}.conf`, 'Lidarr') : '',
|
||||
].filter(Boolean).join('');
|
||||
|
||||
return `<div class="vv-arr-card vv-arr-set-card">
|
||||
<div style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;
|
||||
letter-spacing:.07em;margin-bottom:12px;">Settings</div>
|
||||
|
||||
<div style="display:flex;flex-wrap:wrap;gap:20px;margin-bottom:12px;">
|
||||
${syncTog}
|
||||
</div>
|
||||
|
||||
<hr class="vv-arr-sep">
|
||||
|
||||
<div style="font-size:10px;color:#3a3a3a;text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;">
|
||||
Recovery — ${myId}
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:16px;margin-bottom:10px;">${recTogs}</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span style="font-size:11px;color:#555;">Import recovery age</span>
|
||||
<input class="vv-arr-set-inp" id="vv-arr-rec-age" type="number" min="1" max="72"
|
||||
value="${s.recovery_age_hours ?? 6}">
|
||||
<span style="font-size:10px;color:#3a3a3a;">hours — skip items newer than this</span>
|
||||
<button onclick="vvArrSaveAge()" id="vv-arr-age-btn"
|
||||
style="background:#1a1a1a;border:1px solid #333;color:#666;font-size:10px;
|
||||
padding:3px 10px;border-radius:3px;cursor:pointer;margin-left:4px;">Save</button>
|
||||
<span id="vv-arr-age-fb" style="font-size:10px;"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Main render ───────────────────────────────────────────────────────────────
|
||||
function _render(data) {
|
||||
let html = '';
|
||||
for (const node of (data.nodes || [])) {
|
||||
html += _nodeSection(node, data.host);
|
||||
const nodes = data.nodes || [];
|
||||
if (!nodes.length && !(data.sync?.last_run) && !(data.recovery?.last_run)) {
|
||||
document.getElementById('vv-arrs-grid').innerHTML =
|
||||
`<div style="grid-column:1/-1;color:#444;font-size:12px;padding:24px 0;text-align:center;">
|
||||
No arrs configured — add Sonarr/Radarr/Lidarr URLs and API keys to ${data.settings?.my_host ?? 'host'}.conf
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
html += _syncCard(data.sync || {});
|
||||
html += _recoveryCard(data.recovery || {});
|
||||
|
||||
document.getElementById('vv-arrs-grid').innerHTML = html || '<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">No arrs configured.</div>';
|
||||
let html = '';
|
||||
for (const node of nodes) html += _nodeSection(node, data.host);
|
||||
html += _syncSection(data.sync || {}, data.recovery || {}, data.settings);
|
||||
html += _settingsCard(data.settings);
|
||||
|
||||
document.getElementById('vv-arrs-grid').innerHTML = html;
|
||||
|
||||
const ts = data.ts ? new Date(data.ts * 1000).toLocaleString([],
|
||||
{month:'numeric',day:'numeric',year:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
|
||||
@@ -219,13 +385,47 @@ function _render(data) {
|
||||
|
||||
function vvArrsLoad() {
|
||||
fetch('/plugins/varaverk/api/arrs.php')
|
||||
.then(r => r.json())
|
||||
.then(_render)
|
||||
.catch(() => {});
|
||||
.then(r => r.json()).then(_render).catch(() => {});
|
||||
}
|
||||
|
||||
vvArrsLoad();
|
||||
setInterval(vvArrsLoad, 60000);
|
||||
|
||||
})();
|
||||
|
||||
// ── Toggle handler ────────────────────────────────────────────────────────────
|
||||
function vvArrToggle(track, key, file) {
|
||||
const on = !track.classList.contains('on');
|
||||
track.classList.toggle('on', on);
|
||||
const fd = new FormData();
|
||||
fd.append('id', 'arrs');
|
||||
fd.append('changes', JSON.stringify([{ file, key, value: on ? 'true' : 'false', type: 'scalar' }]));
|
||||
fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(d => { if (!d.ok) track.classList.toggle('on', !on); })
|
||||
.catch(() => track.classList.toggle('on', !on));
|
||||
}
|
||||
|
||||
// ── Recovery age save ─────────────────────────────────────────────────────────
|
||||
function vvArrSaveAge() {
|
||||
const val = document.getElementById('vv-arr-rec-age')?.value;
|
||||
const btn = document.getElementById('vv-arr-age-btn');
|
||||
const fb = document.getElementById('vv-arr-age-fb');
|
||||
if (!val) return;
|
||||
btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = '';
|
||||
const fd = new FormData();
|
||||
fd.append('id', 'arrs');
|
||||
fd.append('changes', JSON.stringify([{
|
||||
file: 'master.conf', key: 'ARR_IMPORT_RECOVERY_AGE', value: val, type: 'scalar'
|
||||
}]));
|
||||
fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
btn.disabled = false; btn.textContent = 'Save';
|
||||
fb.style.color = d.ok ? '#4caf50' : '#ef5350';
|
||||
fb.textContent = d.ok ? 'Saved ✓' : (d.error || 'Failed');
|
||||
if (d.ok) setTimeout(() => { fb.textContent = ''; }, 3000);
|
||||
})
|
||||
.catch(() => { btn.disabled = false; btn.textContent = 'Save'; fb.style.color='#ef5350'; fb.textContent='Failed'; });
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user