diff --git a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_health.ts b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_health.ts index 202497acd3873..586d836b0de88 100644 --- a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_health.ts +++ b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_health.ts @@ -10,6 +10,8 @@ export enum PrivateLocationHealthStatusValue { MissingPackagePolicy = 'missing_package_policy', MissingAgentPolicy = 'missing_agent_policy', MissingLocation = 'missing_location', + MissingAgents = 'missing_agents', + UnhealthyAgent = 'unhealthy_agent', } export interface PrivateLocationHealthStatus { diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/status_labels.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/status_labels.ts index b40588615f80c..9d6c7ca5250d3 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/status_labels.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/status_labels.ts @@ -31,6 +31,20 @@ export const STATUS_LABELS: Record< defaultMessage: 'The monitor references a private location that no longer exists.', } ), + [PrivateLocationHealthStatusValue.MissingAgents]: i18n.translate( + 'xpack.synthetics.monitorHealth.status.missingAgents', + { + defaultMessage: + 'No Fleet agents are enrolled in the agent policy for this private location. Enroll an agent in Fleet to resolve this.', + } + ), + [PrivateLocationHealthStatusValue.UnhealthyAgent]: i18n.translate( + 'xpack.synthetics.monitorHealth.status.unhealthyAgent', + { + defaultMessage: + 'All Fleet agents for this private location are unhealthy or offline. Check the agent status in Fleet.', + } + ), }; export const getStatusLabel = (status: PrivateLocationHealthStatusValue): string | undefined => { diff --git a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.test.ts index 4f8e358881cbc..418795398364b 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.test.ts @@ -73,6 +73,7 @@ const buildApi = (overrides: { fleetGetByIDs?: jest.Mock; fleetAgentPolicyGetByIds?: jest.Mock; fleetGetInstallation?: jest.Mock; + fleetGetAgentStatusForAgentPolicy?: jest.Mock; }): MonitorIntegrationHealthApi => { const fleetGetByIDs = overrides.fleetGetByIDs ?? jest.fn().mockResolvedValue([]); @@ -85,6 +86,11 @@ const buildApi = (overrides: { const fleetGetInstallation = overrides.fleetGetInstallation ?? jest.fn().mockResolvedValue({ install_status: 'installed' }); + // Default: all agents healthy (active > 0, online > 0) + const fleetGetAgentStatusForAgentPolicy = + overrides.fleetGetAgentStatusForAgentPolicy ?? + jest.fn().mockResolvedValue({ all: 1, active: 1, online: 1 }); + const server = { coreStart: { savedObjects: { @@ -97,6 +103,11 @@ const buildApi = (overrides: { packageService: { asInternalUser: { getInstallation: fleetGetInstallation }, }, + agentService: { + asInternalUser: { + getAgentStatusForAgentPolicy: fleetGetAgentStatusForAgentPolicy, + }, + }, }, } as unknown as SyntheticsServerSetup; @@ -602,6 +613,97 @@ describe('MonitorIntegrationHealthApi', () => { }); }); + describe('missing agents', () => { + it('returns MissingAgents when no agents are enrolled in the agent policy', async () => { + const privateLoc = createPrivateLocation('priv-loc-1', 'agent-policy-1'); + const so = createMonitorSO('mon-1', { + locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], + }); + + mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + + const expectedPolicyId = 'mon-1-priv-loc-1'; + const packagePolicy = createPackagePolicy(expectedPolicyId, ['agent-policy-1']); + const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + const fleetGetAgentStatusForAgentPolicy = jest + .fn() + .mockResolvedValue({ all: 0, active: 0, online: 0 }); + + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + fleetGetAgentStatusForAgentPolicy, + }); + + const result = await api.getHealth(['mon-1']); + + const locStatus = result.monitors[0].privateLocations[0]; + expect(locStatus.status).toBe(PrivateLocationHealthStatusValue.MissingAgents); + expect(locStatus.reason).toBeDefined(); + expect(result.monitors[0].isHealthy).toBe(false); + }); + }); + + describe('unhealthy agent', () => { + it('returns UnhealthyAgent when agents are enrolled but none are online', async () => { + const privateLoc = createPrivateLocation('priv-loc-1', 'agent-policy-1'); + const so = createMonitorSO('mon-1', { + locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], + }); + + mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + + const expectedPolicyId = 'mon-1-priv-loc-1'; + const packagePolicy = createPackagePolicy(expectedPolicyId, ['agent-policy-1']); + const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + const fleetGetAgentStatusForAgentPolicy = jest + .fn() + .mockResolvedValue({ all: 2, active: 2, online: 0 }); + + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + fleetGetAgentStatusForAgentPolicy, + }); + + const result = await api.getHealth(['mon-1']); + + const locStatus = result.monitors[0].privateLocations[0]; + expect(locStatus.status).toBe(PrivateLocationHealthStatusValue.UnhealthyAgent); + expect(locStatus.reason).toBeDefined(); + expect(result.monitors[0].isHealthy).toBe(false); + }); + + it('returns Healthy when at least one agent is online', async () => { + const privateLoc = createPrivateLocation('priv-loc-1', 'agent-policy-1'); + const so = createMonitorSO('mon-1', { + locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], + }); + + mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + + const expectedPolicyId = 'mon-1-priv-loc-1'; + const packagePolicy = createPackagePolicy(expectedPolicyId, ['agent-policy-1']); + const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + const fleetGetAgentStatusForAgentPolicy = jest + .fn() + .mockResolvedValue({ all: 3, active: 3, online: 1 }); + + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + fleetGetAgentStatusForAgentPolicy, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.monitors[0].privateLocations[0].status).toBe( + PrivateLocationHealthStatusValue.Healthy + ); + expect(result.monitors[0].isHealthy).toBe(true); + }); + }); + describe('healthy status has no reason field', () => { it('omits reason for healthy locations', async () => { const privateLoc = createPrivateLocation('priv-loc-1', 'agent-policy-1'); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.ts b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.ts index 7809f4cb20abc..e91b6507e42ac 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.ts @@ -33,6 +33,10 @@ const STATUS_REASONS: Record< 'The agent policy referenced by this private location no longer exists.', [PrivateLocationHealthStatusValue.MissingLocation]: 'The monitor references a private location that no longer exists.', + [PrivateLocationHealthStatusValue.MissingAgents]: + 'No Fleet agents are enrolled in the agent policy for this private location.', + [PrivateLocationHealthStatusValue.UnhealthyAgent]: + 'All Fleet agents enrolled in the agent policy for this private location are unhealthy or offline.', }; interface FoundMonitor { @@ -68,12 +72,14 @@ export class MonitorIntegrationHealthApi { const referencedAgentPolicyIds = [ ...new Set(allPrivateLocations.map((loc) => loc.agentPolicyId)), ]; - const [existingPackagePoliciesMap, existingAgentPoliciesMap] = await Promise.all([ - this.getExistingPackagePoliciesMap( - this.getExpectedPackagePolicyIds(foundMonitors, privateLocationAPI, allSpaces) - ), - this.getExistingAgentPoliciesMap(referencedAgentPolicyIds), - ]); + const [existingPackagePoliciesMap, existingAgentPoliciesMap, agentStatusMap] = + await Promise.all([ + this.getExistingPackagePoliciesMap( + this.getExpectedPackagePolicyIds(foundMonitors, privateLocationAPI, allSpaces) + ), + this.getExistingAgentPoliciesMap(referencedAgentPolicyIds), + this.getAgentStatusMap(referencedAgentPolicyIds), + ]); const existingPoliciesArray = [...existingPackagePoliciesMap.values()]; @@ -85,7 +91,7 @@ export class MonitorIntegrationHealthApi { // Only the first matching status is returned per location — downstream issues // are moot when a more fundamental problem exists. // - // Priority: missing_location > missing_agent_policy > missing_package_policy > healthy + // Priority: missing_location > missing_agent_policy > missing_package_policy > missing_agents > unhealthy_agent > healthy const locationStatuses: PrivateLocationHealthStatus[] = privateLocations.map((loc) => { const existingPrivateLocation = allPrivateLocationsMap.get(loc.id); const newFormatPolicyId = privateLocationAPI.getPolicyId( @@ -133,6 +139,28 @@ export class MonitorIntegrationHealthApi { const resolvedPolicyId = hasNewFormatPolicyId ? newFormatPolicyId : legacyPolicyIds[0]; const expectedAgentPolicyId = existingPrivateLocation.agentPolicyId; + const agentStatus = agentStatusMap.get(expectedAgentPolicyId); + if (agentStatus !== undefined) { + if (agentStatus.total === 0) { + return MonitorIntegrationHealthApi.buildLocationStatus( + loc.id, + existingPrivateLocation.label, + PrivateLocationHealthStatusValue.MissingAgents, + resolvedPolicyId, + expectedAgentPolicyId + ); + } + if (agentStatus.online === 0) { + return MonitorIntegrationHealthApi.buildLocationStatus( + loc.id, + existingPrivateLocation.label, + PrivateLocationHealthStatusValue.UnhealthyAgent, + resolvedPolicyId, + expectedAgentPolicyId + ); + } + } + return MonitorIntegrationHealthApi.buildLocationStatus( loc.id, existingPrivateLocation.label, @@ -239,6 +267,26 @@ export class MonitorIntegrationHealthApi { return new Map((existingAgentPolicies ?? []).map((policy) => [policy.id, policy])); } + private async getAgentStatusMap( + agentPolicyIds: string[] + ): Promise> { + if (agentPolicyIds.length === 0) { + return new Map(); + } + + const entries = await Promise.all( + agentPolicyIds.map(async (policyId) => { + const status = + await this.server.fleet.agentService.asInternalUser.getAgentStatusForAgentPolicy( + policyId + ); + return [policyId, { total: status.active, online: status.online }] as const; + }) + ); + + return new Map(entries); + } + private static buildLocationStatus( locationId: string, locationLabel: string,