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);
// 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
// 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.
@@ -100,6 +124,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
'host' => $hostUp,
'containers' => $containers,
'cover' => (object)$cover,
'stacks' => (object)$stackOf,
'missing' => $missing,
'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);
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)])) {
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)];
}
+125 -27
View File
@@ -55,6 +55,46 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
if (vv_ai_ui_on()) vv_ai_chat_assets();
?>
<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 ── */
/* 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. */
@@ -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>
<!-- 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
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 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>
</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.
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.
To change what <span id="vv-fb-cov-partner" style="color:#666;">the partner</span> hands to us, open this page there.
</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() {
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 stacks = d.stacks || {};
const miss = new Set((d.missing || []).map(s => s.toLowerCase()));
const all = [...new Set([...(d.containers || []), ...Object.keys(d.cover || {})])]
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' }));
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('');
// 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]);
}
// 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>';
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 n = Object.keys(d.cover || {}).length;
if (sub) sub.textContent = `${d.host} · ${n} in a tier · ${buckets.lock.length} always up`;
}
// 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) {
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);
if (t === 0) delete _vvFbCov.cover[name];
else _vvFbCov.cover[name] = t;