feat(events): broadcast state-invalidate on docker events so dashboard updates live (#768)

Dashboard and sidebar status indicators previously only refreshed on a
5-30 second polling cadence: a container restart, a degraded -> healthy
transition, or a stack update was invisible until the next tick.

Add a lightweight, non-persisted "state-invalidate" envelope on the
existing /ws/notifications WebSocket:

Backend
- NotificationService.broadcastEvent: sibling of dispatchAlert that
  pushes an arbitrary {type, ...} envelope to every subscriber WITHOUT
  writing to the alerts history (these are pure ephemeral signals).
- DockerEventService.handleEvent: emit the envelope for state-changing
  container actions (start/die/kill/destroy/create/restart/pause/
  unpause/health_status/rename/update). Carries node id, stack name
  (from the compose project label), container id, action, and
  timestamp.

Frontend
- EditorLayout's two notification WebSocket handlers (local plus
  per-remote-node) branch on type. On state-invalidate they re-emit a
  window CustomEvent and trigger a debounced (250ms) refreshStacks so
  a burst of events from compose recreating multiple services
  collapses to one refetch. The refresh callback is held in a ref so
  the long-lived WS effect never closes over a stale function.
- useDashboardData listens for the same window event and refetches
  /stats, /system/stats, and /stacks/statuses on every signal.
  Historical metrics stay on their 60s polling cadence (10-minute
  trend data, not a live indicator).

Tests
- Three new docker-event-service cases assert broadcastEvent fires on
  start and health_status events with the correct envelope shape, and
  does not fire on non-state actions like exec_create.
- Existing 28 cases updated with the broadcastEvent mock so the
  subscriber stub matches the new shape.

Polling stays as a safety net at the same intervals; the WS path is
the fast path. Multi-node fleets benefit on the local node today;
extending the remote forwarder to relay state-invalidate is a
recommended follow-up.
This commit is contained in:
Anso
2026-04-24 23:38:08 -04:00
committed by GitHub
parent a962654a3b
commit 5c5021846a
5 changed files with 164 additions and 1 deletions
@@ -63,6 +63,16 @@ const RECONNECT_JITTER_MS = 500;
/** Compose project label key used by docker compose on every container it creates. */
const COMPOSE_PROJECT_LABEL = 'com.docker.compose.project';
/**
* Container-event actions that change observable stack/container state. The
* UI receives a lightweight `state-invalidate` signal for any of these so it
* can refetch immediately rather than wait for the next polling tick.
*/
const STATE_INVALIDATE_ACTIONS = new Set([
'start', 'die', 'kill', 'destroy', 'create', 'restart', 'pause', 'unpause',
'health_status', 'rename', 'update',
]);
/** TTL for the cached global_crash settings flag (sub-second so toggle takes effect quickly). */
const SETTINGS_CACHE_MS = 500;
@@ -372,6 +382,22 @@ export class DockerEventService {
// Normalize: `health_status: unhealthy` -> base action
const baseAction = action.startsWith('health_status') ? 'health_status' : action;
// Push a lightweight state-invalidate signal so connected UIs can
// refetch stack statuses immediately on a real container event,
// without waiting for the next polling tick. This is fire-and-forget
// and is NOT persisted to the alerts history.
if (STATE_INVALIDATE_ACTIONS.has(baseAction)) {
this.notifier.broadcastEvent({
type: 'state-invalidate',
scope: 'stack',
nodeId: this.nodeId,
stackName: event.Actor?.Attributes?.[COMPOSE_PROJECT_LABEL] ?? null,
containerId: id,
action: baseAction,
ts: Date.now(),
});
}
switch (baseAction) {
case 'kill':
return this.onKill(id, event);