diff --git a/backend/src/__tests__/docker-event-service.test.ts b/backend/src/__tests__/docker-event-service.test.ts index 657cbe68..0d09d359 100644 --- a/backend/src/__tests__/docker-event-service.test.ts +++ b/backend/src/__tests__/docker-event-service.test.ts @@ -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', () => { diff --git a/backend/src/services/DockerEventService.ts b/backend/src/services/DockerEventService.ts index ef694412..1e87888d 100644 --- a/backend/src/services/DockerEventService.ts +++ b/backend/src/services/DockerEventService.ts @@ -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); diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index 033da05f..d509deef 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -52,6 +52,25 @@ export class NotificationService { } } + /** + * Broadcast an arbitrary non-notification event envelope to every + * currently-open subscriber WITHOUT writing it to the alerts history. + * + * Used by DockerEventService to push lightweight `state-invalidate` + * signals so the UI can refetch stack statuses on a real container event + * instead of waiting for the next polling tick. Persisting these would + * spam the notifications panel; they are pure ephemeral signals. + */ + public broadcastEvent(envelope: { type: string; [key: string]: unknown }): void { + if (this.subscribers.size === 0) return; + const msg = JSON.stringify(envelope); + for (const ws of this.subscribers) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(msg); + } + } + } + /** * Dispatch an alert: log to history, push via WebSocket, and route to * external channels. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 44d25f65..f8886e6a 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -609,6 +609,22 @@ export default function EditorLayout() { } }; + // Coalesce a burst of state-invalidate signals (e.g. compose recreating + // 10 services produces ~30 docker events in <500ms) into one stack refetch + // and one downstream window event. The 250ms debounce balances "feels + // instant" against not thrashing the API. The function is held in a ref + // so the long-lived WS effect never closes over a stale refreshStacks. + const stateInvalidateTimerRef = useRef | null>(null); + const refreshStacksRef = useRef(refreshStacks); + useEffect(() => { refreshStacksRef.current = refreshStacks; }, [refreshStacks]); + const scheduleStateInvalidateRefresh = useCallback(() => { + if (stateInvalidateTimerRef.current) clearTimeout(stateInvalidateTimerRef.current); + stateInvalidateTimerRef.current = setTimeout(() => { + stateInvalidateTimerRef.current = null; + refreshStacksRef.current(true); + }, 250); + }, []); + // Notification WS push - subscribe to local real-time alerts. // Initial history load is handled by the [nodes] effect below. useEffect(() => { @@ -645,6 +661,13 @@ export default function EditorLayout() { nodeName: localNode?.name ?? 'Local', }; setNotifications(prev => [tagged, ...prev].sort((a, b) => b.timestamp - a.timestamp)); + } else if (msg.type === 'state-invalidate') { + // Lightweight signal that a container/stack event happened. + // Re-broadcast on the window bus so other hooks (dashboard data, + // sidebar, etc.) can refetch on the same trigger without prop + // drilling. Refresh stack statuses on this layer too. + window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: msg })); + scheduleStateInvalidateRefresh(); } } catch (e) { console.error('[WS notifications] parse error', e); @@ -728,6 +751,9 @@ export default function EditorLayout() { [{ ...msg.payload as Omit, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev] .sort((a, b) => b.timestamp - a.timestamp) ); + } else if (msg.type === 'state-invalidate') { + window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: { ...msg, nodeId: rn.id } })); + scheduleStateInvalidateRefresh(); } } catch (e) { console.error(`[WS notifications:${rn.name}] parse error`, e); diff --git a/frontend/src/components/dashboard/useDashboardData.ts b/frontend/src/components/dashboard/useDashboardData.ts index a76e6c90..8a9ce1dd 100644 --- a/frontend/src/components/dashboard/useDashboardData.ts +++ b/frontend/src/components/dashboard/useDashboardData.ts @@ -164,6 +164,34 @@ export function useDashboardData(): DashboardData { return cleanup; }, [nodeId, fetchJson]); + // React to live `state-invalidate` signals from /ws/notifications: when a + // Docker container event fires (start/stop/die/restart/health), the layout + // re-broadcasts the envelope as a window CustomEvent. Refetch the cheap + // data (stats, system, statuses) immediately so the dashboard header and + // sidebar status update in well under a second instead of waiting for the + // next polling tick. Historical metrics are intentionally skipped — they + // are a 10-minute trend, not a live indicator. + useEffect(() => { + const currentNodeId = nodeId; + const onInvalidate = async () => { + if (nodeIdRef.current !== currentNodeId) return; + const [statsData, sysData, statusesData] = await Promise.all([ + fetchJson('/stats'), + fetchJson('/system/stats'), + fetchJson>('/stacks/statuses'), + ]); + if (nodeIdRef.current !== currentNodeId) return; + if (statsData) { + setStats(statsData); + setLastSyncAt(Date.now()); + } + if (sysData) setSystemStats(sysData); + if (statusesData) setStackStatuses(statusesData); + }; + window.addEventListener('sencho:state-invalidate', onInvalidate); + return () => window.removeEventListener('sencho:state-invalidate', onInvalidate); + }, [nodeId, fetchJson]); + const stackCpuSeries = useMemo>(() => { if (metrics.length === 0) return {}; const grouped = new Map();