From 06b2bd8b7222ee0b9fc61cf272f403e493a4d9dd Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 22 Jun 2026 12:53:52 +0200 Subject: [PATCH] matrix-accounts: 3-state live status dot + user_id + snapshot freshness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for the BE-4 snapshot (#1702): GET /api/matrix-accounts now returns per-account `live` + `user_id` and a top-level `as_of_unix`. Render a 3-state dot instead of the v1 token-present-only one: - green (live + container running) — online - amber (live + container DOWN) — stale: the page already loads /api/state containers, so cross-reference running state; a container that's down means the daemon is down, so a "live" snapshot there is stale - amber (token_present + !live) — provisioned but offline - grey (no token) — not provisioned The daemon rewrites its snapshot only on (re)start, so `as_of_unix` is "live as of", not a heartbeat — surfaced as a tooltip. We deliberately do NOT dim a green purely on snapshot age (an old as_of is ambiguous: stable uptime vs dead daemon); the container cross-ref catches the definitive down case, and true daemon-up-but-client-dead detection is the daemon-heartbeat follow-up. user_id is shown next to the account name. When `live` is absent (v1 backend not yet deployed) the dot falls back to the token-present rendering, so this is safe to ship independent of the backend deploy. FE half of #1702 BE-4; pairs with the backend PR. --- .../dashboard/src/matrix-accounts.css | 14 ++- .../packages/dashboard/src/matrix-accounts.js | 96 +++++++++++++++---- 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/frontend/packages/dashboard/src/matrix-accounts.css b/frontend/packages/dashboard/src/matrix-accounts.css index 8a253e6e..ddcf8580 100644 --- a/frontend/packages/dashboard/src/matrix-accounts.css +++ b/frontend/packages/dashboard/src/matrix-accounts.css @@ -57,12 +57,22 @@ border-radius: 50%; flex: none; } -.ma-dot.ok { background: var(--green); } +/* `ok` is the v1 (pre BE-4) token-present green; `live` is the v2 online green. + `offline`/`stale` are the amber states (provisioned-not-live / container-down + ⟹ daemon-down). `absent` = no token. */ +.ma-dot.ok, +.ma-dot.live { background: var(--green); } +.ma-dot.offline, +.ma-dot.stale { background: var(--amber); } .ma-dot.absent { background: var(--muted); } .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; } .ma-status { margin-left: auto; font-size: 0.8rem; } -.ma-status.ok { color: var(--green); } +.ma-status.ok, +.ma-status.live { color: var(--green); } +.ma-status.offline, +.ma-status.stale { color: var(--amber); } .ma-status.absent { color: var(--muted); } .ma-result { diff --git a/frontend/packages/dashboard/src/matrix-accounts.js b/frontend/packages/dashboard/src/matrix-accounts.js index ea597aaa..92fe2159 100644 --- a/frontend/packages/dashboard/src/matrix-accounts.js +++ b/frontend/packages/dashboard/src/matrix-accounts.js @@ -4,10 +4,12 @@ // account and store its access token, without editing the agent's config // repo. Companion to the multi-account harness support. // -// Backend contract (v1): +// Backend contract (v2 — BE-4 adds live/user_id/as_of from the daemon snapshot): // GET /api/matrix-accounts?agent= -// -> { accounts: [ { name, homeserver, token_present: bool } ] } -// POST /matrix-account-login (x-www-form-urlencoded, operator-auth) +// -> { accounts: [ { name, homeserver: string|null, token_present: bool, +// live: bool, user_id: string|null } ], +// as_of_unix: int|null } +// POST /api/matrix-account-login (x-www-form-urlencoded, operator-auth) // fields: agent, account, homeserver, mode=password|token, // user_id?, password?, token? // -> 2xx { ok: true, user_id } on success @@ -15,15 +17,30 @@ // The token is NEVER echoed back in any response, and this page never // re-renders a submitted secret. // -// Live up/down (a true green/red dot) needs the daemon's account -// registry; until that follow-up lands the dot only reflects whether a -// token is STORED, labelled "token stored" rather than "online". The -// account list shows what is provisioned (has a stored token), so a -// config-declared-but-unprovisioned account appears only once provisioned. +// 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. -import { $, el, esc, renderServerWarnings } from './common.js'; +import { $, el, esc, fmtAgeSecs, renderServerWarnings } from './common.js'; let agents = []; +// agent name → container running (bool), from /api/state. Cross-referenced by +// the live dot: a `live: true` account whose container is DOWN is definitively +// stale (the daemon can't be up if the container isn't), so we flag it rather +// than show a lying green. `undefined` (agent not in the map) = unknown → we +// don't flag stale. +const containerRunning = new Map(); async function loadState() { try { @@ -32,12 +49,14 @@ async function loadState() { const s = await resp.json(); renderServerWarnings(s.server_warnings); // `/api/state` exposes the live roster under `containers` (each entry an - // object carrying `.name`); there is no top-level `agents` field, so the - // picker stays compatible with both string + object shapes defensively. - agents = (s.containers || []) - .map((a) => (typeof a === 'string' ? a : a && a.name)) - .filter(Boolean) - .sort(); + // object carrying `.name` + `.running`); there is no top-level `agents` + // field, so the picker stays compatible with both string + object shapes. + const containers = (s.containers || []) + .map((a) => (typeof a === 'string' ? { name: a } : a)) + .filter((c) => c && c.name); + agents = containers.map((c) => c.name).sort(); + containerRunning.clear(); + for (const c of containers) containerRunning.set(c.name, !!c.running); } catch { // best-effort: on a failed state read the picker renders empty // ("— no agents —") and the submit guard blocks until an agent is @@ -74,6 +93,18 @@ async function loadAccounts(agent) { return; } const accounts = data.accounts || []; + const asOf = typeof data.as_of_unix === 'number' ? data.as_of_unix : null; + // `false` only when the container is explicitly down; `undefined` (unknown, + // 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. + const asOfText = asOf != null + ? 'matrix snapshot · live as of ' + + fmtAgeSecs(Math.max(0, Math.floor(Date.now() / 1000) - asOf)) + ' ago' + : 'no daemon snapshot yet'; list.replaceChildren(); if (!accounts.length) { list.append(el('p', { class: 'meta' }, 'no matrix accounts configured for this agent.')); @@ -82,15 +113,38 @@ async function loadAccounts(agent) { const ul = el('ul', { class: 'ma-accounts' }); for (const acc of accounts) { const present = !!acc.token_present; + // 3-state dot. `live` is absent on the v1 backend (pre BE-4); when + // undefined, fall back to the v1 token-present rendering so the page + // degrades cleanly before the snapshot backend deploys. + let cls; let statusText; let dotTitle; + if (acc.live === undefined) { + cls = present ? 'ok' : 'absent'; + statusText = present ? 'token stored ✓' : 'no token'; + dotTitle = present ? 'token stored' : 'no token yet'; + } else if (acc.live && running === false) { + // container down ⟹ daemon down ⟹ a "live" snapshot is stale. + cls = 'stale'; + statusText = 'container stopped'; + dotTitle = 'container is stopped — live status is stale. ' + asOfText; + } else if (acc.live) { + cls = 'live'; + statusText = 'online ✓'; + dotTitle = asOfText; + } else if (present) { + cls = 'offline'; + statusText = 'token stored · offline'; + dotTitle = 'provisioned but not live. ' + asOfText; + } else { + cls = 'absent'; + statusText = 'no token'; + dotTitle = 'no token yet'; + } ul.append(el('li', { class: 'ma-account' }, - el('span', { - class: 'ma-dot ' + (present ? 'ok' : 'absent'), - title: present ? 'token stored' : 'no token yet', - }), + el('span', { class: 'ma-dot ' + cls, title: dotTitle }), el('span', { class: 'ma-name' }, acc.name || '(unnamed)'), + acc.user_id ? el('span', { class: 'ma-uid' }, acc.user_id) : null, el('span', { class: 'ma-hs' }, acc.homeserver || '—'), - el('span', { class: 'ma-status ' + (present ? 'ok' : 'absent') }, - present ? 'token stored ✓' : 'no token'), + el('span', { class: 'ma-status ' + cls, title: asOfText }, statusText), )); } list.append(ul);