perf(web): patch health in place instead of refetching, and reconnect the SSE stream
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:
@@ -36,6 +36,7 @@
|
||||
} from '$lib/api'
|
||||
import { relativeTime, truncateMiddle } from '$lib/utils'
|
||||
import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events'
|
||||
import { isHealthEvent, applyHealthEventTo } from '$lib/health'
|
||||
import DetailSection from '$lib/components/DetailSection.svelte'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
@@ -217,36 +218,31 @@
|
||||
|
||||
// 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.
|
||||
// mount. 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()
|
||||
|
||||
// Health is patched from the event itself — it carries the new value, so
|
||||
// there is nothing to go and ask for.
|
||||
if (isHealthEvent(ev)) {
|
||||
entity = applyHealthEventTo(entity, ev)
|
||||
return
|
||||
}
|
||||
|
||||
// Signals genuinely need a read: the event says one was raised or
|
||||
// resolved, not what the full signal list now looks like. Just the
|
||||
// signals, though — not the entity, checks, and everything else.
|
||||
if (ev.type.startsWith('signal.') || ev.type.startsWith('coverage.')) {
|
||||
refreshSignals()
|
||||
}
|
||||
})
|
||||
|
||||
// Re-reads only what a health or signal event can change, rather than
|
||||
// re-running the full 11-request load().
|
||||
async function refreshStatus() {
|
||||
async function refreshSignals() {
|
||||
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
|
||||
const next = await fetchEntitySignals(id)
|
||||
// The panel may have switched entity while this was in flight.
|
||||
if (entity?.id === id) signals = next
|
||||
}
|
||||
|
||||
async function refreshExecutions() {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user