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>
|
</div>
|
||||||
<?php endif; ?>
|
<?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 -->
|
<!-- 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>
|
<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);
|
_setToggles(data);
|
||||||
_setInputs(data);
|
_setInputs(data);
|
||||||
_verdict(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');
|
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();
|
vvFbLoad();
|
||||||
setInterval(vvFbLoad, 30000);
|
setInterval(vvFbLoad, 30000);
|
||||||
|
vvFbCovLoad(); // once — this is an editor, not a monitor; polling would fight the operator
|
||||||
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user