Fix host agent type badge flapping in UI

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
This commit is contained in:
rcourtman 2025-11-30 22:07:40 +00:00
parent a7b1cf2ae9
commit 6c1a0815ea

View file

@ -84,6 +84,12 @@ export function createWebSocketStore(url: string) {
let consecutiveEmptyDockerUpdates = 0; let consecutiveEmptyDockerUpdates = 0;
let hasReceivedNonEmptyDockerHosts = false; 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[]) => { const mergeDockerHostRevocations = (incomingHosts: DockerHost[]) => {
if (!Array.isArray(incomingHosts) || incomingHosts.length === 0) { if (!Array.isArray(incomingHosts) || incomingHosts.length === 0) {
return incomingHosts; return incomingHosts;
@ -294,6 +300,8 @@ export function createWebSocketStore(url: string) {
isReconnecting = false; isReconnecting = false;
consecutiveEmptyDockerUpdates = 0; consecutiveEmptyDockerUpdates = 0;
hasReceivedNonEmptyDockerHosts = false; hasReceivedNonEmptyDockerHosts = false;
consecutiveEmptyHostUpdates = 0;
hasReceivedNonEmptyHosts = false;
// Start heartbeat to keep connection alive // Start heartbeat to keep connection alive
if (heartbeatInterval) { if (heartbeatInterval) {
@ -461,16 +469,51 @@ export function createWebSocketStore(url: string) {
logger.debug('[WebSocket] Received null dockerHosts payload'); 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 processedHosts: Host[] | null = null;
let shouldApplyHosts = false;
if (hasHostsUpdate) { if (hasHostsUpdate) {
processedHosts = Array.isArray(message.data.hosts) if (Array.isArray(message.data.hosts)) {
? mergeHostRevocations(message.data.hosts) const incomingHosts = message.data.hosts;
: 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 // 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 // Both dockerHosts and hosts in this message - batch them atomically
batch(() => { batch(() => {
setState('dockerHosts', reconcile(processedDockerHosts!, { key: 'id' })); setState('dockerHosts', reconcile(processedDockerHosts!, { key: 'id' }));
@ -503,7 +546,7 @@ export function createWebSocketStore(url: string) {
pruneMetricsByPrefix(getMetricKeyPrefix('dockerHost'), hostIds); pruneMetricsByPrefix(getMetricKeyPrefix('dockerHost'), hostIds);
pruneMetricsByPrefix(getMetricKeyPrefix('dockerContainer'), dockerContainerIds); pruneMetricsByPrefix(getMetricKeyPrefix('dockerContainer'), dockerContainerIds);
} }
if (processedHosts !== null) { if (shouldApplyHosts && processedHosts !== null) {
setState('hosts', reconcile(processedHosts, { key: 'id' })); setState('hosts', reconcile(processedHosts, { key: 'id' }));
} }
} }