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
@@ -18,6 +18,7 @@ import { EventEmitter } from 'events';
const {
mockDispatchAlert,
mockBroadcastEvent,
mockGetGlobalSettings,
mockGetEvents,
mockListContainers,
@@ -26,6 +27,7 @@ const {
mockGetDocker,
} = vi.hoisted(() => ({
mockDispatchAlert: vi.fn().mockResolvedValue(undefined),
mockBroadcastEvent: vi.fn(),
mockGetGlobalSettings: vi.fn().mockReturnValue({ global_crash: '1' }),
mockGetEvents: vi.fn(),
mockListContainers: vi.fn().mockResolvedValue([]),
@@ -36,7 +38,10 @@ const {
vi.mock('../services/NotificationService', () => ({
NotificationService: {
getInstance: () => ({ dispatchAlert: mockDispatchAlert }),
getInstance: () => ({
dispatchAlert: mockDispatchAlert,
broadcastEvent: mockBroadcastEvent,
}),
},
}));
@@ -586,6 +591,65 @@ describe('DockerEventService - hardening', () => {
});
});
// ── State-invalidate broadcasts ────────────────────────────────────────
describe('DockerEventService - state-invalidate broadcasts', () => {
it('broadcasts state-invalidate on container start', async () => {
service = new DockerEventService(7, 'node-7');
await service.start();
stream.push({
Type: 'container',
Action: 'start',
Actor: { ID: 'aaa', Attributes: { 'com.docker.compose.project': 'web' } },
time: 1,
});
await vi.advanceTimersByTimeAsync(1);
expect(mockBroadcastEvent).toHaveBeenCalledWith(expect.objectContaining({
type: 'state-invalidate',
scope: 'stack',
nodeId: 7,
stackName: 'web',
containerId: 'aaa',
action: 'start',
}));
});
it('broadcasts state-invalidate on health_status:unhealthy', async () => {
service = new DockerEventService(1, 'local');
await service.start();
stream.push({
Type: 'container',
Action: 'health_status: unhealthy',
Actor: { ID: 'bbb', Attributes: { 'com.docker.compose.project': 'api' } },
time: 1,
});
await vi.advanceTimersByTimeAsync(1);
const states = mockBroadcastEvent.mock.calls.filter(c =>
(c[0] as { type?: string }).type === 'state-invalidate');
expect(states.length).toBeGreaterThan(0);
expect(states[0][0]).toMatchObject({ action: 'health_status', stackName: 'api' });
});
it('does not broadcast state-invalidate on non-state actions like exec_create', async () => {
service = new DockerEventService(1, 'local');
await service.start();
stream.push({
Type: 'container',
Action: 'exec_create: /bin/sh',
Actor: { ID: 'ccc' },
time: 1,
});
await vi.advanceTimersByTimeAsync(1);
expect(mockBroadcastEvent).not.toHaveBeenCalled();
});
});
// ── Diagnostics ────────────────────────────────────────────────────────
describe('DockerEventService - getStatus', () => {