From 4f706fa65ffe47ef679951e407503852356e3807 Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 28 Jul 2026 15:05:06 +0200 Subject: [PATCH] fix(web): keep health and status live everywhere they are shown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSE stream already carried health.changed, health.stale, signal.raised, signal.resolved and coverage.unmonitored, but two of the places that render health never listened for them. - The Fleet table refreshed only on entity.*, so its Health column sat at whatever it was when the page mounted while the map view beside it — which did listen — updated live. Health arrives on its own events, not entity.*. Coalesced on a 400ms timer because health.stale fires once per entity during a sweep, and refetching the whole fleet per event would mean a burst of identical requests. - The entity detail window loaded health, signals and monitoring once on open and never again, so a window left on screen kept showing the health it had at mount. That is the same staleness this whole change set has been about, reproduced one window at a time. Now scoped by entity_id, and re-reads only what a health or signal event can actually change rather than re-running the full 11-request load(). Verified against live prod: flipping lxc:apps healthy -> degraded -> healthy updated the Fleet table and an open detail window together, without a reload. Co-Authored-By: Claude --- .../lib/components/EntityDetailContent.svelte | 51 +++++++++++++++---- web/src/pages/KnowledgeBase.svelte | 22 +++++++- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index baa37a9..15ec043 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -200,22 +200,55 @@ // which only became visible once running executions were shown at all. $effect(() => { const ev = $liveEvents[0] - if (!ev || !ev.type.startsWith('execution.')) return + if (!ev) return - if (ev.type === 'execution.output') { - const target = expandedExecution - if (target && ev.entity_id === target) loadExecutionLogs(target) + if (ev.type.startsWith('execution.')) { + if (ev.type === 'execution.output') { + const target = expandedExecution + if (target && ev.entity_id === target) loadExecutionLogs(target) + return + } + if (!entity) return + // Only refetch for an execution this panel is actually showing, so an + // unrelated command elsewhere in the fleet doesn't cause a request here. + if (executions.some((e) => e.id === ev.entity_id)) refreshExecutions() return } - if (!entity) return - // Only refetch for an execution this panel is actually showing, so an - // unrelated command elsewhere in the fleet doesn't cause a request here. - if (executions.some((e) => e.id === ev.entity_id)) { - refreshExecutions() + // Health and signals for THIS entity. The panel loaded these once on + // open, so a window left on screen kept showing the health it had at + // mount — the exact staleness the fleet-wide work was about, reproduced + // one window at a time. Scoped by entity_id so an unrelated entity going + // down elsewhere costs nothing here. + if (!entity || ev.entity_id !== entity.id) return + if ( + ev.type === 'health.changed' || + ev.type === 'health.stale' || + ev.type.startsWith('signal.') || + ev.type.startsWith('coverage.') + ) { + refreshStatus() } }) + // Re-reads only what a health or signal event can change, rather than + // re-running the full 11-request load(). + async function refreshStatus() { + if (!entity) return + const id = entity.id + const slugAtStart = entity.slug + const [fresh, sig, ch] = await Promise.all([ + fetchEntity(slugAtStart), + fetchEntitySignals(id), + fetchChecksForTarget(slugAtStart) + ]) + // The panel may have switched entity while these were in flight. + if (!entity || entity.id !== id) return + if (fresh) entity = fresh + signals = sig + checks = ch + } + async function refreshExecutions() { if (!entity) return const id = entity.id diff --git a/web/src/pages/KnowledgeBase.svelte b/web/src/pages/KnowledgeBase.svelte index 3d47a2c..86b766e 100644 --- a/web/src/pages/KnowledgeBase.svelte +++ b/web/src/pages/KnowledgeBase.svelte @@ -177,10 +177,28 @@ return unsubscribe }) + // Health arrives on its own events, not entity.*, so the table's Health + // column used to sit at whatever it was when the page mounted while the + // graph view beside it updated live. Coalesced because health.stale fires + // once per entity during a sweep, and refetching the whole fleet for each + // would mean a burst of identical requests. + let fleetRefresh: ReturnType | null = null + function refreshFleetSoon() { + if (fleetRefresh) return + fleetRefresh = setTimeout(() => { + fleetRefresh = null + loadEntities() + }, 400) + } + $effect(() => { const ev = $liveEvents[0] - if (!ev || !ev.type.startsWith('entity.')) return - loadEntities() + if (!ev) return + if (ev.type.startsWith('entity.')) { + loadEntities() + return + } + if (ev.type === 'health.changed' || ev.type === 'health.stale') refreshFleetSoon() }) const filteredEntities = $derived.by(() => {