Files
Gmer4Lfe 671e7ea5a4 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.
2026-08-23 16:38:58 -04:00

1306 lines
68 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Fallback tab. Current state of every node (NORMAL / FALLBACK / NO_INTERNET / DARK),
// which tiers have activated, handback strike progress, and which covered containers are
// actually running.
//
// DESIGN PRINCIPLES
// Pure view — markup plus a poll against api/fallback.php. State parsing lives in
// include/fallback.php.
//
// Reports what each node believes about itself. Remote state is read from the partner's own
// state file rather than inferred from what this host can reach.
//
// Polls faster than most tabs (30s) because this is the page someone opens during an
// outage, when stale numbers are actively misleading.
//
// OPERATIONAL SAFEGUARDS
// Cannot cause a failover. The page stops daemons, starts a PREVIEW, and edits conf — it has
// no control that enters FALLBACK or forces a handback. Fallback is driven by fallback.sh
// reacting to real reachability, and a manual override from a browser is exactly the wrong
// way to enter that state. The one start control is --dry-run, which changes nothing.
//
// A missing state file renders as unknown, never as NORMAL — claiming healthy for a
// fallback process that is not running would be the worst possible error on this page.
//
// Liveness is the PROCESS, never the state file's age. fallback.sh writes that file only on a
// transition — the steady NORMAL path writes nothing — so an untouched file means nothing has
// changed, which is the healthy case. An earlier version of this card read that age as
// staleness and coloured a perfectly good five-day-quiet host red. The row is "Last change"
// and carries no verdict; "is anything watching" is answered by _daemonRow reading /proc.
//
// A running preview's state is shown in place of the live value, marked "preview". A dry run
// redirects its writes to its own copy, so reading the live file while one runs reported a
// healthy preview as UNKNOWN on a host that has never run fallback for real.
//
// Stopping the test is a different button from stopping the daemon, deliberately. The test
// holds an iptables rule that only its own EXIT trap removes, so the two cannot share a
// control that might escalate to SIGKILL.
//
// RENDERS
// Per-node state and freshness, daemon liveness and mode, reachability legs, tier activation
// and delays, handback strikes, covered container status, quick settings, assistant
//
// DEPENDS ON
// api/fallback.php polled every 30s → include/fallback.php
// api/fallback_control.php start/stop actions → Fallback/fallback*.sh
// api/confform.php inline conf edits → include/confform.php
require_once dirname(__DIR__) . '/include/confui.php';
require_once dirname(__DIR__) . '/include/ai_chat.php';
// Same shared surface as the Partnership and Monitor tabs, gated the same way. Fallback
// questions — why a tier has not fired, what the mirror would actually start, whether a stale
// state file matters — are asked while looking at this page.
if (vv_ai_ui_on()) vv_ai_chat_assets();
?>
<style>
/* Tier banner — states whose containers these are, on a card headed by a different host.
The node card and the Fallback coverage card below it face OPPOSITE directions: this lists
what THIS node starts for the OTHER one, the coverage card edits what the other starts for us.
Both are correct and neither is guessable, so each says so in as many words. */
.vv-fb-tierhdr { display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;
background:#1a1200;border:1px solid #3a2800;border-left:3px solid #ffb74d;
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",
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. */
.vv-fb-hcard { background:#141414;border:1px solid #262626;border-radius:6px;padding:12px 13px;min-width:0; }
.vv-fb-hcard.me { border-color:#2a3a2a; }
.vv-fb-hcard.warn { border-color:#4a3800; }
.vv-fb-hrow { display:flex;align-items:center;gap:7px;margin-bottom:8px;flex-wrap:wrap; }
.vv-fb-hid { font-size:13px;font-weight:700;color:#ddd; }
.vv-fb-hnm { font-size:11px;color:#666;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0; }
.vv-fb-usbadge { background:#1a3a1a;color:#4caf50;font-size:8px;padding:1px 5px;border-radius:3px; }
.vv-fb-stats { display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;margin-bottom:9px; }
.vv-fb-stats b { color:#444;font-weight:400; }
.vv-fb-sv { color:#999;min-width:0; }
.vv-fb-sv.good { color:#4caf50; }
.vv-fb-sv.warn { color:#ffb74d; }
.vv-fb-sv.bad { color:#ef5350; }
.vv-fb-sv.dim { color:#3a3a3a; }
/* Reachability legs — separate facts, never rolled into one boolean. */
.vv-fb-legs { display:flex;gap:5px;flex-wrap:wrap;margin-bottom:9px; }
.vv-fb-leg { font-size:9px;padding:1px 6px;border-radius:3px;border:1px solid #222;background:#111;color:#444; }
.vv-fb-leg.ok { border-color:#243a24;background:#101a10;color:#4caf50; }
.vv-fb-leg.no { border-color:#3a1e1e;background:#1a1010;color:#ef5350; }
.vv-fb-acts { display:flex;gap:6px;flex-wrap:wrap;margin-top:9px;padding-top:8px;border-top:1px solid #1e1e1e; }
.vv-fb-btn { background:#111;border:1px solid #262626;color:#888;border-radius:3px;
padding:4px 9px;cursor:pointer;font-size:10px;white-space:nowrap; }
.vv-fb-btn:hover:not(:disabled) { border-color:#3a3a3a;color:#ccc; }
.vv-fb-btn:disabled { opacity:.4;cursor:default; }
.vv-fb-btn.stop { color:#ef9a9a; }
.vv-fb-btn.go { color:#4a9eff; }
.vv-fb-out { font-size:9px;color:#555;margin-top:6px;white-space:pre-wrap;word-break:break-word;
max-height:80px;overflow:auto;font-family:monospace; }
/* Quick settings — one card per setting rather than one card of rows, so each carries its own
why. The old single card listed three numbers with no indication what changing one costs. */
.vv-fb-qs { display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-bottom:12px; }
.vv-fb-qcard { background:#141414;border:1px solid #242424;border-radius:6px;padding:10px 11px; }
.vv-fb-qlbl { font-size:11px;color:#aaa;font-weight:600;margin-bottom:2px; }
.vv-fb-qsub { font-size:9px;color:#3f3f3f;line-height:1.35;margin-bottom:7px; }
.vv-fb-qin { display:flex;align-items:center;gap:7px; }
/* ── Existing status styles ── */
.vv-fb-active { background:#1a1200;border:1px solid #5a3800;border-radius:6px;padding:12px 14px; }
.vv-fb-active-h { display:flex;align-items:baseline;gap:10px;margin-bottom:8px; }
.vv-fb-badge { font-size:11px;font-weight:bold;letter-spacing:.06em;padding:2px 7px;border-radius:3px;flex-shrink:0; }
.vv-fb-badge.fb { background:#5a3800;color:#ffb74d; }
.vv-fb-badge.norm { background:#1a2a1a;color:#4caf50; }
.vv-fb-badge.dark { background:#2a1a2a;color:#9c27b0; }
.vv-fb-badge.nonet{ background:#1a1a2a;color:#5c7cfa; }
.vv-fb-badge.susp { background:#2a1a00;color:#888; }
.vv-fb-meta { display:flex;gap:18px;flex-wrap:wrap;margin-bottom:10px; }
.vv-fb-meta-item{ display:flex;flex-direction:column;gap:1px; }
.vv-fb-meta-val { font-size:17px;font-weight:bold;color:#ffb74d; }
.vv-fb-meta-lbl { font-size:10px;color:#5a4020; }
.vv-fb-ctrs { display:flex;gap:6px;flex-wrap:wrap;margin-top:6px; }
.vv-fb-ctr { font-size:11px;padding:2px 8px;border-radius:3px;background:#2a1e00;color:#ffb74d;border:1px solid #4a2e00; }
.vv-fb-ctr.running { background:#1a2a1a;color:#6fcf97;border-color:#2d4a2d; }
.vv-fb-ctr.stopped { background:#2a1a1a;color:#e57;border-color:#4a2020;opacity:.7; }
.vv-fb-node { grid-column:span 4; }
.vv-fb-node-h { display:flex;align-items:baseline;gap:8px;margin-bottom:10px; }
.vv-fb-node-id { font-size:12px;font-weight:bold;color:#666;letter-spacing:.06em;text-transform:uppercase; }
.vv-fb-node-nm { font-size:11px;color:#3a3a3a; }
.vv-fb-arrow { font-size:11px;color:#333; }
.vv-fb-covers { font-size:11px;color:#3a3a3a; }
.vv-fb-tier { margin-bottom:8px; }
.vv-fb-tier-h { display:flex;align-items:baseline;gap:6px;margin-bottom:4px; }
.vv-fb-tier-lbl { font-size:11px;font-weight:bold;color:#555;text-transform:uppercase;letter-spacing:.04em; }
.vv-fb-tier-delay { font-size:10px;color:#3a3a3a; }
.vv-fb-tier-pills { display:flex;gap:5px;flex-wrap:wrap; }
.vv-fb-pill { font-size:11px;padding:2px 7px;border-radius:3px;background:#1e1e1e;color:#777;border:1px solid #2a2a2a; }
.vv-fb-pill.active-t { background:#1a2010;color:#8bc34a;border-color:#2d3a1d; }
.vv-fb-pill.empty { color:#333;font-style:italic; }
.vv-fb-state-dot { width:6px;height:6px;border-radius:50%;flex-shrink:0;margin-top:3px; }
.vv-fb-sep { border:none;border-top:1px solid #222;margin:8px 0; }
.vv-fb-disabled { grid-column:1/-1;color:#3a3a3a;font-size:12px;padding:20px 0;text-align:center; }
/* ── Controls + settings card ── */
.vv-fb-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:14px 16px;margin-bottom:14px; }
.vv-fb-card-hdr { font-size:11px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:.07em;margin-bottom:12px; }
.vv-fb-ctrl-row { display:flex;justify-content:space-between;align-items:center;gap:12px;padding:5px 0; }
.vv-fb-ctrl-lbl { font-size:12px;color:#888; }
.vv-fb-ctrl-sub { font-size:10px;color:#3a3a3a;margin-top:2px; }
.vv-fb-tog { width:32px;height:18px;border-radius:9px;background:#222;border:1px solid #333;
position:relative;transition:background .15s,border-color .15s;flex-shrink:0;cursor:pointer; }
.vv-fb-tog.on { background:#1a3a1a;border-color:#2d5a2d; }
.vv-fb-tog::after { content:'';position:absolute;top:2px;left:2px;width:12px;height:12px;
border-radius:50%;background:#555;transition:left .15s,background .15s; }
.vv-fb-tog.on::after { left:16px;background:#4caf50; }
.vv-fb-set-row { display:flex;justify-content:space-between;align-items:center;padding:5px 0; }
.vv-fb-set-lbl { font-size:11px;color:#555; }
.vv-fb-set-inp { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;
font-size:12px;padding:4px 8px;outline:none;width:72px;font-family:monospace;
text-align:right;box-sizing:border-box; }
.vv-fb-set-inp:focus { border-color:#444; }
.vv-fb-set-unit { font-size:10px;color:#3a3a3a;min-width:56px; }
.vv-fb-save-btn { background:#1a1a1a;border:1px solid #333;color:#888;font-size:11px;
padding:5px 14px;border-radius:3px;cursor:pointer; }
.vv-fb-save-btn:hover { border-color:#555;color:#ccc; }
.vv-fb-save-btn:disabled { opacity:.4;cursor:default; }
</style>
<!-- Controls card -->
<div class="vv-fb-card">
<div class="vv-fb-card-hdr">Controls</div>
<div class="vv-fb-ctrl-row">
<div>
<div class="vv-fb-ctrl-lbl">Partnership</div>
<div class="vv-fb-ctrl-sub">Master gate — disabling stops all cross-server operations</div>
</div>
<div class="vv-fb-tog" id="vv-fb-pt-tog" onclick="vvFbToggle(this,'PARTNERSHIP_ENABLED')"></div>
</div>
<hr class="vv-fb-sep">
<div class="vv-fb-ctrl-row">
<div>
<div class="vv-fb-ctrl-lbl">Fallback</div>
<div class="vv-fb-ctrl-sub">Mutual container fallback between nodes</div>
</div>
<div class="vv-fb-tog" id="vv-fb-en-tog" onclick="vvFbToggle(this,'FALLBACK_ENABLED')"></div>
</div>
<hr class="vv-fb-sep">
<div class="vv-fb-ctrl-row">
<div>
<div class="vv-fb-ctrl-lbl">Rsync on handback</div>
<div class="vv-fb-ctrl-sub">Writeback rsync when the covered host recovers and containers return</div>
</div>
<div class="vv-fb-tog" id="vv-fb-rsync-tog" onclick="vvFbToggle(this,'FALLBACK_RSYNC_ENABLED')"></div>
</div>
<hr class="vv-fb-sep">
<!-- Persistent preview. Distinct from the per-card "Start dry run" button: that launches one
now, this decides what array_started.sh launches at every boot. Without it a preview lasts
exactly until the next array start and is then silently replaced by the live daemon. -->
<div class="vv-fb-ctrl-row">
<div>
<div class="vv-fb-ctrl-lbl">Preview mode <span style="font-size:9px;color:#4a9eff;border:1px solid #1e3a5a;background:#101a26;border-radius:3px;padding:1px 5px;margin-left:5px;">DRY RUN</span></div>
<div class="vv-fb-ctrl-sub">Watch and report, change nothing — no containers, no DDNS, no notifications. Survives reboots, so a long observation run stays a preview.</div>
</div>
<div class="vv-fb-tog" id="vv-fb-dry-tog" onclick="vvFbToggle(this,'FALLBACK_DRY_RUN')"></div>
</div>
</div>
<!-- Mesh verdict — one line answering "is this armed and is anything watching" before any detail -->
<div class="vv-fb-card" id="vv-fb-verdict-card" style="margin-bottom:12px;">
<div id="vv-fb-verdict" style="font-size:12px;color:#555;">Loading…</div>
</div>
<!-- Status section -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Nodes</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-fb-ts"></span>
</div>
<div id="vv-fb-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:12px;margin-bottom:14px;">
<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
describes what runs during an outage, and never rehomed. This is that card, back where it
belongs.
Deliberately edits THIS host's tiers only. FALLBACK_<host>_TIER* lives in that host's conf and
says what someone ELSE starts for it, so this card configures what the partner does for us —
and the partner's own coverage is configured from the partner, because sparse checkout means
this host holds only a read-only RAM-cache copy of their conf.
-->
<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;">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. 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>
<div id="vv-fb-cov-body" style="color:#444;font-size:12px;">Loading…</div>
<!-- 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>
</div>
<!-- Quick settings -->
<div style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;padding:0 2px;">Quick settings</div>
<div class="vv-fb-qs">
<div class="vv-fb-qcard">
<div class="vv-fb-qlbl">Check interval</div>
<div class="vv-fb-qsub">How often the daemon re-tests the partner. Also the age at which a state file is stale.</div>
<div class="vv-fb-qin">
<input class="vv-fb-set-inp" id="vv-fb-interval" type="number" min="5" max="300">
<span class="vv-fb-set-unit">seconds</span>
</div>
</div>
<div class="vv-fb-qcard">
<div class="vv-fb-qlbl">Handback strikes</div>
<div class="vv-fb-qsub">Consecutive healthy checks before handing services back. Higher rides out a flapping link.</div>
<div class="vv-fb-qin">
<input class="vv-fb-set-inp" id="vv-fb-strikes" type="number" min="1" max="20">
<span class="vv-fb-set-unit">consecutive</span>
</div>
</div>
<div class="vv-fb-qcard">
<div class="vv-fb-qlbl">Partnership suspend after</div>
<div class="vv-fb-qsub">Minutes without an active partnership before fallback suspends itself. 0 suspends immediately.</div>
<div class="vv-fb-qin">
<input class="vv-fb-set-inp" id="vv-fb-suspend" type="number" min="0" max="1440">
<span class="vv-fb-set-unit">minutes</span>
</div>
</div>
<!-- Tier delays live in THIS host's conf and pair with the coverage picker above: the picker
says which tier a container is in, these say how long that tier waits. Tier 1 has no delay
by definition — it is what goes over immediately — so there is nothing to show for it. -->
<div class="vv-fb-qcard">
<div class="vv-fb-qlbl">Tier 2 delay</div>
<div class="vv-fb-qsub">How long this host must be dark before the partner starts its Tier 2 containers.</div>
<div class="vv-fb-qin">
<input class="vv-fb-set-inp" id="vv-fb-t2" type="number" min="0" max="10080">
<span class="vv-fb-set-unit">minutes</span>
</div>
</div>
<div class="vv-fb-qcard">
<div class="vv-fb-qlbl">Tier 3 delay</div>
<div class="vv-fb-qsub">Same for Tier 3 — secondary services that can wait out a short outage.</div>
<div class="vv-fb-qin">
<input class="vv-fb-set-inp" id="vv-fb-t3" type="number" min="0" max="10080">
<span class="vv-fb-set-unit">minutes</span>
</div>
</div>
<div class="vv-fb-qcard">
<div class="vv-fb-qlbl">Tier 4 delay</div>
<div class="vv-fb-qsub">Same for Tier 4 — the last to move, usually arrs and downloaders.</div>
<div class="vv-fb-qin">
<input class="vv-fb-set-inp" id="vv-fb-t4" type="number" min="0" max="10080">
<span class="vv-fb-set-unit">minutes</span>
</div>
</div>
<!-- Not a timing knob but the most consequential value on this page: it decides whether an
unreachable partner means "they are down" (FALLBACK) or "we are" (NO_INTERNET), and those
two branches do opposite things to DDNS. CONTAINER_VERIFY_WAIT was the obvious fourth card
here and is deliberately absent — it is a script local, not a conf var, so a settings row
for it would write a key nothing reads. -->
<div class="vv-fb-qcard">
<div class="vv-fb-qlbl">Internet probe target</div>
<div class="vv-fb-qsub">Pinged to tell "the partner is down" apart from "our own line is down". Must be reachable from both hosts.</div>
<div class="vv-fb-qin">
<input class="vv-fb-set-inp" id="vv-fb-extip" type="text" style="width:120px;" placeholder="8.8.8.8">
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;align-items:center;gap:10px;margin-bottom:14px;">
<span id="vv-fb-set-fb" style="font-size:11px;"></span>
<button class="vv-fb-save-btn" id="vv-fb-save-btn" onclick="vvFbSaveSettings()">Save</button>
</div>
<!-- Assistant + mesh chat -->
<?php if (vv_ai_ui_on()): ?>
<div class="vv-card" id="vv-fb-ai-card" style="margin-bottom:12px;">
<?php vv_ai_chat_markup('vv-fb-ai', [
'profile' => 'varaverk',
'compact' => true,
// Fallback is the mesh's business by definition — a failover is two hosts agreeing about
// each other — so the mesh side leads here, as it does on Partnership.
'mesh' => true,
'meshDefault' => true,
'title' => 'Assistant',
'height' => '300px',
'tall' => '500px',
'tallLarge' => '750px',
'empty' => 'Ask about fallback — why a tier has not fired, what the partner would '
. 'start if this host went dark, whether a stale state file matters.',
]); ?>
</div>
<?php endif; ?>
<?php
// The conf sections this page is about, drawn by the shared renderer. They were reachable
// only from the Settings tab's catch-all, which is a long way to go for a setting named
// after the page you are already looking at.
vv_conf_ui_card('vv-cf-fallback', 'fallback|tier', 'Fallback settings');
?>
<script>
(function() {
function _dur(startTs) {
if (!startTs || startTs === 0) return '—';
const s = Math.floor(Date.now() / 1000) - startTs;
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
return m ? h + 'h ' + m + 'm' : h + 'h';
}
function _fmtDelay(min) {
if (!min) return 'immediate';
if (min < 60) return min + 'min';
const h = Math.floor(min / 60), m = min % 60;
return m ? h + 'h ' + m + 'm' : h + 'h';
}
function _activeTier(state) {
if (!state) return 0;
if (state.tier4_started) return 4;
if (state.tier3_started) return 3;
if (state.tier2_started) return 2;
return 1;
}
function _stateBadge(st) {
const map = {
NORMAL: ['norm', 'NORMAL'],
FALLBACK: ['fb', 'FALLBACK'],
NO_INTERNET: ['nonet', 'NO INTERNET'],
DARK: ['dark', 'DARK'],
OFFLINE: ['dark', 'OFFLINE'],
UNREACHABLE: ['dark', 'UNREACHABLE'],
UNKNOWN: ['dark', 'UNKNOWN'],
SUSPENDED: ['susp', 'SUSPENDED'],
};
const [cls, label] = map[st] || ['dark', st];
return `<span class="vv-fb-badge ${cls}">${label}</span>`;
}
function _stateDot(st) {
const col = {
NORMAL:'#4caf50', FALLBACK:'#ffb74d',
NO_INTERNET:'#5c7cfa', DARK:'#9c27b0',
OFFLINE:'#555', UNREACHABLE:'#555', UNKNOWN:'#333', SUSPENDED:'#444',
}[st] || '#333';
return `<span class="vv-fb-state-dot" style="background:${col}"></span>`;
}
function _activeCard(nodes, handbackReq) {
const active = nodes.filter(n => n.state && n.state.state === 'FALLBACK');
if (!active.length) return '';
return active.map(covering => {
const st = covering.state;
const tier = _activeTier(st);
const cov = covering.covers;
const covered = cov ? cov.hostname : '?';
let expected = [...(cov?.tier1 || [])];
if (tier >= 2) expected = expected.concat(cov?.tier2 || []);
if (tier >= 3) expected = expected.concat(cov?.tier3 || []);
if (tier >= 4) expected = expected.concat(cov?.tier4 || []);
const runningSet = new Set(covering.running || []);
const ctrPills = expected.length
? expected.map(c => {
const cls = runningSet.has(c) ? 'running' : 'stopped';
const sym = runningSet.has(c) ? '▲' : '▼';
return `<span class="vv-fb-ctr ${cls}">${sym} ${c}</span>`;
}).join('')
: '<span style="color:#5a4020;font-size:11px;">No containers configured for this tier</span>';
return `<div class="vv-card vv-fb-active" style="grid-column:1/-1;">
<div class="vv-fb-active-h">
${_stateBadge('FALLBACK')}
<span style="font-size:12px;color:#aa7020;">${covering.id} covering ${cov?.id || '?'} (${covered})</span>
</div>
<div class="vv-fb-meta">
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${_dur(st.fallback_start)}</span>
<span class="vv-fb-meta-lbl">DURATION</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">Tier ${tier}</span>
<span class="vv-fb-meta-lbl">ACTIVE TIER</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${st.handback_strikes} / ${handbackReq}</span>
<span class="vv-fb-meta-lbl">HANDBACK STRIKES</span>
</div>
<div class="vv-fb-meta-item">
<span class="vv-fb-meta-val">${expected.length}</span>
<span class="vv-fb-meta-lbl">CONTAINERS</span>
</div>
</div>
<div class="vv-fb-ctrs">${ctrPills}</div>
</div>`;
}).join('');
}
function _tierSection(tiers, activeTier, delays) {
const defs = [
{ n: 1, key: 'tier1', label: 'Tier 1', delay: 0 },
{ n: 2, key: 'tier2', label: 'Tier 2', delay: delays?.tier2 },
{ n: 3, key: 'tier3', label: 'Tier 3', delay: delays?.tier3 },
{ n: 4, key: 'tier4', label: 'Tier 4', delay: delays?.tier4 },
];
return defs.map(({ n, key, label, delay }) => {
const containers = tiers[key] || [];
const isActive = activeTier >= n;
const pills = containers.length
? containers.map(c => `<span class="vv-fb-pill${isActive ? ' active-t' : ''}">${c}</span>`).join('')
: `<span class="vv-fb-pill empty">none</span>`;
const delayStr = n === 1 ? 'immediate' : _fmtDelay(delay);
return `<div class="vv-fb-tier">
<div class="vv-fb-tier-h">
<span class="vv-fb-tier-lbl">${label}</span>
<span class="vv-fb-tier-delay">${delayStr}</span>
</div>
<div class="vv-fb-tier-pills">${pills}</div>
</div>`;
}).join('');
}
// Only rendered against FRESH state — see _nodeCard. A grace timer counted off a state file
// nobody has written for five days reads as an active grace window, which is precisely the kind
// of stale-as-current claim this page must not make.
function _ptStatus(st) {
if (!st) return '';
if (st.partnership_suspended) return _stateBadge('SUSPENDED');
if (st.partner_lost_at && st.partner_lost_at > 0) {
const minGone = Math.floor((Date.now() / 1000 - st.partner_lost_at) / 60);
return `<span class="vv-fb-badge susp">GRACE ${minGone}m</span>`;
}
return '';
}
function _age(sec) {
if (sec === null || sec === undefined) return '—';
if (sec < 60) return sec + 's ago';
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
return Math.floor(sec / 86400) + 'd ago';
}
// Daemon liveness, stated as one of four distinct things rather than a boolean. "Not running"
// and "running a preview" are opposite operational facts and the old page showed neither.
function _daemonRow(p, checkInterval) {
if (!p) return ['dim', 'unknown', ''];
if (p.stale_lock) return ['bad', 'not running · stale lock' + (p.pid ? ' (PID ' + p.pid + ')' : ''), 'stale'];
if (p.running === null) return ['dim', 'unknown — partner not reachable', ''];
if (!p.running) return ['bad', 'not running', ''];
const mode = p.mode === 'dry-run'
? '<span style="color:#4a9eff;">dry run</span>'
: '<span style="color:#4caf50;">live</span>';
return ['good', mode + ' · PID ' + p.pid, 'up'];
}
// Time since the last STATE CHANGE — not staleness, and not a heartbeat.
//
// fallback.sh writes this file only on a transition: the steady NORMAL path logs and writes
// nothing. So a file untouched for five days means "nothing has changed in five days", which on
// this page is the healthy case, and an earlier version of this card labelled exactly that as
// "stale" and coloured it red. Liveness is the PROCESS, which _daemonRow reads directly and is
// the only thing that can answer "is anything watching right now".
function _lastChange(node) {
const dry = node.proc && node.proc.running && node.proc.mode === 'dry-run';
const a = dry ? (node.preview?.age ?? null) : node.state_age;
if (a === null || a === undefined) {
if (dry) return ['dim', '—'];
return node.is_me
? ['dim', 'never — no state file yet']
: ['dim', 'not readable from here'];
}
return ['dim', _age(a)];
}
function _leg(ok, label) {
const cls = ok === true ? 'ok' : ok === false ? 'no' : '';
const sym = ok === true ? '✓' : ok === false ? '✕' : '·';
return `<span class="vv-fb-leg ${cls}">${sym} ${label}</span>`;
}
function _nodeCard(node, data) {
const st = node.state || {};
const state = st.partnership_suspended ? 'SUSPENDED' : (st.state || 'UNKNOWN');
const cov = node.covers;
const active = _activeTier(state === 'FALLBACK' ? st : null);
const slot = node.slot;
const [dCls, dTxt, dKind] = _daemonRow(node.proc, data.check_interval);
const [fCls, fTxt] = _lastChange(node);
// A preview running beside an absent or unchanging live state is what is actually being
// decided right now, so it is what the badge shows — labelled, never merged into live state.
const dryRun = node.proc && node.proc.running && node.proc.mode === 'dry-run';
const shown = (dryRun && node.preview && node.preview.state) ? node.preview.state : state;
const test = node.proc_test || {};
const reach = node.reach || {};
const covTarget = cov
? `<span class="vv-fb-sv">${cov.id} <span style="color:#3a3a3a;">(${vvEscHtml(cov.hostname)})</span></span>`
: '<span class="vv-fb-sv dim">nothing configured</span>';
// Whose containers these are. The list is read from the COVERED host's conf and describes
// that host's containers — so a card headed HOST2 is listing HOST1's services. Without saying
// so, the obvious reading is the wrong one.
const tierHdr = cov
? `<div class="vv-fb-tierhdr">
<span class="vv-fb-tierhdr-t">Fallback containers for ${vvEscHtml(cov.id)}</span>
<span class="vv-fb-tierhdr-s">${node.is_me ? 'this host' : vvEscHtml(node.id)} starts these when ${vvEscHtml(cov.id)} goes dark</span>
</div>`
: '';
const tierSection = cov
? _tierSection(cov, active, cov.delays)
: '<div style="color:#3a3a3a;font-size:11px;">No coverage configured</div>';
// Tier lists live in the COVERED host's conf, so an empty set here is a config gap on the
// other side, not on this one. Saying which conf to edit saves the hunt.
const tierTotal = cov ? ['tier1','tier2','tier3','tier4'].reduce((n,k)=>n+((cov[k]||[]).length),0) : 0;
const tierWarn = (cov && tierTotal === 0)
? `<div style="font-size:9px;color:#5a4020;margin-top:5px;">No containers in any tier — set FALLBACK_${cov.id}_TIER1 in ${cov.slot}.conf</div>`
: '';
const busy = _vvFbBusy[slot] ? 'disabled' : '';
const acts = `
<button class="vv-fb-btn stop" ${busy} onclick="vvFbAct('${slot}','stop',this)"
title="fallback.sh --stop — SIGTERM, then SIGKILL after 10s">Stop fallback</button>
<button class="vv-fb-btn go" ${busy} onclick="vvFbAct('${slot}','start_dry',this)"
title="fallback.sh --dry-run — previews decisions, changes nothing">Start dry run</button>
<button class="vv-fb-btn stop" ${busy} onclick="vvFbAct('${slot}','stop_test',this)"
title="fallback_test.sh --stop — SIGTERM only, never SIGKILL: only its trap removes the iptables rule">Stop test</button>
${(node.proc?.stale_lock || test.stale_lock)
? `<button class="vv-fb-btn" ${busy} onclick="vvFbAct('${slot}','clear_lock',this)"
title="Remove lock files left by a killed run">Clear lock</button>` : ''}`;
const testRow = test.running
? `<b>Test</b><span class="vv-fb-sv warn">running · PID ${test.pid} — holds an iptables rule</span>`
: test.stale_lock
? `<b>Test</b><span class="vv-fb-sv bad">stale lock — an iptables rule may be stranded</span>`
: '';
return `<div class="vv-fb-hcard${node.is_me ? ' me' : ''}${(dCls === 'bad' || fCls === 'bad') ? ' warn' : ''}">
<div class="vv-fb-hrow">
${_stateDot(state)}
<span class="vv-fb-hid">${node.id}</span>
<span class="vv-fb-hnm">${vvEscHtml(node.hostname)}</span>
${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>' : ''}${(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>
<div class="vv-fb-stats">
<b>Daemon</b> <span class="vv-fb-sv ${dCls}">${dTxt}</span>
<b>Last change</b><span class="vv-fb-sv ${fCls}">${fTxt}</span>
<b>Covers</b> ${covTarget}
<b>Running</b> <span class="vv-fb-sv">${node.running_count ?? 0} containers</span>
${state === 'FALLBACK'
? `<b>Strikes</b><span class="vv-fb-sv warn">${st.handback_strikes} / ${data.handback_req || 3}</span>` : ''}
${testRow}
</div>
<hr class="vv-fb-sep">
${tierHdr}
${tierSection}
${tierWarn}
<div class="vv-fb-acts">${acts}</div>
<div class="vv-fb-out" id="vv-fb-out-${slot}"></div>
</div>`;
}
// One mesh-level sentence, above the detail. Answers the two questions the page exists for
// before anyone has to read a card: is fallback armed, and is anything actually watching.
function _verdict(data) {
const el = document.getElementById('vv-fb-verdict');
if (!el) return;
const nodes = data.nodes || [];
if (!data.partnership_enabled)
return void (el.innerHTML = '<span style="color:#ef5350;">PARTNERSHIP_ENABLED=false</span> — all cross-server operations are disabled.');
if (!data.fb_enabled)
return void (el.innerHTML = '<span style="color:#ffb74d;">FALLBACK_ENABLED=false</span> — nothing will fall back. Arming it takes effect at the next array start.');
const inFb = nodes.filter(n => (n.state?.state) === 'FALLBACK');
const live = nodes.filter(n => n.proc?.running && n.proc.mode === 'live');
const dry = nodes.filter(n => n.proc?.running && n.proc.mode === 'dry-run');
const dead = nodes.filter(n => n.proc && n.proc.running === false);
if (inFb.length)
return void (el.innerHTML = `<span style="color:#ffb74d;font-weight:600;">${inFb.map(n=>n.id).join(', ')} in FALLBACK</span> — covering for a partner right now.`);
// Count every running daemon, then say what KIND. Splitting live from dry-run and reporting
// only the live count read as "0/2 nodes running the daemon · 2 in dry run" — two clauses
// contradicting each other about the same two processes.
const up = live.length + dry.length;
let s = up === nodes.length
? `<span style="color:#4caf50;">Armed</span> · all ${nodes.length} node${nodes.length!==1?'s':''} running`
: `<span style="color:#4caf50;">Armed</span> · ${up}/${nodes.length} node${nodes.length!==1?'s':''} running`;
if (dry.length && !live.length)
s += ` — <span style="color:#4a9eff;">all previewing (dry run), nothing would actually fall back</span>`;
else if (dry.length)
s += ` · <span style="color:#4a9eff;">${dry.map(n=>n.id).join(', ')} previewing (dry run)</span>`;
if (dead.length)
s += ` · <span style="color:#ef5350;">${dead.map(n=>n.id).join(', ')} not running — nothing would detect an outage there</span>`;
el.innerHTML = s;
}
function _setToggles(data) {
const pairs = [
['vv-fb-pt-tog', !!data.partnership_enabled],
['vv-fb-en-tog', !!data.fb_enabled],
['vv-fb-rsync-tog', !!data.fb_rsync_enabled],
['vv-fb-dry-tog', !!data.dry_run],
];
pairs.forEach(([id, on]) => {
const el = document.getElementById(id);
if (el) el.classList.toggle('on', on);
});
}
function _setInputs(data) {
const td = data.tier_delays ?? {};
const fields = [
['vv-fb-interval', data.check_interval ?? 30],
['vv-fb-strikes', data.handback_req ?? 3],
['vv-fb-suspend', data.suspend_after ?? 120],
['vv-fb-t2', td.tier2 ?? 240],
['vv-fb-t3', td.tier3 ?? 720],
['vv-fb-t4', td.tier4 ?? 1440],
['vv-fb-extip', data.external_ip ?? ''],
];
_vvFbMyConf = data.my_conf || _vvFbMyConf;
fields.forEach(([id, val]) => {
const el = document.getElementById(id);
if (el && el !== document.activeElement) el.value = val;
});
}
function _render(data) {
_vvFbLast = data;
_setToggles(data);
_setInputs(data);
_verdict(data);
// Names the partner in the coverage hint, so "open this page there" points somewhere.
const _pn = (data.nodes || []).find(n => !n.is_me);
const _pe = document.getElementById('vv-fb-cov-partner');
if (_pe && _pn) _pe.textContent = _pn.id + ' (' + _pn.hostname + ')';
const grid = document.getElementById('vv-fb-grid');
if (!data.partnership_enabled) {
grid.innerHTML = '<div class="vv-fb-disabled">PARTNERSHIP_ENABLED=false — all cross-server operations disabled</div>';
} else if (!data.fb_enabled) {
grid.innerHTML = '<div class="vv-fb-disabled">FALLBACK_ENABLED=false — fallback monitoring is disabled</div>';
} else {
const nodes = data.nodes || [];
let html = _activeCard(nodes, data.handback_req || 3);
for (const node of nodes) html += _nodeCard(node, data);
grid.innerHTML = html || '<div class="vv-fb-disabled">No nodes configured.</div>';
}
const ts = data.ts
? new Date(data.ts * 1000).toLocaleString([], {
month:'numeric', day:'numeric', year:'numeric',
hour:'2-digit', minute:'2-digit', second:'2-digit'})
: '';
document.getElementById('vv-fb-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvFbLoad() {
fetch('/plugins/varaverk/api/fallback.php')
.then(r => r.json())
.then(_render)
.catch(() => {
document.getElementById('vv-fb-grid').innerHTML =
'<div style="grid-column:1/-1;color:#555;font-size:12px;padding:24px 0;text-align:center;">Error loading fallback data — check API</div>';
});
}
// Per-slot in-flight guard, so a second click cannot fire while a stop is still escalating.
// Keyed by slot rather than one page-wide flag: acting on one host must not freeze the other's
// controls, and on this page the two hosts are routinely in different states.
const _vvFbBusy = {};
let _vvFbLast = null;
// URLSearchParams, not FormData. Unraid's CSRF token injector is jQuery-only, and a multipart
// body from native fetch() hangs here with no status and no server-side trace.
window.vvFbAct = async function (slot, action, btn) {
if (_vvFbBusy[slot]) return;
const labels = {
stop: 'Stop the fallback daemon on ' + slot.toUpperCase() + '?',
stop_test: 'Stop the fallback TEST on ' + slot.toUpperCase() + '?\n\nSIGTERM only — the test needs its own trap to remove the iptables rule it installed.',
start_dry: 'Start a fallback DRY RUN on ' + slot.toUpperCase() + '?\n\nIt previews decisions and changes nothing.',
clear_lock: 'Clear fallback lock files on ' + slot.toUpperCase() + '?\n\nOnly do this when no fallback process is running.',
};
// vvConfirm, never native confirm() — a native dialog offers "prevent additional dialogs",
// which kills every later dialog on the page document-wide. It returns a PROMISE, not a
// callback: passing a function would land in its opts argument and the body would never run,
// leaving a button that silently does nothing.
if (!await vvConfirm(labels[action] || ('Run ' + action + '?'))) return;
_vvFbBusy[slot] = true;
const out = document.getElementById('vv-fb-out-' + slot);
if (out) { out.style.color = '#555'; out.textContent = 'Running ' + action + '…'; }
document.querySelectorAll('.vv-fb-btn').forEach(b => { b.disabled = true; });
const fd = new URLSearchParams();
fd.append('action', action);
fd.append('host', slot);
fetch('/plugins/varaverk/api/fallback_control.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
if (out) {
// The scripts explain refusals in words — "did not exit within 30s", "NOT
// force-killing" — so the reply is shown verbatim rather than reduced to ok/failed.
out.style.color = d.ok ? '#555' : '#ef5350';
out.textContent = d.ok ? (d.output || 'done') : (d.error || 'failed');
}
})
.catch(e => { if (out) { out.style.color = '#ef5350'; out.textContent = 'Request failed: ' + e; } })
.finally(() => {
_vvFbBusy[slot] = false;
document.querySelectorAll('.vv-fb-btn').forEach(b => { b.disabled = false; });
// Re-poll rather than guessing: whether the daemon actually stopped is a fact to read
// back, not one to infer from the request having returned.
setTimeout(vvFbLoad, 1200);
});
};
window.vvFbToggle = function(track, key) {
const on = !track.classList.contains('on');
track.classList.toggle('on', on);
const fd = new URLSearchParams();
fd.append('id', 'fallback');
fd.append('changes', JSON.stringify([{ file: 'master.conf', key, value: on ? 'true' : 'false', type: 'scalar' }]));
fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => { if (!d.ok) track.classList.toggle('on', !on); })
.catch(() => track.classList.toggle('on', !on));
};
// Which conf each field belongs to. Tier delays are per-host and live in host<n>.conf; the rest
// are mesh-wide in master.conf. confform refuses a key it cannot find in the named file rather
// than appending it into an unknown section, so getting this wrong fails the save outright —
// which is the right failure, but only if the mapping is stated rather than assumed.
let _vvFbMyConf = 'host1.conf';
window.vvFbSaveSettings = function() {
const num = id => parseInt(document.getElementById(id).value, 10);
const fb = document.getElementById('vv-fb-set-fb');
const btn = document.getElementById('vv-fb-save-btn');
const interval = num('vv-fb-interval'), strikes = num('vv-fb-strikes'), suspend = num('vv-fb-suspend');
const t2 = num('vv-fb-t2'), t3 = num('vv-fb-t3'), t4 = num('vv-fb-t4');
const extip = (document.getElementById('vv-fb-extip').value || '').trim();
if ([interval, strikes, suspend, t2, t3, t4].some(n => isNaN(n) || n < 0)) {
fb.style.color = '#ef5350'; fb.textContent = 'Invalid values'; return;
}
// Tiers fire in order, and a later tier that fires sooner than an earlier one is a config that
// cannot do what it reads as. Caught here rather than at 3am during an outage.
if (!(t2 <= t3 && t3 <= t4)) {
fb.style.color = '#ef5350'; fb.textContent = 'Tier delays must increase: T2 ≤ T3 ≤ T4'; return;
}
if (extip === '') {
fb.style.color = '#ef5350'; fb.textContent = 'Probe target cannot be empty'; return;
}
btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = '';
const H = _vvFbMyConf.replace('.conf', '').toUpperCase();
const fd = new URLSearchParams();
fd.append('id', 'fallback');
fd.append('changes', JSON.stringify([
{ file: 'master.conf', key: 'FALLBACK_CHECK_INTERVAL', value: String(interval), type: 'scalar' },
{ file: 'master.conf', key: 'FALLBACK_HANDBACK_STRIKES', value: String(strikes), type: 'scalar' },
{ file: 'master.conf', key: 'FALLBACK_PARTNERSHIP_SUSPEND_AFTER', value: String(suspend), type: 'scalar' },
{ file: 'master.conf', key: 'EXTERNAL_IP', value: extip, type: 'scalar' },
{ file: _vvFbMyConf, key: H + '_TIER2_DELAY', value: String(t2), type: 'scalar' },
{ file: _vvFbMyConf, key: H + '_TIER3_DELAY', value: String(t3), type: 'scalar' },
{ file: _vvFbMyConf, key: H + '_TIER4_DELAY', value: String(t4), type: 'scalar' },
]));
fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
btn.disabled = false; btn.textContent = 'Save';
fb.style.color = d.ok ? '#4caf50' : '#ef5350';
fb.textContent = d.ok ? 'Saved ✓' : (d.error || 'Failed');
if (d.ok) { setTimeout(() => { fb.textContent = ''; }, 3000); vvFbLoad(); }
})
.catch(() => {
btn.disabled = false; btn.textContent = 'Save';
fb.style.color = '#ef5350'; fb.textContent = 'Request failed';
});
};
// ── Fallback coverage picker ──────────────────────────────────────────────────
// Holds the whole edit in memory and writes all four tiers in one POST. Per-row saves would
// leave the four arrays briefly disagreeing, and fallback.sh reads them as a set.
let _vvFbCov = null; // { containers:[], cover:{name:tier}, missing:[] }
function vvFbCovLoad() {
fetch('/plugins/varaverk/api/fallback_coverage.php')
.then(r => r.json())
.then(d => {
if (!d.ok) throw new Error(d.error || 'load failed');
_vvFbCov = d;
_vvFbCovRender();
})
.catch(e => {
const b = document.getElementById('vv-fb-cov-body');
if (b) b.innerHTML = `<span style="color:#ef5350;font-size:11px;">Could not load coverage — ${vvEscHtml(String(e))}</span>`;
});
}
// 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} in a tier · ${buckets.lock.length} always up`;
}
// Tier 0 means "not covered" — the picker's way of removing something, since an array the
// operator emptied is a legitimate and otherwise inexpressible choice.
// Delegated off the card, and the name travels in a data attribute rather than inside an
// inline handler's quotes. vvEscAttr() escapes " but not ', and these names come from conf as
// well as from Docker — a hand-typed apostrophe would have broken out of the handler string.
document.getElementById('vv-fb-cov-card')?.addEventListener('change', ev => {
const sel = ev.target.closest('.vv-fb-cov-sel');
if (sel) vvFbCovSet(sel.dataset.cov, sel.value);
});
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;
const sub = document.getElementById('vv-fb-cov-sub');
const n = Object.keys(_vvFbCov.cover).length;
if (sub) sub.textContent = `${_vvFbCov.host} · ${n} container${n !== 1 ? 's' : ''} covered · unsaved`;
}
window.vvFbCovSave = async function () {
if (!_vvFbCov) return;
const fbEl = document.getElementById('vv-fb-cov-fb');
const btn = document.getElementById('vv-fb-cov-save');
const n = Object.keys(_vvFbCov.cover).length;
// Emptying the list is allowed and is sometimes what is wanted, but it is also what a stray
// click looks like, and the consequence only appears during an outage.
if (n === 0 && !await vvConfirm('Save with NO containers covered?\n\nThe partner would start nothing for this host during an outage.')) return;
btn.disabled = true; btn.textContent = 'Saving…'; fbEl.textContent = '';
const fd = new URLSearchParams();
fd.append('action', 'cover');
fd.append('tiers', JSON.stringify(_vvFbCov.cover));
fetch('/plugins/varaverk/api/fallback_coverage.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
btn.disabled = false; btn.textContent = 'Save coverage';
fbEl.style.color = d.ok ? '#4caf50' : '#ef5350';
if (d.ok) {
const c = d.counts || {};
fbEl.textContent = `Saved ✓ T1 ${c[1]??0} · T2 ${c[2]??0} · T3 ${c[3]??0} · T4 ${c[4]??0}`;
setTimeout(() => { fbEl.textContent = ''; }, 5000);
// Re-read rather than trust the local copy, and refresh the node cards whose tier
// pills and coverage counts this just changed.
vvFbCovLoad();
vvFbLoad();
} else {
fbEl.textContent = d.error || 'Failed';
}
})
.catch(e => {
btn.disabled = false; btn.textContent = 'Save coverage';
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>