Fallback coverage: colour the wait, group by tier, and lock the stacks the partner already runs
This commit is contained in:
@@ -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)];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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 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('');
|
||||
|
||||
// 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>';
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user