Files
Varaverk/Plugin/unraid/pages/fallback.php
T

958 lines
47 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>
/* ── 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 failover 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>
</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 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;">Failover 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.
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>
<div style="display:flex;justify-content:flex-end;align-items:center;gap:10px;margin-top:10px;padding-top:8px;border-top:1px solid #1e1e1e;">
<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>';
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>' : ''}
</div>
<div class="vv-fb-legs">
${_leg(reach.tailscale, 'tailscale')}
${_leg(node.is_me ? true : reach.ssh, 'ssh')}
${_leg(reach.state_file, 'state file')}
${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">
${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 fail over. 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 fail over</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],
];
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';
});
};
// ── Failover 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>`;
});
}
function _vvFbCovRender() {
const d = _vvFbCov;
const body = document.getElementById('vv-fb-cov-body');
if (!d || !body) return;
const sub = document.getElementById('vv-fb-cov-sub');
const n = Object.keys(d.cover || {}).length;
if (sub) sub.textContent = `${d.host} · ${n} container${n !== 1 ? 's' : ''} covered`;
// Names in a tier that are not installed here. Kept and shown rather than dropped: a tier
// entry for a container that does not exist is a line fallback.sh fails on mid-outage.
const miss = new Set((d.missing || []).map(s => s.toLowerCase()));
const all = [...new Set([...(d.containers || []), ...Object.keys(d.cover || {})])]
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
const rows = all.map(name => {
const cur = d.cover?.[name] ?? 0;
const gone = miss.has(name.toLowerCase());
const opts = [0, 1, 2, 3, 4].map(t =>
`<option value="${t}"${t === cur ? ' selected' : ''}>${t === 0 ? '—' : 'T' + t}</option>`).join('');
return `<div style="display:flex;align-items:center;gap:8px;padding:3px 0;border-bottom:1px solid #171717;">
<select class="vv-fb-set-inp vv-fb-cov-sel" style="width:56px;flex-shrink:0;"
data-cov="${vvEscAttr(name)}">${opts}</select>
<span style="font-size:11px;color:${cur ? '#ccc' : '#555'};min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${vvEscHtml(name)}</span>
${gone ? '<span class="vv-fb-leg no" style="flex-shrink:0;">not installed</span>' : ''}
</div>`;
}).join('');
// Three columns. One column of forty-nine rows is a scroll, not a list — you cannot see what
// is covered without dragging, which is the one thing this card exists to show.
body.innerHTML = '<div style="max-height:340px;overflow:auto;display:grid;'
+ 'grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:0 14px;">'
+ rows + '</div>';
}
// 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;
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;
});
};
vvFbLoad();
setInterval(vvFbLoad, 30000);
vvFbCovLoad(); // once — this is an editor, not a monitor; polling would fight the operator
})();
</script>