diff --git a/Plugin/unraid/include/monitor.php b/Plugin/unraid/include/monitor.php
index 20620a1..c59fb41 100644
--- a/Plugin/unraid/include/monitor.php
+++ b/Plugin/unraid/include/monitor.php
@@ -1,5 +1,6 @@
null, 'pid' => null, 'mode' => null, 'stale_lock' => false];
+
+ // A dry run keeps its own state copy, so the live file legitimately stops changing while a
+ // preview runs. Reported so the card can say which file it is describing.
+ $preview = ['state' => null, 'age' => null];
+ if (($proc['mode'] ?? '') === 'dry-run' && !empty($proc['pid']) && function_exists('vv_fb_dryrun_state')) {
+ $preview = vv_fb_dryrun_state((int)$proc['pid']);
+ }
+
+ $sp = STATE_DIR . '/fallback_state.db';
+ $lastChg = is_file($sp) ? time() - (int)@filemtime($sp) : null;
+
+ // What this host would take on if the partner went dark, and what the partner would take on
+ // for us. Both are counts, not lists — the card is one column wide and the Fallback tab owns
+ // the detail. Tier lists always live in the COVERED host's conf.
+ $partner = null;
+ foreach (vv_known_hosts() as $slot => $hostname) {
+ if ($slot !== $me) { $partner = $slot; break; }
+ }
+ $weCover = $theyCover = null;
+ if ($partner !== null && function_exists('vv_read_host_conf_raw')) {
+ $pRaw = vv_read_host_conf_raw($partner);
+ $myRaw = vv_read_host_conf_raw($me);
+ $count = function (string $raw, string $id): int {
+ $n = 0;
+ for ($t = 1; $t <= 4; $t++) $n += count(vv_parse_bash_array($raw, "FALLBACK_{$id}_TIER{$t}"));
+ return $n;
+ };
+ $weCover = $count($pRaw, strtoupper($partner)); // partner's tiers = what we start for them
+ $theyCover = $count($myRaw, strtoupper($me)); // our tiers = what they start for us
+ }
+
+ return [
+ 'daemon' => $proc,
+ 'preview' => $preview,
+ 'last_change' => $lastChg,
+ 'partner_id' => $partner !== null ? strtoupper($partner) : null,
+ 'we_cover' => $weCover,
+ 'they_cover' => $theyCover,
+ 'rsync_on_handback' => ($vars['FALLBACK_RSYNC_ENABLED'] ?? 'false') === 'true',
+ ];
+}
+
function vv_fallback_state(): array {
$vars = vv_conf_vars();
$enabled = ($vars['FALLBACK_ENABLED'] ?? 'false') === 'true';
@@ -135,7 +190,7 @@ function vv_fallback_state(): array {
$stateFile = STATE_DIR . '/fallback_state.db';
if (!file_exists($stateFile)) {
- return ['state' => 'UNKNOWN', 'enabled' => $enabled, 'check_interval' => $interval,
+ return vv_fb_card_extra() + ['state' => 'UNKNOWN', 'enabled' => $enabled, 'check_interval' => $interval,
'handback_strikes' => 0, 'handback_strikes_required' => $reqStrikes,
'partnership_suspended' => false,
'partner_lost_at' => 0, 'partnership_suspend_after' => $suspendAfter];
@@ -145,7 +200,7 @@ function vv_fallback_state(): array {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v);
}
- return [
+ return vv_fb_card_extra() + [
'state' => $raw['state'] ?? 'UNKNOWN',
'failover_start' => $raw['failover_start'] ?? '0',
'tier2_started' => $raw['tier2_started'] ?? 'false',
diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php
index 22f803a..e9e8e50 100644
--- a/Plugin/unraid/pages/monitor.php
+++ b/Plugin/unraid/pages/monitor.php
@@ -1212,6 +1212,49 @@ function vvPollMonitor(live) {
}
fbHtml += `
${sLabel}${stateExtra}
`;
+ // ── Daemon liveness — shown in EVERY state ─────────────────────────────
+ // The line above describes a file. This one describes whether anything is still writing
+ // it. Without it the card read "✓ Nominal · monitoring · 30s" on a host where nothing had
+ // run for five days: the state file said NORMAL, and nothing contradicted it.
+ const dae = fb.daemon ?? {};
+ let daeHtml;
+ if (dae.stale_lock) {
+ daeHtml = `✕ not running
+ · stale lock${dae.pid ? ' (PID ' + dae.pid + ')' : ''}`;
+ } else if (dae.running === false) {
+ daeHtml = `✕ not running
+ · nothing is watching`;
+ } else if (dae.running && dae.mode === 'dry-run') {
+ // A preview decides nothing and writes to its own state copy, so the live figures on
+ // this card stop moving while it runs. Said plainly rather than left to infer.
+ daeHtml = `◐ dry run
+ · preview only, PID ${dae.pid}`;
+ } else if (dae.running) {
+ daeHtml = `◉ watching
+ · every ${interval}s`;
+ } else {
+ daeHtml = `— unknown`;
+ }
+ fbHtml += `${daeHtml}
`;
+
+ // ── Mutual coverage ────────────────────────────────────────────────────
+ // Both directions, because they are configured in different files and are routinely
+ // asymmetric — this mesh currently has one side fully populated and the other empty,
+ // which is invisible if the card only reports its own.
+ const pid2 = fb.partner_id;
+ if (pid2) {
+ const we = fb.we_cover;
+ const they = fb.they_cover;
+ const num = (n, warn) => n === null || n === undefined
+ ? `—`
+ : `${n}`;
+ fbHtml += `
+ We cover ${vvEscHtml(pid2)}${num(we, true)}
+ ${vvEscHtml(pid2)} covers us${num(they, false)}
+
`;
+ if (we === 0) fbHtml += `No tiers set for ${vvEscHtml(pid2)} — we would start nothing
`;
+ }
+
// ── Handback strike progress ───────────────────────────────────────────
if (state === 'FALLBACK' && strikes > 0) {
const dots = Array.from({length: maxStrikes}, (_,i) =>
@@ -1257,9 +1300,22 @@ function vvPollMonitor(live) {
// ── NORMAL quiet state ─────────────────────────────────────────────────
if (state === 'NORMAL' && !fbActive.length) {
- let meta = `monitoring · ${interval}s`;
- if (ptRequired) meta += ' · partnership gated';
- fbHtml += `${meta}
`;
+ // "monitoring · 30s" used to live here and was the card's only footer — a claim about a
+ // running process made from a file. The daemon row above owns that claim now, so this
+ // reports the things the state file genuinely knows.
+ const bits = [];
+ const lc = fb.last_change;
+ if (lc !== null && lc !== undefined) {
+ const a = lc < 60 ? lc + 's' : lc < 3600 ? Math.floor(lc/60) + 'm'
+ : lc < 86400 ? Math.floor(lc/3600) + 'h' : Math.floor(lc/86400) + 'd';
+ bits.push(`no change in ${a}`);
+ }
+ if (ptRequired) bits.push('partnership gated');
+ // Only mentioned when OFF, and only here: handback writeback is the step that carries
+ // work done during an outage back to the recovered host, and losing it silently is the
+ // expensive half of a failover nobody notices until afterwards.
+ if (fb.rsync_on_handback === false) bits.push('no handback rsync');
+ if (bits.length) fbHtml += `${bits.join(' · ')}
`;
}
// ── NO_INTERNET / DARK detail ──────────────────────────────────────────
@@ -1276,6 +1332,12 @@ function vvPollMonitor(live) {
if (fbCard) {
fbCard.classList.remove('vv-accent-ok','vv-accent-warn','vv-accent-err');
if (!enabled || ptSuspended) fbCard.classList.add('vv-accent-warn');
+ // A green accent is a claim that this is covered. It is not, if nothing is running or
+ // the only thing running is a preview that would take no action.
+ else if (fb.daemon && (fb.daemon.running === false || fb.daemon.stale_lock))
+ fbCard.classList.add('vv-accent-err');
+ else if (fb.daemon && fb.daemon.mode === 'dry-run')
+ fbCard.classList.add('vv-accent-warn');
else if (state === 'NORMAL') fbCard.classList.add('vv-accent-ok');
else if (state === 'FALLBACK') fbCard.classList.add('vv-accent-err');
else if (state !== 'UNKNOWN') fbCard.classList.add('vv-accent-warn');