diff --git a/frontend/packages/dashboard/src/matrix-accounts.css b/frontend/packages/dashboard/src/matrix-accounts.css index ddcf8580..090537ea 100644 --- a/frontend/packages/dashboard/src/matrix-accounts.css +++ b/frontend/packages/dashboard/src/matrix-accounts.css @@ -65,6 +65,15 @@ .ma-dot.offline, .ma-dot.stale { background: var(--amber); } .ma-dot.absent { background: var(--muted); } +/* `live stale-age`: snapshot still says live but the daemon heartbeat stalled + (> ~90s). Keep the green hue but dim + desaturate so it reads "was online, + now uncertain" — visually distinct from the solid amber container-down + `stale`. More-specific (3 classes) so it overrides `.ma-dot.live`. */ +.ma-dot.live.stale-age { + background: var(--green); + opacity: 0.4; + filter: saturate(0.45); +} .ma-name { font-weight: 600; color: var(--fg); } .ma-uid { color: var(--muted); font-size: 0.8rem; margin-left: 0.4em; } .ma-hs { color: var(--muted); font-size: 0.85rem; } @@ -74,6 +83,7 @@ .ma-status.offline, .ma-status.stale { color: var(--amber); } .ma-status.absent { color: var(--muted); } +.ma-status.live.stale-age { color: var(--green); opacity: 0.6; } .ma-result { margin-top: 0.7rem; diff --git a/frontend/packages/dashboard/src/matrix-accounts.js b/frontend/packages/dashboard/src/matrix-accounts.js index 26db0a9a..b0d5fed6 100644 --- a/frontend/packages/dashboard/src/matrix-accounts.js +++ b/frontend/packages/dashboard/src/matrix-accounts.js @@ -21,20 +21,22 @@ // The token is NEVER echoed back in any response, and this page never // re-renders a submitted secret. // -// Live status dot (coordinated with the BE-4 snapshot): the daemon publishes a -// host-visible snapshot rewritten on each (re)start, so `live` means "restored -// as of `as_of_unix`", not a real-time heartbeat. We render 3 states — -// green (live + container running) = online -// amber (live + container DOWN) = stale (container down ⟹ daemon down) -// amber (token_present + !live) = provisioned but offline -// grey (no token) = not provisioned -// `as_of_unix` is tooltipped ("live as of N ago") so a green isn't read as a -// real-time guarantee. We deliberately do NOT dim a green purely on snapshot -// age: an old `as_of` is ambiguous (stable long uptime vs dead daemon), and the -// container cross-ref already catches the definitive "down" case; true -// daemon-up-but-client-dead detection is a daemon-heartbeat follow-up. When -// `live` is absent (v1 backend not yet deployed) the dot falls back to the -// token-present rendering. +// Live status dot (coordinated with the BE snapshot + heartbeat): the daemon +// force-rewrites its host-visible snapshot every ~30s (heartbeat, BE), so +// `as_of_unix` advances while the daemon is alive — a stalled `as_of` is now an +// honest "daemon stopped publishing" signal, not just "snapshot is old". We +// render — +// green (live + running + fresh) = online +// dim green (live but as_of stale > ~90s) = heartbeat stopped, likely dead +// amber (live + container DOWN) = stale (container down ⟹ down) +// amber (token_present + !live) = provisioned but offline +// grey (no token) = not provisioned +// The dim-green age case is meaningful only because of the heartbeat: 3 missed +// ~30s beats (>90s) without the container being explicitly down means the +// daemon is up-but-dead or wedged. The container cross-ref still takes +// precedence (a stopped container is definitively stale regardless of age). +// `as_of_unix` is tooltipped throughout so freshness is always legible. When +// `live` is absent (v1 backend) the dot falls back to token-present rendering. import { $, el, esc, fmtAgeSecs, renderServerWarnings } from './common.js'; @@ -102,13 +104,18 @@ async function loadAccounts(agent) { // e.g. a failed /api/state read) is treated as not-down so we never flag a // false stale. const running = containerRunning.get(agent); - // The daemon rewrites its snapshot only on (re)start, so this is "live as of" - // the last publish, not a heartbeat — surface it so a green isn't read as a - // real-time guarantee. + // The daemon force-rewrites its snapshot every ~30s, so `as_of_unix` advances + // while it's alive — this is a heartbeat, and a stalled value is meaningful. + const ageSecs = asOf != null + ? Math.max(0, Math.floor(Date.now() / 1000) - asOf) + : null; const asOfText = asOf != null - ? 'matrix snapshot · live as of ' - + fmtAgeSecs(Math.max(0, Math.floor(Date.now() / 1000) - asOf)) + ' ago' + ? 'matrix snapshot · live as of ' + fmtAgeSecs(ageSecs) + ' ago' : 'no daemon snapshot yet'; + // 3 missed ~30s heartbeats. Past this a `live` snapshot whose container is + // NOT down means the daemon stopped publishing (dead/wedged) — dim its dot. + const STALE_AGE_SECS = 90; + const staleByAge = ageSecs != null && ageSecs > STALE_AGE_SECS; list.replaceChildren(); if (!accounts.length) { list.append(el('p', { class: 'meta' }, 'no matrix accounts configured for this agent.')); @@ -130,6 +137,16 @@ async function loadAccounts(agent) { cls = 'stale'; statusText = 'container stopped'; dotTitle = 'container is stopped — live status is stale. ' + asOfText; + } else if (acc.live && staleByAge) { + // Snapshot says live, but the heartbeat (snapshot mtime = as_of) hasn't + // advanced in > ~90s while the container is NOT down — the daemon stopped + // publishing, so the "live" is no longer trustworthy. Keep the green + // family but dim it (distinct from the amber container-down 'stale'). + cls = 'live stale-age'; + statusText = 'online · no heartbeat'; + dotTitle = 'snapshot says live but the daemon heartbeat stalled ' + + fmtAgeSecs(ageSecs) + ' ago (publishes every ~30s) — likely dead or wedged. ' + + asOfText; } else if (acc.live) { cls = 'live'; statusText = 'online ✓';