diff --git a/Plugin/unraid/api/fallback_coverage.php b/Plugin/unraid/api/fallback_coverage.php new file mode 100644 index 0000000..76de81d --- /dev/null +++ b/Plugin/unraid/api/fallback_coverage.php @@ -0,0 +1,166 @@ +_TIER1-4 in this host's own conf — which of THIS host's +// containers the partner starts when this host goes dark, and in which delay tier. +// +// OPERATIONAL MODEL +// GET this host's containers plus current tier membership. +// POST action=cover tiers= rewrites all four tier arrays. +// +// Originally the Coverage picker on the Partnership page (e8ee5b0), removed the same day in +// 1a836da — "they describe what the partner runs during an outage, which is the Fallback tab's +// subject" — and never rehomed, because that tab had nothing to host it. This is that card, +// rebuilt where it belongs, with the services half left behind: pushing XML templates to a +// mirror is an onboard concern, not a failover one. +// +// DESIGN PRINCIPLES +// Edit the array fallback.sh actually reads, not a parallel one. +// The coverage tiers already exist and already carry the timing. A second "what to fail +// over" list would be a second answer to the same question, and the two would drift. +// +// A host edits only its OWN tiers, and the page says so. +// FALLBACK__TIER* lives in that host's conf and describes what someone else runs for +// it. Sparse checkout means this host does not have the partner's host*.conf at all — only +// the read-only RAM cache conf_sync fills — so an editor for the partner's coverage would +// be writing to a cache that the next sync overwrites. Configure HOST2's coverage from +// HOST2. This is the same trap that left the Watchdog card reporting a partner's lists as +// empty when they were merely somewhere else. +// +// OPERATIONAL SAFEGUARDS +// POST only for writes, so Unraid's CSRF guard applies. +// +// Names are validated against containers this host runs, PLUS whatever the tiers already name. +// The conf legitimately holds entries for containers not present right now — removed, +// stopped, or renamed. Validating only against the running set would refuse to save a list +// the operator never touched. New names still have to be real; the guard is against +// inventing containers, not against keeping ones already recorded. +// +// A tier outside 1-4 is rejected, never clamped. Silently moving a container from tier 9 to +// tier 4 would give it a 24-hour delay nobody asked for. +// +// An absent array is refused, not appended. Writing a new block into an unknown position in a +// conf is how a setting ends up in the wrong section and stops being read. +// +// Writing an empty list is allowed — "cover nothing" is a legitimate choice and the only way +// to express it. +// +// REQUEST +// GET → current lists +// POST action=cover tiers={"Emby":1,...} → rewrite tiers 1-4 +// +// RESPONSE +// {"ok":true,...} read payload, or {"ok":true,"counts":{...}} after a write +// {"ok":false,"error":string} validation or write failure, stated +// +// DEPENDS ON +// include/confform.php vv_conf_edit(), vv_conf_last_error(), vv_parse_conf_list() +// include/common.php vv_docker_containers() +// include/config.php vv_detect_host(), vv_read_conf_raw(), vv_push_master_conf() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ +header('Content-Type: application/json'); +require_once dirname(__DIR__) . '/include/confform.php'; +require_once dirname(__DIR__) . '/include/common.php'; + +$hostId = vv_detect_host(); +$hostUp = strtoupper($hostId); +$myConf = $hostId . '.conf'; + +$TIERS = [1, 2, 3, 4]; +$tierVar = fn(int $t) => "FALLBACK_{$hostUp}_TIER{$t}"; + +// ── Read ───────────────────────────────────────────────────────────────────────────────────── +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $raw = vv_read_conf_raw($myConf); + $mRaw = vv_read_conf_raw('master.conf'); + + $cover = []; + foreach ($TIERS as $t) { + // host*.conf first, master.conf second — installs that kept the tiers there still read. + $vals = vv_parse_conf_list($raw, $tierVar($t)) ?: vv_parse_conf_list($mRaw, $tierVar($t)); + foreach ($vals as $c) { $c = trim($c); if ($c !== '') $cover[$c] = $t; } + } + + $containers = []; + foreach (vv_docker_containers() as $c) { + $n = is_array($c) ? ($c['name'] ?? '') : (string)$c; + if ($n !== '') $containers[] = $n; + } + sort($containers, SORT_NATURAL | SORT_FLAG_CASE); + + // Named in a tier but not installed here. Reported rather than filtered: a tier entry for a + // container that does not exist is a line fallback.sh fails on during an outage, which is + // the worst possible moment to find a typo. + $missing = []; + $have = array_map('strtolower', $containers); + foreach (array_keys($cover) as $n) if (!in_array(strtolower($n), $have, true)) $missing[] = $n; + + echo json_encode([ + 'ok' => true, + 'host' => $hostUp, + 'containers' => $containers, + 'cover' => (object)$cover, + 'missing' => $missing, + 'tier_vars' => array_map($tierVar, $TIERS), + ]); + exit; +} + +// ── Write ──────────────────────────────────────────────────────────────────────────────────── +if (($_POST['action'] ?? '') !== 'cover') { + echo json_encode(['ok' => false, 'error' => 'Unknown action']); + exit; +} + +$known = []; +foreach (vv_docker_containers() as $c) { + $n = is_array($c) ? ($c['name'] ?? '') : (string)$c; + if ($n !== '') $known[strtolower($n)] = $n; +} +$existingRaw = vv_read_conf_raw($myConf); +foreach ($TIERS as $t) { + foreach (vv_parse_conf_list($existingRaw, $tierVar($t)) as $n) { + $n = trim($n); + if ($n !== '' && !isset($known[strtolower($n)])) $known[strtolower($n)] = $n; + } +} + +$map = json_decode((string)($_POST['tiers'] ?? ''), true); +if (!is_array($map)) { echo json_encode(['ok' => false, 'error' => 'tiers must be an object']); exit; } + +$byTier = array_fill_keys($TIERS, []); +foreach ($map as $name => $tier) { + $t = (int)$tier; + if (!in_array($t, $TIERS, true)) { + echo json_encode(['ok' => false, 'error' => "Tier $tier is not 1-4 (for $name)"]); exit; + } + if (!isset($known[strtolower((string)$name)])) { + echo json_encode(['ok' => false, 'error' => "No container named $name on this host"]); exit; + } + $byTier[$t][] = $known[strtolower((string)$name)]; +} + +// Rewrites one `NAME=(` … `)` block in place, preserving the conf's leading indent. +$rewrite = function (string $cur, string $var, array $items): ?string { + $body = ''; + foreach ($items as $i) $body .= " \"" . $i . "\"\n"; + $pattern = '/^([ \t]*)' . preg_quote($var, '/') . '=\((?:[^)]*)\)/m'; + if (!preg_match($pattern, $cur)) return null; // absent: refuse rather than append blind + return preg_replace_callback($pattern, + fn($m) => $m[1] . $var . "=(\n" . $body . $m[1] . ")", $cur, 1); +}; + +$ok = vv_conf_edit($myConf, function (string $cur) use ($byTier, $TIERS, $tierVar, $rewrite): ?string { + foreach ($TIERS as $t) { + $next = $rewrite($cur, $tierVar($t), $byTier[$t]); + if ($next === null) return null; + $cur = $next; + } + return $cur; +}, [], array_map($tierVar, $TIERS)); + +if (!$ok) { echo json_encode(['ok' => false, 'error' => vv_conf_last_error() ?: 'Write failed']); exit; } + +// A partner holding the old list is a partner that will act on the old list. +vv_push_master_conf(); +echo json_encode(['ok' => true, 'counts' => array_map('count', $byTier)]); diff --git a/Plugin/unraid/pages/fallback.php b/Plugin/unraid/pages/fallback.php index 36e22f7..2a4afef 100644 --- a/Plugin/unraid/pages/fallback.php +++ b/Plugin/unraid/pages/fallback.php @@ -225,6 +225,33 @@ if (vv_ai_ui_on()) vv_ai_chat_assets(); + + +
+
+

Failover coverage

+ Loading… +
+
+ Which of this host's containers the partner starts when this host goes dark. + Tier 1 is immediate; later tiers wait out the delays set in this host's conf. + To change what the partner hands to us, open this page there. +
+
Loading…
+
+ + +
+
Quick settings
@@ -606,6 +633,10 @@ function _render(data) { _setToggles(data); _setInputs(data); _verdict(data); + // Names the partner in the coverage hint, so "open this page there" points somewhere. + const _pn = (data.nodes || []).find(n => !n.is_me); + const _pe = document.getElementById('vv-fb-cov-partner'); + if (_pe && _pn) _pe.textContent = _pn.id + ' (' + _pn.hostname + ')'; const grid = document.getElementById('vv-fb-grid'); @@ -740,8 +771,117 @@ window.vvFbSaveSettings = function() { }); }; + +// ── Failover coverage picker ────────────────────────────────────────────────── +// Holds the whole edit in memory and writes all four tiers in one POST. Per-row saves would +// leave the four arrays briefly disagreeing, and fallback.sh reads them as a set. +let _vvFbCov = null; // { containers:[], cover:{name:tier}, missing:[] } + +function vvFbCovLoad() { + fetch('/plugins/varaverk/api/fallback_coverage.php') + .then(r => r.json()) + .then(d => { + if (!d.ok) throw new Error(d.error || 'load failed'); + _vvFbCov = d; + _vvFbCovRender(); + }) + .catch(e => { + const b = document.getElementById('vv-fb-cov-body'); + if (b) b.innerHTML = `Could not load coverage — ${vvEscHtml(String(e))}`; + }); +} + +function _vvFbCovRender() { + const d = _vvFbCov; + const body = document.getElementById('vv-fb-cov-body'); + if (!d || !body) return; + + const sub = document.getElementById('vv-fb-cov-sub'); + const n = Object.keys(d.cover || {}).length; + if (sub) sub.textContent = `${d.host} · ${n} container${n !== 1 ? 's' : ''} covered`; + + // Names in a tier that are not installed here. Kept and shown rather than dropped: a tier + // entry for a container that does not exist is a line fallback.sh fails on mid-outage. + const miss = new Set((d.missing || []).map(s => s.toLowerCase())); + const all = [...new Set([...(d.containers || []), ...Object.keys(d.cover || {})])] + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + + const rows = all.map(name => { + const cur = d.cover?.[name] ?? 0; + const gone = miss.has(name.toLowerCase()); + const opts = [0, 1, 2, 3, 4].map(t => + ``).join(''); + return `
+ + ${vvEscHtml(name)} + ${gone ? 'not installed' : ''} +
`; + }).join(''); + + body.innerHTML = `
${rows}
`; +} + +// Tier 0 means "not covered" — the picker's way of removing something, since an array the +// operator emptied is a legitimate and otherwise inexpressible choice. +// Delegated off the card, and the name travels in a data attribute rather than inside an +// inline handler's quotes. vvEscAttr() escapes " but not ', and these names come from conf as +// well as from Docker — a hand-typed apostrophe would have broken out of the handler string. +document.getElementById('vv-fb-cov-card')?.addEventListener('change', ev => { + const sel = ev.target.closest('.vv-fb-cov-sel'); + if (sel) vvFbCovSet(sel.dataset.cov, sel.value); +}); + +function vvFbCovSet(name, val) { + if (!_vvFbCov) return; + const t = parseInt(val, 10); + if (t === 0) delete _vvFbCov.cover[name]; + else _vvFbCov.cover[name] = t; + const sub = document.getElementById('vv-fb-cov-sub'); + const n = Object.keys(_vvFbCov.cover).length; + if (sub) sub.textContent = `${_vvFbCov.host} · ${n} container${n !== 1 ? 's' : ''} covered · unsaved`; +} + +window.vvFbCovSave = async function () { + if (!_vvFbCov) return; + const fbEl = document.getElementById('vv-fb-cov-fb'); + const btn = document.getElementById('vv-fb-cov-save'); + const n = Object.keys(_vvFbCov.cover).length; + + // Emptying the list is allowed and is sometimes what is wanted, but it is also what a stray + // click looks like, and the consequence only appears during an outage. + if (n === 0 && !await vvConfirm('Save with NO containers covered?\n\nThe partner would start nothing for this host during an outage.')) return; + + btn.disabled = true; btn.textContent = 'Saving…'; fbEl.textContent = ''; + const fd = new URLSearchParams(); + fd.append('action', 'cover'); + fd.append('tiers', JSON.stringify(_vvFbCov.cover)); + + fetch('/plugins/varaverk/api/fallback_coverage.php', { method: 'POST', body: fd }) + .then(r => r.json()) + .then(d => { + btn.disabled = false; btn.textContent = 'Save coverage'; + fbEl.style.color = d.ok ? '#4caf50' : '#ef5350'; + if (d.ok) { + const c = d.counts || {}; + fbEl.textContent = `Saved ✓ T1 ${c[1]??0} · T2 ${c[2]??0} · T3 ${c[3]??0} · T4 ${c[4]??0}`; + setTimeout(() => { fbEl.textContent = ''; }, 5000); + // Re-read rather than trust the local copy, and refresh the node cards whose tier + // pills and coverage counts this just changed. + vvFbCovLoad(); + vvFbLoad(); + } else { + fbEl.textContent = d.error || 'Failed'; + } + }) + .catch(e => { + btn.disabled = false; btn.textContent = 'Save coverage'; + fbEl.style.color = '#ef5350'; fbEl.textContent = 'Request failed: ' + e; + }); +}; vvFbLoad(); setInterval(vvFbLoad, 30000); +vvFbCovLoad(); // once — this is an editor, not a monitor; polling would fight the operator })();