dashboard(matrix-accounts): dim live dot when daemon heartbeat stalls

The daemon now force-rewrites its matrix-accounts.json snapshot every
~30s, so as_of_unix advances while the daemon is alive and a stalled
value is an honest 'stopped publishing' signal rather than just an old
snapshot. Use it: when an account's snapshot still says live but as_of
hasn't advanced in more than ~90s (3 missed heartbeats) and the
container is NOT explicitly down, dim + desaturate the green dot and
label it 'online · no heartbeat', with a tooltip explaining the daemon
is likely dead or wedged. The container cross-ref still takes
precedence — a stopped container stays the solid amber 'stale'. Keeps
the existing 3 states intact; adds a distinct degraded-green sub-state.
This commit is contained in:
iris 2026-06-22 14:15:19 +02:00
commit fe974c580d
2 changed files with 46 additions and 19 deletions

View file

@ -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;

View file

@ -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 ✓';