From 6c1a0815ea0d28b10ffa12f70cdd1124fb324963 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 30 Nov 2025 22:07:40 +0000 Subject: [PATCH] Fix host agent type badge flapping in UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add transient empty payload protection for hosts data, matching the existing protection for dockerHosts. When a websocket message contains an empty hosts array (transient state), the UI now waits for 3 consecutive empty payloads before applying the change. This prevents the "Host" type badge from disappearing intermittently in Settings → Agents → Managed Agents when the unified agent is reporting both host and docker data. Related to #773 --- frontend-modern/src/stores/websocket.ts | 55 ++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts index cbdada0..78c4443 100644 --- a/frontend-modern/src/stores/websocket.ts +++ b/frontend-modern/src/stores/websocket.ts @@ -84,6 +84,12 @@ export function createWebSocketStore(url: string) { let consecutiveEmptyDockerUpdates = 0; let hasReceivedNonEmptyDockerHosts = false; + // Track consecutive empty hosts payloads (same protection as dockerHosts) + // This prevents "Host" type badge from disappearing when transient empty + // hosts arrays are received. See #773. + let consecutiveEmptyHostUpdates = 0; + let hasReceivedNonEmptyHosts = false; + const mergeDockerHostRevocations = (incomingHosts: DockerHost[]) => { if (!Array.isArray(incomingHosts) || incomingHosts.length === 0) { return incomingHosts; @@ -294,6 +300,8 @@ export function createWebSocketStore(url: string) { isReconnecting = false; consecutiveEmptyDockerUpdates = 0; hasReceivedNonEmptyDockerHosts = false; + consecutiveEmptyHostUpdates = 0; + hasReceivedNonEmptyHosts = false; // Start heartbeat to keep connection alive if (heartbeatInterval) { @@ -461,16 +469,51 @@ export function createWebSocketStore(url: string) { logger.debug('[WebSocket] Received null dockerHosts payload'); } - // Prepare hosts data if present + // Prepare hosts data if present (with same transient empty protection as dockerHosts) let processedHosts: Host[] | null = null; + let shouldApplyHosts = false; + if (hasHostsUpdate) { - processedHosts = Array.isArray(message.data.hosts) - ? mergeHostRevocations(message.data.hosts) - : message.data.hosts; + if (Array.isArray(message.data.hosts)) { + const incomingHosts = message.data.hosts; + if (incomingHosts.length === 0) { + consecutiveEmptyHostUpdates += 1; + + shouldApplyHosts = + !hasReceivedNonEmptyHosts || + consecutiveEmptyHostUpdates >= 3 || + message.type === WEBSOCKET.MESSAGE_TYPES.INITIAL_STATE; + + if (shouldApplyHosts) { + logger.debug('[WebSocket] Updating hosts', { + count: incomingHosts.length, + }); + processedHosts = mergeHostRevocations(incomingHosts); + } else { + logger.debug('[WebSocket] Skipping transient empty hosts payload', { + streak: consecutiveEmptyHostUpdates, + }); + } + } else { + consecutiveEmptyHostUpdates = 0; + hasReceivedNonEmptyHosts = true; + shouldApplyHosts = true; + logger.debug('[WebSocket] Updating hosts', { + count: incomingHosts.length, + }); + processedHosts = mergeHostRevocations(incomingHosts); + } + } else { + logger.warn('[WebSocket] Received non-array hosts payload', { + type: typeof message.data.hosts, + }); + } + } else if (message.data.hosts === null) { + logger.debug('[WebSocket] Received null hosts payload'); } // Apply updates - batch together if both are present to prevent flapping - if (shouldApplyDockerHosts && processedHosts !== null) { + if (shouldApplyDockerHosts && shouldApplyHosts) { // Both dockerHosts and hosts in this message - batch them atomically batch(() => { setState('dockerHosts', reconcile(processedDockerHosts!, { key: 'id' })); @@ -503,7 +546,7 @@ export function createWebSocketStore(url: string) { pruneMetricsByPrefix(getMetricKeyPrefix('dockerHost'), hostIds); pruneMetricsByPrefix(getMetricKeyPrefix('dockerContainer'), dockerContainerIds); } - if (processedHosts !== null) { + if (shouldApplyHosts && processedHosts !== null) { setState('hosts', reconcile(processedHosts, { key: 'id' })); } }