fix(web): keep health and status live everywhere they are shown
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 15:05:06 +02:00
parent 50e899e5ee
commit 4f706fa65f
2 changed files with 62 additions and 11 deletions

View File

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

View File

@@ -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<typeof setTimeout> | 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(() => {