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:
85
web/src/lib/health.test.ts
Normal file
85
web/src/lib/health.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isHealthEvent, healthFromEvent, applyHealthEvent, applyHealthEventTo } from './health'
|
||||
import type { Entity } from './api'
|
||||
import type { OikosEvent } from './stores/events'
|
||||
|
||||
function ev(partial: Partial<OikosEvent>): OikosEvent {
|
||||
return {
|
||||
id: 1,
|
||||
ts: '2026-07-28T12:00:00Z',
|
||||
type: 'health.changed',
|
||||
entity_id: 'e1',
|
||||
severity: 'info',
|
||||
source: 'scheduler',
|
||||
data: { to: 'degraded' },
|
||||
correlation_id: null,
|
||||
...partial
|
||||
} as OikosEvent
|
||||
}
|
||||
|
||||
function entity(partial: Partial<Entity> = {}): Entity {
|
||||
return {
|
||||
id: 'e1',
|
||||
slug: 'lxc:apps',
|
||||
type: 'lxc',
|
||||
name: 'apps',
|
||||
attributes: {},
|
||||
version: 1,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
health: 'healthy',
|
||||
...partial
|
||||
} as Entity
|
||||
}
|
||||
|
||||
describe('health event application', () => {
|
||||
it('reads the new health out of the payload', () => {
|
||||
expect(healthFromEvent(ev({}))).toBe('degraded')
|
||||
expect(healthFromEvent(ev({ type: 'health.stale', data: { to: 'stale' } }))).toBe('stale')
|
||||
})
|
||||
|
||||
it('ignores events that are not health events', () => {
|
||||
expect(isHealthEvent(ev({ type: 'signal.raised' }))).toBe(false)
|
||||
expect(healthFromEvent(ev({ type: 'execution.output' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a health event with no usable payload', () => {
|
||||
expect(healthFromEvent(ev({ data: {} }))).toBeNull()
|
||||
expect(healthFromEvent(ev({ entity_id: null }))).toBeNull()
|
||||
})
|
||||
|
||||
it('patches the matching entity in a list and leaves the rest alone', () => {
|
||||
const list = [entity(), entity({ id: 'e2', slug: 'lxc:dns', health: 'healthy' })]
|
||||
const next = applyHealthEvent(list, ev({}))
|
||||
expect(next[0].health).toBe('degraded')
|
||||
expect(next[0].last_check_at).toBe('2026-07-28T12:00:00Z')
|
||||
// untouched entities keep their identity, so rows that did not change do
|
||||
// not re-render
|
||||
expect(next[1]).toBe(list[1])
|
||||
})
|
||||
|
||||
// Returning the same array reference matters: assigning a fresh array on
|
||||
// every unrelated event would churn the whole table.
|
||||
it('returns the original array when the event does not apply', () => {
|
||||
const list = [entity()]
|
||||
expect(applyHealthEvent(list, ev({ entity_id: 'nobody' }))).toBe(list)
|
||||
expect(applyHealthEvent(list, ev({ type: 'signal.raised' }))).toBe(list)
|
||||
expect(applyHealthEvent(list, ev({ data: { to: 'healthy' } }))).toBe(list) // already healthy
|
||||
})
|
||||
|
||||
it('patches a single entity for a detail view', () => {
|
||||
const e = entity()
|
||||
const next = applyHealthEventTo(e, ev({}))
|
||||
expect(next?.health).toBe('degraded')
|
||||
expect(applyHealthEventTo(e, ev({ entity_id: 'other' }))).toBe(e)
|
||||
expect(applyHealthEventTo(null, ev({}))).toBeNull()
|
||||
})
|
||||
|
||||
// The scheduler emits health.changed with entity_id = the observed entity
|
||||
// but data.slug = the *check's* slug, so slug must never be used to match.
|
||||
it('matches on entity_id, never on the payload slug', () => {
|
||||
const list = [entity()]
|
||||
const next = applyHealthEvent(list, ev({ data: { to: 'down', slug: 'check:ping:whatever:0' } }))
|
||||
expect(next[0].health).toBe('down')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user