Files
oikos/web/src/lib/health.ts
dtoro cc8eae4979
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
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>
2026-07-28 19:55:26 +02:00

55 lines
2.3 KiB
TypeScript

// Applying health events in place.
//
// health.changed / health.stale already carry the new value in their payload,
// so a view that renders health does not need to refetch anything: it can
// patch the one entity it already holds. Refetching the whole fleet because a
// single container went degraded costs a full round trip, re-runs the parent
// grouping, and churns the table on every transition — for information the
// event had already delivered.
//
// NOTE: match on entity_id, never on data.slug. The scheduler emits
// health.changed with entity_id = the observed entity but data.slug = the
// *check's* slug (e.g. "check:ping:host:hubris:0"), so the slug in the
// payload does not identify the thing whose health changed.
import type { Entity, EntityHealth } from '$lib/api'
import type { OikosEvent } from '$lib/stores/events'
const HEALTH_EVENTS = new Set(['health.changed', 'health.stale'])
export function isHealthEvent(ev: OikosEvent): boolean {
return HEALTH_EVENTS.has(ev.type)
}
/** The new health an event reports, or null if it isn't a usable health event. */
export function healthFromEvent(ev: OikosEvent): EntityHealth | null {
if (!isHealthEvent(ev) || !ev.entity_id) return null
const to = (ev.data as Record<string, unknown> | undefined)?.to
return typeof to === 'string' ? (to as EntityHealth) : null
}
/**
* Returns a copy of `list` with the event's entity patched, or the original
* array when the event doesn't apply — so callers can assign unconditionally
* without forcing a re-render for an entity they aren't showing.
*/
export function applyHealthEvent(list: Entity[], ev: OikosEvent): Entity[] {
const health = healthFromEvent(ev)
if (!health) return list
const i = list.findIndex((e) => e.id === ev.entity_id)
if (i < 0) return list
if (list[i].health === health) return list
const next = list.slice()
// ev.ts is when the check ran, which is exactly what "checked N ago" means.
next[i] = { ...next[i], health, last_check_at: ev.ts }
return next
}
/** Single-entity form, for a detail view holding one entity. */
export function applyHealthEventTo(entity: Entity | null, ev: OikosEvent): Entity | null {
if (!entity || ev.entity_id !== entity.id) return entity
const health = healthFromEvent(ev)
if (!health || entity.health === health) return entity
return { ...entity, health, last_check_at: ev.ts }
}