Fallback coverage: colour the wait, group by tier, and lock the stacks the partner already runs

This commit is contained in:
Gmer4Lfe
2026-08-22 09:33:06 -04:00
parent 5d42a28d34
commit 1c38abe1d0
2 changed files with 172 additions and 29 deletions
+45
View File
@@ -88,6 +88,30 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
} }
sort($containers, SORT_NATURAL | SORT_FLAG_CASE); sort($containers, SORT_NATURAL | SORT_FLAG_CASE);
// Containers the partner already runs continuously, because onboard deployed them there.
// They cannot be failover material: there is nothing to start during an outage that is not
// already up, and a tier assignment for one would be a delay applied to a running container.
//
// The stacks store XML template filenames, and their case does not have to agree with the
// container's — this conf ships "my-prowlarr.xml" against a container named "Prowlarr" — so
// the mapping is done case-insensitively, once, here.
$stackOf = [];
$byLower = [];
foreach ($containers as $n) $byLower[strtolower($n)] = $n;
foreach ([
'auth' => "{$hostUp}_PARTNERSHIP_AUTH_STACK",
'arrs' => "{$hostUp}_PARTNERSHIP_ARR_STACK",
'services' => "{$hostUp}_PARTNERSHIP_SERVICES_STACK",
] as $label => $var) {
foreach (vv_parse_conf_list($raw, $var) as $xml) {
$n = preg_replace('/^my-|\.xml$/', '', trim($xml));
if ($n === '') continue;
// The container's real name where this host has one, the template's otherwise: a
// stack entry for something not installed here is still a stack entry.
$stackOf[$byLower[strtolower($n)] ?? $n] = $label;
}
}
// Named in a tier but not installed here. Reported rather than filtered: a tier entry for a // 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 // 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. // the worst possible moment to find a typo.
@@ -100,6 +124,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
'host' => $hostUp, 'host' => $hostUp,
'containers' => $containers, 'containers' => $containers,
'cover' => (object)$cover, 'cover' => (object)$cover,
'stacks' => (object)$stackOf,
'missing' => $missing, 'missing' => $missing,
'tier_vars' => array_map($tierVar, $TIERS), 'tier_vars' => array_map($tierVar, $TIERS),
]); ]);
@@ -125,6 +150,22 @@ foreach ($TIERS as $t) {
} }
} }
// Stack containers refused here, not only greyed out in the picker. The card disables their
// selects, but a disabled input is a courtesy to the operator, not a constraint on the endpoint —
// and a tier assignment for a container the partner already runs permanently is a delay attached
// to something that never stops.
$stackNames = [];
foreach ([
"{$hostUp}_PARTNERSHIP_AUTH_STACK",
"{$hostUp}_PARTNERSHIP_ARR_STACK",
"{$hostUp}_PARTNERSHIP_SERVICES_STACK",
] as $var) {
foreach (vv_parse_conf_list($existingRaw, $var) as $xml) {
$n = preg_replace('/^my-|\.xml$/', '', trim($xml));
if ($n !== '') $stackNames[strtolower($n)] = true;
}
}
$map = json_decode((string)($_POST['tiers'] ?? ''), true); $map = json_decode((string)($_POST['tiers'] ?? ''), true);
if (!is_array($map)) { echo json_encode(['ok' => false, 'error' => 'tiers must be an object']); exit; } if (!is_array($map)) { echo json_encode(['ok' => false, 'error' => 'tiers must be an object']); exit; }
@@ -137,6 +178,10 @@ foreach ($map as $name => $tier) {
if (!isset($known[strtolower((string)$name)])) { if (!isset($known[strtolower((string)$name)])) {
echo json_encode(['ok' => false, 'error' => "No container named $name on this host"]); exit; echo json_encode(['ok' => false, 'error' => "No container named $name on this host"]); exit;
} }
if (isset($stackNames[strtolower((string)$name)])) {
echo json_encode(['ok' => false,
'error' => "$name is deployed to the partner as part of a stack — it already runs there, so it cannot be given a fallback tier"]); exit;
}
$byTier[$t][] = $known[strtolower((string)$name)]; $byTier[$t][] = $known[strtolower((string)$name)];
} }
+127 -29
View File
@@ -55,6 +55,46 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
if (vv_ai_ui_on()) vv_ai_chat_assets(); if (vv_ai_ui_on()) vv_ai_chat_assets();
?> ?>
<style> <style>
/* ── Fallback coverage ── */
/* One continuum, worst outcome to best: never comes back → 24h → 12h → 4h → immediate → never
goes down at all. The colour answers "how long am I without this if the partner takes over",
which is the only question the card is asked. */
.vv-fb-cv-t0 { --cv:#3a3a3a; --cvbg:#141414; } /* not covered — lost for the outage */
.vv-fb-cv-t4 { --cv:#ef5350; --cvbg:#1c1010; } /* 24h */
.vv-fb-cv-t3 { --cv:#ff7043; --cvbg:#1c1410; } /* 12h */
.vv-fb-cv-t2 { --cv:#ffa726; --cvbg:#1c1710; } /* 4h */
.vv-fb-cv-t1 { --cv:#ffd54f; --cvbg:#1c1a10; } /* immediate */
.vv-fb-cv-lock { --cv:#4caf50; --cvbg:#101c12; } /* already running there — never down */
.vv-fb-cv-arrs { --cv:#4dd0e1; --cvbg:#101a1c; } /* arrs stack, told apart from auth */
.vv-fb-cv-row { display:flex;align-items:center;gap:7px;padding:3px 7px;border-radius:4px;
border:1px solid #1c1c1c;background:var(--cvbg);margin-bottom:3px;min-width:0; }
.vv-fb-cv-row.on{ border-color:#2e2e2e; border-color:color-mix(in srgb, var(--cv) 35%, #1c1c1c); }
.vv-fb-cv-dot { width:7px;height:7px;border-radius:50%;background:var(--cv);flex-shrink:0; }
.vv-fb-cv-nm { font-size:11px;color:#bbb;min-width:0;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;flex:1; }
.vv-fb-cv-row.t0 .vv-fb-cv-nm { color:#5a5a5a; }
.vv-fb-cv-tag { font-size:8px;letter-spacing:.05em;text-transform:uppercase;padding:1px 5px;
border-radius:3px;flex-shrink:0;color:var(--cv);
/* Solid fallbacks first: without color-mix() these resolve to transparent,
which is an invisible tag rather than a plainly-coloured one. */
background:#181818; border:1px solid #2e2e2e;
background:color-mix(in srgb, var(--cv) 14%, transparent);
border-color:color-mix(in srgb, var(--cv) 30%, transparent); }
.vv-fb-cv-sel { background:#111;border:1px solid #262626;color:#aaa;border-radius:3px;
font-size:10px;padding:2px 3px;width:52px;flex-shrink:0;cursor:pointer; }
.vv-fb-cv-sel:disabled { opacity:.35;cursor:not-allowed; }
/* Summary strip — the card's headline. Reads left to right as the recovery timeline. */
.vv-fb-cv-sum { display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px; }
.vv-fb-cv-chip { display:flex;align-items:center;gap:5px;font-size:10px;padding:3px 8px;
border-radius:4px;background:var(--cvbg);color:var(--cv);
border:1px solid #2e2e2e;
border-color:color-mix(in srgb, var(--cv) 28%, transparent); }
.vv-fb-cv-chip b{ font-size:13px;font-weight:700;line-height:1; }
.vv-fb-cv-grp { font-size:9px;color:#444;text-transform:uppercase;letter-spacing:.07em;
margin:8px 0 4px;display:flex;align-items:center;gap:6px; }
.vv-fb-cv-grp::after { content:'';flex:1;height:1px;background:#1a1a1a; }
/* ── Host card: daemon, freshness, reachability, controls ── */ /* ── Host card: daemon, freshness, reachability, controls ── */
/* Class names are all vv-fb-* prefixed. Unraid Connect injects a global Tailwind layer into /* Class names are all vv-fb-* prefixed. Unraid Connect injects a global Tailwind layer into
every page, so a bare utility-shaped name like `fixed` or `grid` would be captured by it. */ every page, so a bare utility-shaped name like `fixed` or `grid` would be captured by it. */
@@ -205,7 +245,7 @@ if (vv_ai_ui_on()) vv_ai_chat_assets();
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div> <div style="grid-column:1/-1;color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div> </div>
<!-- Failover coverage — this host's own tiers --> <!-- Fallback coverage — this host's own tiers -->
<!-- <!--
Originally on the Partnership page (e8ee5b0), removed the same day in 1a836da because it 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 describes what runs during an outage, and never rehomed. This is that card, back where it
@@ -218,11 +258,11 @@ if (vv_ai_ui_on()) vv_ai_chat_assets();
--> -->
<div class="vv-card" id="vv-fb-cov-card" style="margin-bottom:12px;"> <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;"> <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> <h3 style="margin:0;">Fallback coverage</h3>
<span style="font-size:10px;color:#444;" id="vv-fb-cov-sub">Loading…</span> <span style="font-size:10px;color:#444;" id="vv-fb-cov-sub">Loading…</span>
</div> </div>
<div style="font-size:10px;color:#3f3f3f;margin-bottom:9px;line-height:1.45;"> <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. Which of <b style="color:#666;">this host's</b> containers the partner starts when this host goes dark. Anything already deployed there — the auth and arr stacks — never goes down, so it takes no tier.
Tier 1 is immediate; later tiers wait out the delays set in this host's conf. 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. To change what <span id="vv-fb-cov-partner" style="color:#666;">the partner</span> hands to us, open this page there.
</div> </div>
@@ -857,39 +897,93 @@ function vvFbCovLoad() {
}); });
} }
// Delay label per tier, from the same conf the daemon reads, so the card cannot claim a timing
// the failover will not honour.
function _cvDelay(t, td) {
if (t === 1) return 'immediate';
const m = (td || {})['tier' + t];
if (m === null || m === undefined) return '';
return _fmtDelay(m);
}
function _vvFbCovRender() { function _vvFbCovRender() {
const d = _vvFbCov; const d = _vvFbCov;
const body = document.getElementById('vv-fb-cov-body'); const body = document.getElementById('vv-fb-cov-body');
if (!d || !body) return; if (!d || !body) return;
const stacks = d.stacks || {};
const miss = new Set((d.missing || []).map(s => s.toLowerCase()));
const td = (_vvFbLast && _vvFbLast.tier_delays) || {};
const all = [...new Set([...(d.containers || []), ...Object.keys(d.cover || {}), ...Object.keys(stacks)])]
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
// Bucket first, render second. Alphabetical across 49 rows answers "where is X"; grouped by
// tier answers "what happens when this host goes dark", which is what the card is for.
const buckets = { lock: [], 1: [], 2: [], 3: [], 4: [], 0: [] };
for (const name of all) {
const st = stacks[name];
if (st) { buckets.lock.push([name, st]); continue; }
buckets[d.cover?.[name] ?? 0].push([name, null]);
}
const row = (name, stack) => {
const tier = stack ? null : (d.cover?.[name] ?? 0);
const cls = stack ? (stack === 'arrs' ? 'vv-fb-cv-arrs' : 'vv-fb-cv-lock') : 'vv-fb-cv-t' + tier;
const gone = miss.has(name.toLowerCase());
// Locked, and the select says why rather than simply refusing to move: these already run on
// the partner, so a tier would be a delay applied to a container that never stops.
const opts = stack
? '<option>up</option>'
: [0, 1, 2, 3, 4].map(t =>
`<option value="${t}"${t === tier ? ' selected' : ''}>${t === 0 ? '—' : 'T' + t}</option>`).join('');
const tag = stack
? `<span class="vv-fb-cv-tag">${stack === 'services' ? 'svc' : vvEscHtml(stack)}</span>`
: (gone ? '<span class="vv-fb-cv-tag" style="--cv:#ef5350;">gone</span>' : '');
return `<div class="vv-fb-cv-row ${cls}${(stack || tier) ? ' on' : ' t0'}">
<span class="vv-fb-cv-dot"></span>
<span class="vv-fb-cv-nm" title="${vvEscAttr(name)}">${vvEscHtml(name)}</span>
${tag}
<select class="vv-fb-cv-sel vv-fb-cov-sel" data-cov="${vvEscAttr(name)}"
${stack ? 'disabled title="Deployed to the partner as part of the ' + vvEscAttr(stack) + ' stack — it is already running there"' : ''}>${opts}</select>
</div>`;
};
// Summary strip: the recovery timeline, worst first. Counts only, because the detail is
// directly below it and a chip that lists names stops being scannable at three containers.
const chip = (cls, n, label) => n
? `<span class="vv-fb-cv-chip ${cls}"><b>${n}</b> ${label}</span>` : '';
const sum = [
chip('vv-fb-cv-lock', buckets.lock.filter(x => x[1] !== 'arrs').length, 'auth · always up'),
chip('vv-fb-cv-arrs', buckets.lock.filter(x => x[1] === 'arrs').length, 'arrs · always up'),
chip('vv-fb-cv-t1', buckets[1].length, 'immediate'),
chip('vv-fb-cv-t2', buckets[2].length, _cvDelay(2, td)),
chip('vv-fb-cv-t3', buckets[3].length, _cvDelay(3, td)),
chip('vv-fb-cv-t4', buckets[4].length, _cvDelay(4, td)),
chip('vv-fb-cv-t0', buckets[0].length, 'not covered'),
].join('');
const group = (label, items) => items.length
? `<div class="vv-fb-cv-grp">${label}</div>` + items.map(([n, s]) => row(n, s)).join('')
: '';
const cols = `<div style="max-height:360px;overflow:auto;display:grid;`
+ `grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:0 14px;align-content:start;">`;
body.innerHTML =
`<div class="vv-fb-cv-sum">${sum}</div>`
+ cols
+ group('Always up · deployed to the partner', buckets.lock)
+ group('Tier 1 · immediate', buckets[1])
+ group('Tier 2 · ' + _cvDelay(2, td), buckets[2])
+ group('Tier 3 · ' + _cvDelay(3, td), buckets[3])
+ group('Tier 4 · ' + _cvDelay(4, td), buckets[4])
+ group('Not covered', buckets[0])
+ `</div>`;
const sub = document.getElementById('vv-fb-cov-sub'); const sub = document.getElementById('vv-fb-cov-sub');
const n = Object.keys(d.cover || {}).length; const n = Object.keys(d.cover || {}).length;
if (sub) sub.textContent = `${d.host} · ${n} container${n !== 1 ? 's' : ''} covered`; if (sub) sub.textContent = `${d.host} · ${n} in a tier · ${buckets.lock.length} always up`;
// 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('');
// Three columns. One column of forty-nine rows is a scroll, not a list — you cannot see what
// is covered without dragging, which is the one thing this card exists to show.
body.innerHTML = '<div style="max-height:340px;overflow:auto;display:grid;'
+ 'grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:0 14px;">'
+ rows + '</div>';
} }
// Tier 0 means "not covered" — the picker's way of removing something, since an array the // Tier 0 means "not covered" — the picker's way of removing something, since an array the
@@ -904,6 +998,10 @@ document.getElementById('vv-fb-cov-card')?.addEventListener('change', ev => {
function vvFbCovSet(name, val) { function vvFbCovSet(name, val) {
if (!_vvFbCov) return; if (!_vvFbCov) return;
// Locked rows carry a disabled select, so this cannot normally fire for one — but the guard is
// cheap and the endpoint refuses them anyway, and a state the UI can hold but not save is worse
// than one it refuses to hold.
if ((_vvFbCov.stacks || {})[name]) return;
const t = parseInt(val, 10); const t = parseInt(val, 10);
if (t === 0) delete _vvFbCov.cover[name]; if (t === 0) delete _vvFbCov.cover[name];
else _vvFbCov.cover[name] = t; else _vvFbCov.cover[name] = t;