Rehome the failover coverage picker on the Fallback tab, five days after it was dropped from Partnership for belonging here

This commit is contained in:
Gmer4Lfe
2026-08-22 01:32:08 -04:00
parent 8ce8f0dfbe
commit ce613bcf9d
2 changed files with 306 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Failover coverage: FALLBACK_<me>_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=<json {container: tier}> 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_<host>_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)]);
+140
View File
@@ -225,6 +225,33 @@ if (vv_ai_ui_on()) vv_ai_chat_assets();
</div>
<?php endif; ?>
<!-- Failover coverage — this host's own tiers -->
<!--
Originally on the Partnership page (e8ee5b0), removed the same day in 1a836da because it
describes what runs during an outage, and never rehomed. This is that card, back where it
belongs.
Deliberately edits THIS host's tiers only. FALLBACK_<host>_TIER* lives in that host's conf and
says what someone ELSE starts for it, so this card configures what the partner does for us —
and the partner's own coverage is configured from the partner, because sparse checkout means
this host holds only a read-only RAM-cache copy of their conf.
-->
<div class="vv-card" id="vv-fb-cov-card" style="margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:4px;">
<h3 style="margin:0;">Failover coverage</h3>
<span style="font-size:10px;color:#444;" id="vv-fb-cov-sub">Loading…</span>
</div>
<div style="font-size:10px;color:#3f3f3f;margin-bottom:9px;line-height:1.45;">
Which of <b style="color:#666;">this host's</b> 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 <span id="vv-fb-cov-partner" style="color:#666;">the partner</span> hands to us, open this page there.
</div>
<div id="vv-fb-cov-body" style="color:#444;font-size:12px;">Loading…</div>
<div style="display:flex;justify-content:flex-end;align-items:center;gap:10px;margin-top:10px;padding-top:8px;border-top:1px solid #1e1e1e;">
<span id="vv-fb-cov-fb" style="font-size:11px;"></span>
<button class="vv-fb-save-btn" id="vv-fb-cov-save" onclick="vvFbCovSave()">Save coverage</button>
</div>
</div>
<!-- Quick settings -->
<div style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;padding:0 2px;">Quick settings</div>
@@ -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 = `<span style="color:#ef5350;font-size:11px;">Could not load coverage — ${vvEscHtml(String(e))}</span>`;
});
}
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 =>
`<option value="${t}"${t === cur ? ' selected' : ''}>${t === 0 ? '—' : 'T' + t}</option>`).join('');
return `<div style="display:flex;align-items:center;gap:8px;padding:3px 0;border-bottom:1px solid #171717;">
<select class="vv-fb-set-inp vv-fb-cov-sel" style="width:56px;flex-shrink:0;"
data-cov="${vvEscAttr(name)}">${opts}</select>
<span style="font-size:11px;color:${cur ? '#ccc' : '#555'};min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${vvEscHtml(name)}</span>
${gone ? '<span class="vv-fb-leg no" style="flex-shrink:0;">not installed</span>' : ''}
</div>`;
}).join('');
body.innerHTML = `<div style="max-height:320px;overflow:auto;">${rows}</div>`;
}
// 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
})();
</script>