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:
@@ -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)]);
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user