Files
Varaverk/Plugin/unraid/pages/fallback.php
T
Gmer4Lfe ccc6c742ee Add structured headers to the PHP pages layer
Documents each tab's purpose, what it renders, and which endpoints it polls.
Pages that start with markup get the header in a <?php ?> block so it never
reaches the browser.

Also corrects the layer diagram in README-unraid.md: eight of eleven pages hold
no PHP logic and poll api/ for everything — only auth, monitor and scheduler
require an include/ file directly.
2026-08-02 00:51:41 -04:00

452 lines
19 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
// Read-only. The page cannot trigger a failover, force a handback, or start a covered
// container. 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.
//
// 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.
//
// RENDERS
// Per-node state, tier activation and delays, handback strikes, covered container status
//
// DEPENDS ON
// api/fallback.php polled every 30s → include/fallback.php
// api/confform.php inline conf edits → include/confform.php
?>
<style>
/* ── 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:#888;
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>
<!-- 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;">Status</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(8,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>
<!-- Settings card -->
<div class="vv-fb-card">
<div class="vv-fb-card-hdr">Settings</div>
<div class="vv-fb-set-row">
<span class="vv-fb-set-lbl">Check interval</span>
<div style="display:flex;align-items:center;gap:8px;">
<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-set-row">
<span class="vv-fb-set-lbl">Handback strikes</span>
<div style="display:flex;align-items:center;gap:8px;">
<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-set-row">
<span class="vv-fb-set-lbl">Partnership suspend after</span>
<div style="display:flex;align-items:center;gap:8px;">
<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>
<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-set-fb" style="font-size:11px;"></span>
<button class="vv-fb-save-btn" id="vv-fb-save-btn" onclick="vvFbSaveSettings()">Save</button>
</div>
</div>
<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('');
}
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 _nodeCard(node, suspendAfter) {
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 covTarget = cov
? `<span class="vv-fb-arrow">→</span><span class="vv-fb-covers">covers ${cov.id} (${cov.hostname})</span>`
: '';
const tierSection = cov
? _tierSection(cov, active, cov.delays)
: '<div style="color:#3a3a3a;font-size:11px;">No coverage configured</div>';
const ptBadge = node.is_me ? _ptStatus(st) : '';
return `<div class="vv-card vv-fb-node">
<div class="vv-fb-node-h">
${_stateDot(state)}
<span class="vv-fb-node-id">${node.id}</span>
<span class="vv-fb-node-nm">${node.hostname}</span>
${covTarget}
<span style="flex:1"></span>
${ptBadge}
${_stateBadge(state)}
</div>
<hr class="vv-fb-sep">
${tierSection}
</div>`;
}
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 fields = [
['vv-fb-interval', data.check_interval ?? 30],
['vv-fb-strikes', data.handback_req ?? 3],
['vv-fb-suspend', data.suspend_after ?? 120],
];
fields.forEach(([id, val]) => {
const el = document.getElementById(id);
if (el && el !== document.activeElement) el.value = val;
});
}
function _render(data) {
_setToggles(data);
_setInputs(data);
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.suspend_after || 120);
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>';
});
}
window.vvFbToggle = function(track, key) {
const on = !track.classList.contains('on');
track.classList.toggle('on', on);
const fd = new FormData();
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));
};
window.vvFbSaveSettings = function() {
const ivEl = document.getElementById('vv-fb-interval');
const stEl = document.getElementById('vv-fb-strikes');
const suEl = document.getElementById('vv-fb-suspend');
const fb = document.getElementById('vv-fb-set-fb');
const btn = document.getElementById('vv-fb-save-btn');
const interval = parseInt(ivEl.value, 10);
const strikes = parseInt(stEl.value, 10);
const suspend = parseInt(suEl.value, 10);
if ([interval, strikes, suspend].some(n => isNaN(n) || n < 0)) {
fb.style.color = '#ef5350'; fb.textContent = 'Invalid values'; return;
}
btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = '';
const fd = new FormData();
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' },
]));
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);
})
.catch(() => {
btn.disabled = false; btn.textContent = 'Save';
fb.style.color = '#ef5350'; fb.textContent = 'Request failed';
});
};
vvFbLoad();
setInterval(vvFbLoad, 30000);
})();
</script>