Make fallback coverage something that can actually happen, and say so on the page

fallback.sh starts covered containers with docker start and never creates them, so a coverage list
the partner has never been sent is a promise nothing can keep — all twelve were missing. Adds the
push and remove paths, a readiness card that checks rather than infers, and the fallback state the
assistant needs to answer for it.
This commit is contained in:
Gmer4Lfe
2026-08-23 16:38:58 -04:00
parent d5cf3db2ec
commit 671e7ea5a4
7 changed files with 867 additions and 5 deletions
+218 -2
View File
@@ -64,6 +64,28 @@ if (vv_ai_ui_on()) vv_ai_chat_assets();
border-radius:4px;padding:6px 9px;margin-bottom:8px; }
.vv-fb-tierhdr-t { font-size:12px;font-weight:700;color:#ffb74d;letter-spacing:.01em; }
.vv-fb-tierhdr-s { font-size:10px;color:#7a6038; }
/* ── Failover readiness ──────────────────────────────────────────────────────────────────────
Every other card on this page shows what is CONFIGURED. This one shows what would actually
happen, which on 2026-08-23 turned out to be a different thing entirely — twelve containers
configured, none of them present on the partner, and no surface said so.
Verdict colour always ships beside a word, never alone. */
.vv-fb-rd { display:flex;flex-direction:column;gap:4px; }
.vv-fb-rdrow { display:flex;align-items:center;gap:9px;padding:5px 8px;border-radius:3px;
background:#0d0d0d;border:1px solid #161616;border-left:3px solid var(--rv,#333); }
.vv-fb-rdrow.ok { --rv:#4caf50; }
.vv-fb-rdrow.warn { --rv:#ffb74d; }
.vv-fb-rdrow.fail { --rv:#ef5350; }
.vv-fb-rdrow.unknown { --rv:#5a7a8a; }
.vv-fb-rdv { font-size:9px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;
min-width:56px;color:var(--rv,#666); }
.vv-fb-rdl { font-size:12px;color:#b8b8b8;min-width:190px; }
.vv-fb-rdd { font-size:11px;color:#5a5a5a;flex:1;min-width:0;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap; }
.vv-fb-rdwhy { font-size:10px;padding:2px 8px;border-radius:3px;cursor:pointer;
background:#0e1a2a;color:#7ab;border:1px solid #1e3a5a;white-space:nowrap; }
.vv-fb-rdwhy:hover { background:#12233a; }
.vv-fb-rdsum { font-size:11px;font-weight:600;margin-bottom:7px; }
/* ── 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",
@@ -267,6 +289,18 @@ 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 readiness — deterministic checks. The assistant EXPLAINS these rows and never
produces them: a model must not be the thing that says a failover will work. -->
<div class="vv-card" id="vv-fb-readiness" style="margin-bottom:12px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<h3 style="margin:0;">Failover readiness</h3>
<span style="font-size:10px;color:#444;">would a failover actually work right now</span>
<span style="flex:1;"></span>
<button class="vv-fb-save-btn" onclick="vvFbReadiness(true)" title="Re-run the checks">↻</button>
</div>
<div class="vv-fb-rdsum" id="vv-fb-rdsum">checking…</div>
<div class="vv-fb-rd" id="vv-fb-rdrows"></div>
</div>
<!-- Fallback coverage — this host's own tiers -->
<!--
Originally on the Partnership page (e8ee5b0), removed the same day in 1a836da because it
@@ -289,7 +323,23 @@ if (vv_ai_ui_on()) vv_ai_chat_assets();
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;">
<!-- Deploy row, deliberately separate from Save. Save writes the tier list; these two change
what the partner physically holds. fallback.sh starts a covered container with
`docker start`, which fails unless it was built there first — so a coverage list the
partner has never been sent is a promise nothing can keep, and this row is where that
is made visible and fixed. -->
<div id="vv-fb-cov-deploy" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px;padding-top:8px;border-top:1px solid #1e1e1e;">
<span style="font-size:10px;color:#5a5a5a;text-transform:uppercase;letter-spacing:.04em;">On partner</span>
<span id="vv-fb-cov-presence" style="font-size:11px;color:#444;">checking…</span>
<span style="flex:1;"></span>
<span id="vv-fb-cov-dfb" style="font-size:11px;"></span>
<button class="vv-fb-save-btn" id="vv-fb-cov-push" onclick="vvFbCovDeploy('push')"
title="Build every covered container on the partner, left stopped, so a failover can start them">Push to partner</button>
<button class="vv-fb-save-btn" id="vv-fb-cov-rm" onclick="vvFbCovDeploy('remove')"
style="background:#1a1208;color:#c88;border-color:#3a2a1a;"
title="Stop and remove these containers on the partner, and delete their appdata there. Not reversible.">Remove from partner</button>
</div>
<div style="display:flex;justify-content:flex-end;align-items:center;gap:10px;margin-top:8px;">
<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>
@@ -654,13 +704,14 @@ function _nodeCard(node, data) {
${node.is_me ? '<span class="vv-fb-usbadge">US</span>' : ''}
<span style="flex:1"></span>
${(node.is_me && node.proc && node.proc.running && node.proc.mode === 'live') ? _ptStatus(st) : ''}
${_stateBadge(shown)}${dryRun ? '<span class="vv-fb-leg" style="margin-left:4px;">preview</span>' : ''}
${_stateBadge(shown)}${dryRun ? '<span class="vv-fb-leg" style="margin-left:4px;">preview</span>' : ''}${(st.inferred && !dryRun) ? '<span class="vv-fb-leg" style="margin-left:4px;" title="Inferred from the running daemon rather than read from a state file — this node has never transitioned.">inferred</span>' : ''}
</div>
<div class="vv-fb-legs">
${_leg(reach.tailscale, 'tailscale')}
${_leg(node.is_me ? true : reach.ssh, 'ssh')}
${_leg(reach.state_file, 'state file')}
${st.inferred ? '<span class="vv-fb-leg" title="fallback.sh writes its state file only on a transition, so a node that has run cleanly since it was built has none. The verdict comes from the live daemon holding a valid lock.">· never transitioned</span>' : ''}
${node.ts_ip ? `<span class="vv-fb-leg">${vvEscHtml(node.ts_ip)}</span>` : ''}
</div>
@@ -1081,9 +1132,174 @@ window.vvFbCovSave = async function () {
fbEl.style.color = '#ef5350'; fbEl.textContent = 'Request failed: ' + e;
});
};
function _vvFbPartnerName() {
const el = document.getElementById('vv-fb-cov-partner');
const t = el ? el.textContent.trim() : '';
return (t && t !== 'the partner') ? t : 'the partner';
}
// ── Deploy: what the partner actually holds ───────────────────────────────────────────────────
// Presence is read from the partner, never inferred from the tier list. The whole point of this
// row is that the two disagree — coverage said 12 containers, the partner had none of them.
window.vvFbCovPresence = async function () {
const el = document.getElementById('vv-fb-cov-presence');
if (!el) return;
try {
const fd = new URLSearchParams({ action: 'deploy_status' });
const d = await (await fetch('/plugins/varaverk/api/fallback_coverage.php',
{ method: 'POST', body: fd })).json();
if (!d.ok || !d.checked) { el.style.color = '#a05a2c'; el.textContent = d.error || 'could not check'; return; }
const have = Object.keys(d.present || {}).length, miss = (d.missing || []).length;
if (miss === 0 && have === 0) { el.style.color = '#444'; el.textContent = 'nothing covered'; }
else if (miss === 0) { el.style.color = '#4caf50'; el.textContent = `all ${have} present`; }
else {
el.style.color = '#ef5350';
// Named, not just counted: "3 missing" is a number, the names are what you act on.
el.textContent = `${miss} missing — ${(d.missing||[]).slice(0,3).join(', ')}${miss>3?` +${miss-3}`:''}`;
}
} catch (e) { el.style.color = '#a05a2c'; el.textContent = 'check failed'; }
};
window.vvFbCovDeploy = async function (which) {
const push = which === 'push';
const fbEl = document.getElementById('vv-fb-cov-dfb');
const btn = document.getElementById(push ? 'vv-fb-cov-push' : 'vv-fb-cov-rm');
const n = _vvFbCov ? Object.keys(_vvFbCov.cover).length : 0;
const msg = push
? `Build ${n} container${n!==1?'s':''} on ${_vvFbPartnerName()}?\n\n`
+ 'Each is created and left STOPPED so a failover can start it. Nothing starts running now.\n\n'
+ 'Save first if you have unsaved changes — this pushes what is in the conf, not what is on screen.'
: `Stop and remove ${n} container${n!==1?'s':''} on ${_vvFbPartnerName()} AND DELETE THEIR APPDATA?\n\n`
+ 'Not reversible. Only paths under /mnt/*/appdata* are touched; a bind of the appdata root is refused.\n\n'
+ 'If the partner has ever covered for this host, what it holds may be the NEWER copy — the one a '
+ 'handback rsyncs home. Fallback state is NORMAL, so nothing is failing over right now, but a '
+ 'handback that partly failed would not show up here.\n\n'
+ 'Coverage stays as configured, so a later Push rebuilds the containers from scratch.';
if (!await vvConfirm(msg, { title: push ? 'Push to partner' : 'Remove from partner',
confirmText: push ? 'Push' : 'Remove' })) return;
btn.disabled = true; const label = btn.textContent; btn.textContent = push ? 'Pushing…' : 'Removing…';
fbEl.style.color = '#7ab'; fbEl.textContent = 'job started…';
try {
const fd = new URLSearchParams({ action: which });
const d = await (await fetch('/plugins/varaverk/api/fallback_coverage.php',
{ method: 'POST', body: fd })).json();
if (!d.ok) { fbEl.style.color = '#ef5350'; fbEl.textContent = d.error || 'Failed'; }
else {
// The job runs past this response. Re-checking presence is the only honest completion
// signal available here, so poll it rather than claiming success on dispatch.
fbEl.style.color = '#7ab'; fbEl.textContent = 'running — see the Fallback log';
let ticks = 0;
const t = setInterval(async () => {
await vvFbCovPresence();
// Presence just changed, so the readiness verdict that depends on it is stale.
vvFbReadiness(false);
if (++ticks >= 20) { clearInterval(t); fbEl.textContent = ''; }
}, 6000);
}
} catch (e) {
fbEl.style.color = '#ef5350'; fbEl.textContent = 'Request failed: ' + e;
}
btn.disabled = false; btn.textContent = label;
};
// ── Assistant ────────────────────────────────────────────────────────────────────────────────
// vv_ai_chat_markup() above emits the boxes and nothing else — no <script>, no init. Without this
// block the card renders looking complete and dies on the first click with VvAiChat undefined.
// Fallback was the only one of seven pages mounting a dock and never instantiating it, which is
// exactly the failure watchdog.php warns about in its own comment.
//
// troubleshoot, not varaverk: the placeholder invites "what would the partner start if this host
// went dark", and a docs-only profile cannot reach live state to answer it. On 2026-08-23 the
// documented answer would also have been wrong — coverage listed 12 containers and none of them
// existed on the partner.
let vvFbChat = null;
let vvFbScope = 'Fallback';
if (typeof VvAiChat === 'function' && document.getElementById('vv-fb-ai-chat')) {
vvFbChat = VvAiChat({
prefix: 'vv-fb-ai',
profile: 'troubleshoot',
scopeLabel: 'Fallback',
// Read at send time rather than captured — a Why? retargets the scope and sends from the
// same click.
scope: () => vvFbScope,
// Pinned for the same reason Monitor pins its own: without it the card resumes whatever
// thread was last touched anywhere, landing this tab mid-conversation under a profile it
// never offers.
resumeProfile: 'troubleshoot',
think: p => p === 'troubleshoot',
empty: 'Ask about fallback — why a tier has not fired, what the partner would actually '
+ 'start if this host went dark, whether a stale state file matters.',
});
}
// Retarget the assistant at one thing on the page, then ask about it — same shape as the Watchdog
// Why? buttons, and deliberately using the component's real API. There is no ask(): it is
// retarget() + set the input + send(), and calling a method that does not exist would fail
// silently on click, which is the bug this page already had once.
window.vvFbWhy = function (label, question, scope) {
if (!vvFbChat || vvFbChat.busy()) return;
// troubleshoot, never repair. This page arms and disarms failover; repair is the one profile
// that can write conf, and a chat box is the wrong place to do that from.
vvFbScope = scope || label;
vvFbChat.retarget('troubleshoot', label, 'now looking at ' + label);
const input = document.getElementById('vv-fb-ai-input');
if (input) input.value = question;
vvFbChat.send();
const card = document.getElementById('vv-fb-ai-card');
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
};
// ── Failover readiness ───────────────────────────────────────────────────────────────────────
// Rendered from the endpoint's verdicts verbatim. Nothing here decides anything — if a row says
// fail, it is because a check failed, not because the page inferred it.
window.vvFbReadiness = async function (force) {
const sum = document.getElementById('vv-fb-rdsum');
const rows = document.getElementById('vv-fb-rdrows');
if (!sum || !rows) return;
if (force) { sum.textContent = 'checking…'; sum.style.color = '#7ab'; }
try {
const d = await (await fetch('/plugins/varaverk/api/fallback_coverage.php',
{ method: 'POST', body: new URLSearchParams({ action: 'readiness' }) })).json();
if (!d.ok) throw new Error(d.error || 'no verdict');
const tone = { ok: '#4caf50', warn: '#ffb74d', fail: '#ef5350', unknown: '#5a7a8a' };
sum.style.color = tone[d.verdict] || '#888';
sum.textContent = d.summary;
rows.innerHTML = (d.rows || []).map(r =>
`<div class="vv-fb-rdrow ${vvEscAttr(r.verdict)}">`
+ `<span class="vv-fb-rdv">${vvEscHtml(r.verdict)}</span>`
+ `<span class="vv-fb-rdl">${vvEscHtml(r.label)}</span>`
+ `<span class="vv-fb-rdd" title="${vvEscAttr(r.detail)}">${vvEscHtml(r.detail)}</span>`
+ (r.ask ? `<span class="vv-fb-rdwhy" data-ask="${vvEscAttr(r.ask)}" `
+ `data-label="${vvEscAttr(r.label)}">Why?</span>` : '')
+ `</div>`).join('');
} catch (e) {
sum.style.color = '#ef5350';
sum.textContent = 'Could not run the checks — ' + e;
rows.innerHTML = '';
}
};
// Delegated: the rows are rebuilt on every refresh, so per-node handlers would leak. Neither the
// question nor the label is interpolated into an onclick — vvEscHtml does not escape quotes.
(function () {
const host = document.getElementById('vv-fb-rdrows');
if (host) host.addEventListener('click', ev => {
const b = ev.target.closest('.vv-fb-rdwhy');
if (b) vvFbWhy(b.dataset.label || 'Fallback readiness', b.dataset.ask || 'What does this mean?');
});
})();
vvFbLoad();
setInterval(vvFbLoad, 30000);
vvFbCovLoad(); // once — this is an editor, not a monitor; polling would fight the operator
vvFbCovPresence();
vvFbReadiness(false);
})();
</script>