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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user