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
+26
View File
@@ -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<ReturnType<typeof setTimeout> | 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<NotificationItem, 'nodeId' | 'nodeName'>, 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);
@@ -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>('/stats'),
fetchJson<SystemStats>('/system/stats'),
fetchJson<Record<string, StackStatusEntry>>('/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<Record<string, StackCpuSeries>>(() => {
if (metrics.length === 0) return {};
const grouped = new Map<string, MetricPoint[]>();