perf(web): patch health in place instead of refetching, and reconnect the SSE stream
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

Refetching everything on a health event was wasteful and churned the UI: one
container going degraded pulled down the entire fleet entity list (plus its
parent-grouping pass), or the whole fleet graph, to learn something the event
had already delivered.

health.changed / health.stale carry the new value in their payload, so the
views that hold the entity just patch it:

- Fleet table: patch the row. Only entity.* changes which entities exist, so
  only that still refetches.
- Fleet map: patch the node AND graph.health[id] — healthOf() reads the side
  map in preference to the node's own field, so patching only the nodes would
  have left the rendered colour unchanged.
- Entity detail: patch the open entity. Signals still need a read (the event
  says one was raised, not what the list now contains) but only the signals,
  not the entity and checks alongside them.

Shared in $lib/health.ts, which returns the original array when an event does
not apply so unrelated rows keep their identity and do not re-render. Note it
matches on entity_id, never data.slug: the scheduler emits health.changed with
entity_id = the observed entity but slug = the *check's* slug.

Separately, events.ts had no reconnect. onerror was empty on the assumption
the browser retries, but EventSource only does that for a transient failure --
once it reaches CLOSED (an HTTP error on connect, e.g. the API restarting
during a deploy) it stays closed forever. A single blip silently froze every
live surface in the app with nothing on screen to say so. Now reconnects with
capped exponential backoff, and exports eventsConnected so a future indicator
can show when the stream is down.

Verified against live prod: flipping lxc:apps health recoloured the map node
and moved its counts (30 healthy -> 29, 9 down -> 10) with ZERO network
requests.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-28 19:55:11 +02:00
parent 4f706fa65f
commit cc8eae4979
6 changed files with 229 additions and 48 deletions

View File

@@ -9,6 +9,7 @@
type Ontology
} from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { isHealthEvent, applyHealthEvent } from '$lib/health'
import EntityTable from '$lib/components/EntityTable.svelte'
import FleetMap from '$lib/components/FleetMap.svelte'
import { openEntityWindow, wmState } from '$lib/stores/windows'
@@ -179,18 +180,13 @@
// 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)
}
// graph view beside it updated live.
//
// Patched in place rather than refetched: the event already carries the new
// health, so a fleet-wide reload (plus its parent-grouping pass) would be a
// round trip to learn something we were just told — and would churn the
// whole table on every transition. Only entity.* changes the SET of
// entities, so only that needs a fetch.
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
@@ -198,7 +194,7 @@
loadEntities()
return
}
if (ev.type === 'health.changed' || ev.type === 'health.stale') refreshFleetSoon()
if (isHealthEvent(ev)) allEntities = applyHealthEvent(allEntities, ev)
})
const filteredEntities = $derived.by(() => {