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

@@ -14,6 +14,7 @@
import { onMount, onDestroy } from 'svelte'
import { fetchGraph, type GraphView, type Entity } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { isHealthEvent, applyHealthEvent, healthFromEvent } from '$lib/health'
import { Skeleton } from '$lib/components/ui/skeleton'
import { Button } from '$lib/components/ui/button'
import GlobeIcon from '@lucide/svelte/icons/globe'
@@ -56,16 +57,29 @@
return subscribeEvents()
})
// Refetch on structural or health changes, same triggers as the old graph.
// Structural changes need a refetch — they change which nodes and edges
// exist. Health does not: the event carries the new value, so the node is
// patched in place instead of pulling the entire fleet graph (and its
// layout) down again for one colour change.
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
if (
ev.type.startsWith('entity.') ||
ev.type.startsWith('relationship.') ||
ev.type === 'health.changed'
) {
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.')) {
load()
return
}
if (isHealthEvent(ev) && graph) {
const health = healthFromEvent(ev)
if (!health || !ev.entity_id) return
// healthOf() reads graph.health[id] in preference to the node's own
// field (include=status attaches it as a side map), so patching only
// the nodes would leave the rendered colour unchanged. Patch both.
const nodes = applyHealthEvent(graph.nodes, ev)
graph = {
...graph,
nodes,
health: { ...(graph.health ?? {}), [ev.entity_id]: health as Health }
}
}
})