Fallback card: rich state display with tiers, strikes, disabled/suspended

include/monitor.php: vv_fallback_state() now returns:
  enabled, check_interval, handback_strikes, handback_strikes_required,
  partnership_suspended, partner_lost_at, partnership_required,
  partnership_suspend_after — all read from state file + conf vars.

pages/monitor.php: fallback card render rebuilt:
  Disabled: clear message with how to enable
  Suspended: partnership inactive with elapsed time
  FAILOVER: outage duration + T1/T2/T3/T4 tier badges (active=green,
    pending=dark) + handback strike progress dots (●●○) when remote
    comes back and strikes are counting
  NO_INTERNET/DARK: short contextual line
  NORMAL: quiet "monitoring · 30s · partnership gated" meta line
  Accent border: disabled/suspended → warn, NORMAL → ok, FAILOVER → err
This commit is contained in:
Gmer4Lfe
2026-05-30 17:24:55 -04:00
parent 084dc819d9
commit 1e9d8bc75b
2 changed files with 146 additions and 51 deletions
+26 -7
View File
@@ -46,20 +46,39 @@ function vv_partner_state(): array {
}
function vv_fallback_state(): array {
// State file written by fallback.sh
$vars = vv_conf_vars();
$enabled = ($vars['FALLBACK_ENABLED'] ?? 'false') === 'true';
$interval = (int)($vars['FALLBACK_CHECK_INTERVAL'] ?? 30);
$reqStrikes = (int)($vars['FALLBACK_HANDBACK_STRIKES'] ?? 3);
$ptRequired = ($vars['FALLBACK_PARTNERSHIP_REQUIRED'] ?? 'true') === 'true';
$suspendAfter = (int)($vars['FALLBACK_PARTNERSHIP_SUSPEND_AFTER'] ?? 120);
$stateFile = '/tmp/fallback_state.db';
if (!file_exists($stateFile)) return ['state' => 'UNKNOWN'];
if (!file_exists($stateFile)) {
return ['state' => 'UNKNOWN', 'enabled' => $enabled, 'check_interval' => $interval,
'handback_strikes' => 0, 'handback_strikes_required' => $reqStrikes,
'partnership_required' => $ptRequired, 'partnership_suspended' => false,
'partner_lost_at' => 0, 'partnership_suspend_after' => $suspendAfter];
}
$raw = [];
foreach (file($stateFile) ?: [] as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v);
}
return [
'state' => $raw['state'] ?? 'UNKNOWN',
'failover_start' => $raw['failover_start'] ?? '0',
'tier2_started' => $raw['tier2_started'] ?? 'false',
'tier3_started' => $raw['tier3_started'] ?? 'false',
'tier4_started' => $raw['tier4_started'] ?? 'false',
'state' => $raw['state'] ?? 'UNKNOWN',
'failover_start' => $raw['failover_start'] ?? '0',
'tier2_started' => $raw['tier2_started'] ?? 'false',
'tier3_started' => $raw['tier3_started'] ?? 'false',
'tier4_started' => $raw['tier4_started'] ?? 'false',
'handback_strikes' => (int)($raw['handback_strikes'] ?? 0),
'partnership_suspended' => ($raw['partnership_suspended'] ?? 'false') === 'true',
'partner_lost_at' => (int)($raw['partner_lost_at'] ?? 0),
'enabled' => $enabled,
'check_interval' => $interval,
'handback_strikes_required' => $reqStrikes,
'partnership_required' => $ptRequired,
'partnership_suspend_after' => $suspendAfter,
];
}
+120 -44
View File
@@ -693,51 +693,127 @@ function vvPollMonitor() {
})();
// ── Fallback ──────────────────────────────────────────────────────────────
const fb = d.fallback ?? {};
const fbActive = d.fallback_active ?? [];
const state = (fb.state ?? 'UNKNOWN').toUpperCase();
const stateMap = {
NORMAL: ['#4caf50', '✓ Nominal'],
FAILOVER: ['#f44336', '⚠ Failover active'],
NO_INTERNET: ['#ff9800', '⚡ Internet lost'],
DARK: ['#9e9e9e', '◌ Dark mode'],
UNKNOWN: ['#444', '— No state file'],
};
const [sColor, sLabel] = stateMap[state] ?? ['#444', state];
let fbHtml = `<div style="font-size:11px;color:${sColor};margin-bottom:8px;">${sLabel}`;
if (state === 'FAILOVER') {
const start = parseInt(fb.failover_start ?? 0);
if (start > 0) {
const sec = Math.floor(Date.now() / 1000) - start;
fbHtml += ` · ${Math.floor(sec/3600)}h ${Math.floor((sec%3600)/60)}m`;
}
}
fbHtml += `</div>`;
if (fbActive.length) {
fbActive.forEach(group => {
fbHtml += `<div style="font-size:10px;color:#888;margin-bottom:4px;">Running for ${group.hostname}</div>`;
group.containers.forEach(c => {
const img = c.image.includes('/') ? c.image.split('/').pop() : c.image;
fbHtml += `<div style="display:flex;justify-content:space-between;align-items:center;
background:#1a1a1a;border-radius:4px;padding:4px 8px;margin-bottom:4px;">
<span style="color:#ccc;font-size:11px;font-weight:500;">${c.name}</span>
<span style="color:#555;font-size:9px;margin-left:8px;white-space:nowrap;">${img}</span>
</div>`;
});
});
} else if (state === 'NORMAL') {
fbHtml += `<div style="font-size:10px;color:#333;">No containers active</div>`;
}
document.getElementById('vv-fallback-body').innerHTML = fbHtml;
(function() {
const c = document.getElementById('vv-fallback');
if (!c) return;
c.classList.remove('vv-accent-ok','vv-accent-warn','vv-accent-err');
if (state === 'NORMAL') c.classList.add('vv-accent-ok');
else if (state === 'FAILOVER') c.classList.add('vv-accent-err');
else if (state !== 'UNKNOWN') c.classList.add('vv-accent-warn');
const fb = d.fallback ?? {};
const fbActive = d.fallback_active ?? [];
const state = (fb.state ?? 'UNKNOWN').toUpperCase();
const enabled = fb.enabled ?? false;
const strikes = fb.handback_strikes ?? 0;
const maxStrikes = fb.handback_strikes_required ?? 3;
const ptSuspended = fb.partnership_suspended ?? false;
const partnerLostAt = fb.partner_lost_at ?? 0;
const suspendAfter = fb.partnership_suspend_after ?? 120;
const interval = fb.check_interval ?? 30;
const ptRequired = fb.partnership_required ?? false;
const stateMap = {
NORMAL: ['#4caf50', '✓ Nominal'],
FAILOVER: ['#f44336', '⚠ Failover active'],
NO_INTERNET: ['#ff9800', '⚡ Internet lost'],
DARK: ['#9e9e9e', '◌ Dark mode'],
UNKNOWN: ['#444', '— No state file'],
};
const [sColor, sLabel] = stateMap[state] ?? ['#444', state];
// ── Disabled ──────────────────────────────────────────────────────────
if (!enabled) {
document.getElementById('vv-fallback-body').innerHTML =
`<div style="font-size:11px;color:#444;margin-bottom:6px;">○ Disabled</div>
<div style="font-size:10px;color:#333;">Set FALLBACK_ENABLED=true in master.conf to activate</div>`;
return;
}
// ── Suspended (partnership gate) ───────────────────────────────────────
if (ptSuspended) {
const lostMin = partnerLostAt > 0 ? Math.floor((Date.now()/1000 - partnerLostAt) / 60) : '?';
document.getElementById('vv-fallback-body').innerHTML =
`<div style="font-size:11px;color:#555;margin-bottom:6px;">⊘ Suspended</div>
<div style="font-size:10px;color:#444;margin-bottom:3px;">Partnership inactive · ${lostMin}m elapsed</div>
<div style="font-size:10px;color:#333;">Resumes when partnership becomes active</div>`;
return;
}
let fbHtml = '';
// ── State label + outage duration ──────────────────────────────────────
let stateExtra = '';
if (state === 'FAILOVER' || state === 'DARK') {
const start = parseInt(fb.failover_start ?? 0);
if (start > 0) {
const sec = Math.floor(Date.now() / 1000) - start;
stateExtra = ` · ${Math.floor(sec/3600)}h ${Math.floor((sec%3600)/60)}m`;
}
}
fbHtml += `<div style="font-size:11px;color:${sColor};font-weight:500;margin-bottom:6px;">${sLabel}${stateExtra}</div>`;
// ── Handback strike progress ───────────────────────────────────────────
if (state === 'FAILOVER' && strikes > 0) {
const dots = Array.from({length: maxStrikes}, (_,i) =>
`<span style="color:${i < strikes ? '#4caf50' : '#2a2a2a'};font-size:14px;line-height:1;">●</span>`
).join('');
fbHtml += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
<span style="font-size:10px;color:#555;">Handback</span>
<span style="display:flex;gap:3px;">${dots}</span>
<span style="font-size:10px;color:#555;">${strikes}/${maxStrikes}</span>
</div>`;
}
// ── Tier badges (FAILOVER only) ────────────────────────────────────────
if (state === 'FAILOVER') {
const t2 = fb.tier2_started === 'true';
const t3 = fb.tier3_started === 'true';
const t4 = fb.tier4_started === 'true';
const tier = (n, on) => `<span style="font-size:9px;padding:1px 6px;border-radius:3px;
background:${on ? '#1a2a1a' : '#1a1a1a'};
border:1px solid ${on ? '#2a5a2a' : '#252525'};
color:${on ? '#4caf50' : '#333'};">T${n}</span>`;
fbHtml += `<div style="display:flex;gap:4px;margin-bottom:8px;">
${tier(1,true)} ${tier(2,t2)} ${tier(3,t3)} ${tier(4,t4)}
</div>`;
}
// ── Active containers ──────────────────────────────────────────────────
if (fbActive.length) {
fbActive.forEach(group => {
fbHtml += `<div style="font-size:10px;color:#555;margin-bottom:4px;letter-spacing:.03em;">
COVERING ${group.hostname}
</div>`;
group.containers.forEach(c => {
const img = c.image.includes('/') ? c.image.split('/').pop() : c.image;
fbHtml += `<div style="display:flex;justify-content:space-between;align-items:center;
background:#1a1a1a;border-radius:4px;padding:4px 8px;margin-bottom:3px;">
<span style="color:#ccc;font-size:11px;font-weight:500;">${c.name}</span>
<span style="color:#444;font-size:9px;margin-left:8px;white-space:nowrap;">${img}</span>
</div>`;
});
});
}
// ── NORMAL quiet state ─────────────────────────────────────────────────
if (state === 'NORMAL' && !fbActive.length) {
let meta = `monitoring · ${interval}s`;
if (ptRequired) meta += ' · partnership gated';
fbHtml += `<div style="font-size:10px;color:#2a2a2a;">${meta}</div>`;
}
// ── NO_INTERNET / DARK detail ──────────────────────────────────────────
if (state === 'NO_INTERNET') {
fbHtml += `<div style="font-size:10px;color:#555;">DDNS stopped · awaiting recovery</div>`;
} else if (state === 'DARK') {
fbHtml += `<div style="font-size:10px;color:#555;">Remote + internet both down</div>`;
}
document.getElementById('vv-fallback-body').innerHTML = fbHtml;
// Status accent border
const fbCard = document.getElementById('vv-fallback');
if (fbCard) {
fbCard.classList.remove('vv-accent-ok','vv-accent-warn','vv-accent-err');
if (!enabled || ptSuspended) fbCard.classList.add('vv-accent-warn');
else if (state === 'NORMAL') fbCard.classList.add('vv-accent-ok');
else if (state === 'FAILOVER') fbCard.classList.add('vv-accent-err');
else if (state !== 'UNKNOWN') fbCard.classList.add('vv-accent-warn');
}
})();
// ── CPU ─────────────────────────────────────────────────────────────────