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>
55 lines
2.3 KiB
TypeScript
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 }
|
|
}
|