mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 20:29:10 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
Reference in New Issue
Block a user