arr-summary/static/js/service.js
Barely Removable 2dedd4e915
Some checks failed
Deploy / deploy (push) Has been cancelled
Add a manual refresh button to the Array section on System
Unraid's stats were already fetched live on every page load (no
server-side cache), so this doesn't bypass anything — it just lets the
section be re-fetched without a full page reload, for confirming it's
current without waiting on a reload.

Array section moves into its own partial (_array_section.html) so a new
/api/array/refresh route can re-render just that fragment and swap it
into #array-section, rather than duplicating the table/bar/disk-row
markup in JS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPJxtE9tPURsaWDZ6rC4Dt
2026-09-05 10:01:51 -07:00

380 lines
14 KiB
JavaScript

/* ═══════════════════════════════════════════════════════
Page 100 — service behaviour.
Three things: the header clock, the page-number jump (the
signature interaction), and the fastext colour keys. Plus
guarded live polling for the two figures that must be
current-second accurate.
Every element lookup is guarded. Nine pages share this file
and none of them carry all of its targets.
═══════════════════════════════════════════════════════ */
(function () {
'use strict';
var body = document.body;
var BASE = body.dataset.urlBase || '';
var TZ = body.dataset.timezone || 'UTC';
/* ── Clock ────────────────────────────────────────────
Teletext's header carried the transmission clock to the
second. It is the one thing on the page that is always
moving, and it is how you know the page is live. */
var clockEl = document.getElementById('head-clock');
function tick() {
if (!clockEl) return;
var now = new Date();
var d = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ, weekday: 'short', day: '2-digit', month: 'short'
}).format(now);
var t = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ, hour: '2-digit', minute: '2-digit',
second: '2-digit', hour12: false
}).format(now);
clockEl.textContent = d.toUpperCase() + ' ' + t;
}
tick();
setInterval(tick, 1000);
/* ── Page-number jump ─────────────────────────────────
Type three digits anywhere and the service goes to that
page, exactly as a teletext remote did. The digits fill in
the header as you type them, so the interaction shows its
own state instead of happening invisibly. */
var pages = {};
try {
(JSON.parse(body.dataset.pages || '[]')).forEach(function (p) {
pages[p[1]] = p[3]; // "100" -> "/"
});
} catch (e) { /* no nav data: the jump simply does nothing */ }
var entryEl = document.getElementById('page-entry');
var buf = '';
var clearTimer = null;
function paintEntry() {
if (!entryEl) return;
if (!buf) { entryEl.hidden = true; entryEl.textContent = ''; return; }
entryEl.hidden = false;
entryEl.textContent = 'P' + buf + '_'.repeat(3 - buf.length);
}
function resetEntry() {
buf = '';
paintEntry();
}
function typing(e) {
var t = e.target;
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
t.tagName === 'SELECT' || t.isContentEditable);
}
document.addEventListener('keydown', function (e) {
if (typing(e) || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'Escape') { resetEntry(); return; }
if (e.key >= '0' && e.key <= '9') {
buf += e.key;
paintEntry();
clearTimeout(clearTimer);
if (buf.length === 3) {
var dest = pages[buf];
if (dest) { window.location.href = BASE + dest; }
else {
// No such page. Teletext just sat on the number and waited.
if (entryEl) entryEl.textContent = 'P' + buf + ' ?';
clearTimer = setTimeout(resetEntry, 1200);
}
buf = '';
return;
}
// A half-typed number times out rather than lingering.
clearTimer = setTimeout(resetEntry, 2000);
return;
}
/* Fastext keys: r / g / y / c, the coloured buttons. */
var k = e.key.toLowerCase();
if ('rgyc'.indexOf(k) !== -1) {
var link = document.querySelector('.fastext a[data-key="' + k + '"]');
if (link) { window.location.href = link.href; }
}
});
/* ── Two-tap add ──────────────────────────────────────
Adding to the library is destructive enough to want a
confirming second press. The button states its own stage
rather than opening a dialog. */
var armed = null;
function disarm() {
if (!armed) return;
armed.classList.remove('armed');
if (armed.dataset.label) armed.textContent = armed.dataset.label;
armed = null;
}
window.armAdd = function (btn) {
if (btn.disabled) return;
if (armed && armed !== btn) disarm();
if (armed === btn) {
var title = btn.dataset.title;
var target = btn.dataset.target;
btn.disabled = true;
btn.classList.remove('armed');
btn.textContent = 'SENDING';
fetch(BASE + '/add-to-' + target, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.success) {
btn.classList.add('done');
btn.textContent = 'ADDED';
} else {
btn.classList.add('armed');
btn.disabled = false;
btn.textContent = 'FAILED';
}
})
.catch(function () {
btn.disabled = false;
btn.classList.add('armed');
btn.textContent = 'FAILED';
});
armed = null;
return;
}
btn.dataset.label = btn.textContent.trim();
btn.classList.add('armed');
btn.textContent = 'CONFIRM?';
armed = btn;
};
/* ── Request verdicts (P104) ──────────────────────────
Approve and Deny share the two-tap arming above — and share its
`armed` slot, so arming one disarms the other rather than leaving
two live verdicts on the same row. An approval starts a download
and a denial is somebody's answer; neither wants a stray tap.
The page keeps its own count honest after a decision. Leaving the
Pending figure at its server-rendered value would have the page
contradicting a row the visitor just resolved. */
function settlePending() {
var fig = document.getElementById('req-pending');
var meta = document.getElementById('req-pending-meta');
var field = document.getElementById('req-field');
if (!fig) return;
var n = parseInt(fig.textContent, 10);
if (isNaN(n) || n <= 0) return;
n -= 1;
fig.textContent = n;
if (meta) meta.textContent = n;
if (n === 0) {
// Nothing waits on a human any more, so the page stops being heavy.
fig.classList.remove('pending');
fig.classList.add('ok');
if (field) field.classList.remove('warn');
}
}
window.armDecision = function (btn) {
if (btn.disabled) return;
if (armed && armed !== btn) disarm();
if (armed === btn) {
var acts = btn.parentNode;
var row = btn.closest('.tt-row');
var verdict = btn.dataset.verdict;
var sibs = acts.querySelectorAll('button');
var i;
for (i = 0; i < sibs.length; i++) { sibs[i].disabled = true; }
btn.classList.remove('armed');
btn.textContent = 'SENDING';
armed = null;
fetch(BASE + '/api/ombi/decide', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: btn.dataset.id,
type: btn.dataset.type,
verdict: verdict
})
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.success) {
var val = row && row.querySelector('.tt-row-val');
if (val) {
val.className = 'tt-row-val ' + (verdict === 'approve' ? 'ok' : 'alert');
val.textContent = (d.status || verdict).toUpperCase();
}
// The row is settled: the verdicts have nothing left to offer.
if (acts.parentNode) acts.parentNode.removeChild(acts);
settlePending();
} else {
for (var j = 0; j < sibs.length; j++) { sibs[j].disabled = false; }
btn.textContent = 'FAILED';
}
})
.catch(function () {
for (var k = 0; k < sibs.length; k++) { sibs[k].disabled = false; }
btn.textContent = 'FAILED';
});
return;
}
// Captured once, from the server-rendered text — a retry after a
// FAILED must still disarm back to "Approve", not to "FAILED".
if (!btn.dataset.label) btn.dataset.label = btn.textContent.trim();
btn.classList.add('armed');
btn.textContent = 'CONFIRM?';
armed = btn;
};
/* ── Admin: delete unwatched (P105) ───────────────────
Removing a series from Sonarr with its files is permanent,
so it shares the same two-tap arming as everything else here
rather than a native confirm(). Each row carries its own byte
count, so the reclaimable total in the field meta can be
recomputed client-side after a delete instead of going stale
until the next page load. */
function fmtBytes(b) {
if (b >= 1073741824) return (b / 1073741824).toFixed(1) + ' GB';
return (b / 1048576).toFixed(0) + ' MB';
}
function settleAdmin() {
var list = document.getElementById('admin-rows');
var meta = document.getElementById('admin-meta');
var rows = list ? list.querySelectorAll('.tt-row') : [];
var total = 0;
for (var i = 0; i < rows.length; i++) total += parseInt(rows[i].dataset.bytes, 10) || 0;
if (meta) meta.textContent = rows.length + ' unwatched · ' + fmtBytes(total) + ' reclaimable';
if (rows.length === 0 && list) {
var empty = document.createElement('p');
empty.className = 'tt-empty';
empty.id = 'admin-empty';
empty.textContent = 'Nothing left to reclaim.';
list.parentNode.replaceChild(empty, list);
}
}
window.armDelete = function (btn) {
if (btn.disabled) return;
if (armed && armed !== btn) disarm();
if (armed === btn) {
var row = btn.closest('.tt-row');
btn.disabled = true;
btn.classList.remove('armed');
btn.textContent = 'DELETING';
armed = null;
fetch(BASE + '/api/delete/show', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sonarr_id: btn.dataset.id })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.success) {
if (row && row.parentNode) row.parentNode.removeChild(row);
settleAdmin();
} else {
btn.disabled = false;
btn.classList.add('armed');
btn.textContent = 'FAILED';
}
})
.catch(function () {
btn.disabled = false;
btn.classList.add('armed');
btn.textContent = 'FAILED';
});
return;
}
btn.dataset.label = btn.textContent.trim();
btn.classList.add('armed');
btn.textContent = 'CONFIRM?';
armed = btn;
};
document.addEventListener('click', function (e) {
if (armed && !armed.contains(e.target)) disarm();
});
/* ── Array refresh (P105) ─────────────────────────────
Unraid's stats are fetched live already, never cached server-side
(see get_unraid_stats), so this button doesn't bypass a cache —
it exists so the section can be re-fetched without a full page
reload, for the visitor's own peace of mind that it's current. */
window.refreshArray = function (btn) {
if (btn.disabled) return;
btn.disabled = true;
btn.textContent = 'REFRESHING';
fetch(BASE + '/api/array/refresh')
.then(function (r) { return r.text(); })
.then(function (html) {
var section = document.getElementById('array-section');
if (section) section.innerHTML = html;
})
.catch(function () {
btn.disabled = false;
btn.textContent = 'FAILED';
});
};
/* ── Live figures ─────────────────────────────────────
The queue and the active streams are the only two numbers
that must be accurate to the second, so they are the only
two that poll. Everything else is cached server-side and
arrives with the page. The queue also drives the header's
download row, present on every page, so it polls
unconditionally rather than only where #sab-queue exists. */
function setText(id, value) {
var el = document.getElementById(id);
if (el) el.textContent = value;
}
function pollSab() {
fetch(BASE + '/api/sabnzbd')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || d.status !== 'ok') return;
setText('sab-queue', d.queue_count);
setText('sab-speed', d.speed || '0');
setText('sab-remaining', d.sizeleft || '—');
var dl = document.getElementById('head-dl');
if (dl) {
var active = d.queue_count > 0;
dl.textContent = active
? d.queue_count + ' IN QUEUE · ' + (d.speed || '0')
: '0 IN QUEUE';
dl.classList.toggle('live', active);
}
})
.catch(function () { /* offline is the dev baseline, not an error */ });
}
function pollTautulli() {
if (!document.getElementById('tt-streams')) return;
fetch(BASE + '/api/tautulli')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || d.status !== 'ok') return;
setText('tt-streams', d.active_streams);
})
.catch(function () { /* as above */ });
}
pollSab();
setInterval(pollSab, 5000);
if (document.getElementById('tt-streams')) {
pollTautulli();
setInterval(pollTautulli, 15000);
}
})();