diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/use_monitor_integration_health.test.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/use_monitor_integration_health.test.ts index e56c80f5ee8a9..9476fec891a2e 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/use_monitor_integration_health.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/use_monitor_integration_health.test.ts @@ -16,6 +16,10 @@ jest.mock('react-redux', () => ({ useDispatch: jest.fn(), })); +jest.mock('../../../contexts', () => ({ + useSyntheticsRefreshContext: jest.fn().mockReturnValue({ lastRefresh: 0 }), +})); + jest.mock('../../../state/monitor_management/api', () => ({ resetMonitorAPI: jest.fn(), resetMonitorBulkAPI: jest.fn(), @@ -91,6 +95,23 @@ describe('useMonitorIntegrationHealth', () => { (reactRedux.useDispatch as jest.Mock).mockReturnValue(dispatchSpy); }); + it('does not re-fetch health when configIds is a new array reference with the same ids', () => { + setupSelectors({ monitors: [unhealthyMonitor], errors: [] }); + + const { rerender } = renderHook( + ({ configIds }: { configIds: string[] }) => useMonitorIntegrationHealth({ configIds }), + { initialProps: { configIds: ['mon-2'] } } + ); + + rerender({ configIds: ['mon-2'] }); + rerender({ configIds: ['mon-2'] }); + + const healthDispatches = dispatchSpy.mock.calls.filter( + ([action]: [{ type: string }]) => action.type === '[MONITOR HEALTH] GET' + ); + expect(healthDispatches).toHaveLength(1); + }); + describe('status helpers', () => { it('isUnhealthy returns true for unhealthy monitors', () => { setupSelectors({ monitors: [healthyMonitor, unhealthyMonitor], errors: [] }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/use_monitor_integration_health.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/use_monitor_integration_health.ts index 31f7aa3b95a46..4579cce9bf8a9 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/use_monitor_integration_health.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/use_monitor_integration_health.ts @@ -15,6 +15,7 @@ import { import { ConfigKey, PrivateLocationHealthStatusValue, + type EncryptedSyntheticsSavedMonitor, } from '../../../../../../common/runtime_types'; import { fetchMonitorHealthAction, selectMonitorHealth } from '../../../state/monitor_health'; import { resetMonitorAPI, resetMonitorBulkAPI } from '../../../state/monitor_management/api'; @@ -35,6 +36,29 @@ interface UseMonitorIntegrationHealthOptions { configIds?: string[]; } +const getPrivateLocationMonitorIds = (monitors: EncryptedSyntheticsSavedMonitor[]): string[] => + monitors + .filter((m) => (m[ConfigKey.LOCATIONS] ?? []).some((loc) => !loc.isServiceManaged)) + .map((m) => m[ConfigKey.CONFIG_ID]); + +const getMonitorIdsFetchKey = ({ + explicitConfigIdsKey, + listLoaded, + listMonitors, +}: { + explicitConfigIdsKey: string | undefined; + listLoaded: boolean; + listMonitors: EncryptedSyntheticsSavedMonitor[]; +}): string => { + if (explicitConfigIdsKey !== undefined) { + return explicitConfigIdsKey; + } + if (!listLoaded) { + return ''; + } + return getPrivateLocationMonitorIds(listMonitors).join(','); +}; + interface UseMonitorIntegrationHealthReturn { statuses: Map; loading: boolean; @@ -73,22 +97,28 @@ export const useMonitorIntegrationHealth = ( } }, [dispatch, configIds, listLoaded, listLoading]); - const monitorIdsToFetch = useMemo(() => { - if (configIds) { - return configIds; - } - if (!listLoaded) { - return []; - } - return listMonitors - .filter((m) => (m[ConfigKey.LOCATIONS] ?? []).some((loc) => !loc.isServiceManaged)) - .map((m) => m[ConfigKey.CONFIG_ID]); - }, [configIds, listLoaded, listMonitors]); + // Compare by joined ids — callers often pass inline arrays (e.g. [configId]). + const explicitConfigIdsKey = configIds?.join(','); + + const monitorIdsFetchKey = useMemo( + () => + getMonitorIdsFetchKey({ + explicitConfigIdsKey, + listLoaded, + listMonitors, + }), + [explicitConfigIdsKey, listLoaded, listMonitors] + ); + + const monitorIdsToFetch = useMemo( + () => (monitorIdsFetchKey ? monitorIdsFetchKey.split(',') : []), + [monitorIdsFetchKey] + ); useEffect(() => { if (monitorIdsToFetch.length === 0) return; dispatch(fetchMonitorHealthAction.get(monitorIdsToFetch)); - }, [dispatch, monitorIdsToFetch, lastRefresh]); + }, [dispatch, lastRefresh, monitorIdsToFetch]); const statuses = useMemo(() => { const map = new Map(); 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 418795398364b..123c5084d9081 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 @@ -39,6 +39,7 @@ const createMonitorSO = ( opts: { name?: string; origin?: string; + monitorQueryId?: string; locations?: Array<{ id: string; label?: string; isServiceManaged: boolean }>; } = {} ): SavedObject => @@ -47,6 +48,7 @@ const createMonitorSO = ( attributes: { [ConfigKey.NAME]: opts.name ?? `Monitor ${id}`, [ConfigKey.MONITOR_SOURCE_TYPE]: opts.origin ?? SourceType.UI, + [ConfigKey.MONITOR_QUERY_ID]: opts.monitorQueryId ?? id, [ConfigKey.LOCATIONS]: opts.locations ?? [], }, } as unknown as SavedObject); @@ -469,6 +471,45 @@ describe('MonitorIntegrationHealthApi', () => { ); expect(result.monitors[0].privateLocations[0].packagePolicyId).toBe(expectedPolicyId); }); + + it('uses MONITOR_QUERY_ID when it differs from the saved object id', async () => { + const privateLoc = createPrivateLocation('priv-loc-1', 'agent-policy-1'); + const monitorQueryId = 'journey-project-default'; + const so = createMonitorSO('so-uuid', { + origin: SourceType.PROJECT, + monitorQueryId, + locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], + }); + + mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + + const expectedPolicyId = `${monitorQueryId}-priv-loc-1`; + const wrongPolicyId = `so-uuid-priv-loc-1`; + const packagePolicy = createPackagePolicy(expectedPolicyId, ['agent-policy-1']); + const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + }); + + const result = await api.getHealth(['so-uuid']); + + expect(fleetGetByIDs).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([expectedPolicyId]), + expect.anything() + ); + expect(fleetGetByIDs).not.toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([wrongPolicyId]), + expect.anything() + ); + expect(result.monitors[0].privateLocations[0].status).toBe( + PrivateLocationHealthStatusValue.Healthy + ); + expect(result.monitors[0].privateLocations[0].packagePolicyId).toBe(expectedPolicyId); + }); }); describe('multiple monitors and locations', () => { 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 e91b6507e42ac..c7fa60b6af739 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 @@ -52,6 +52,18 @@ export class MonitorIntegrationHealthApi { private readonly spaceId: string ) {} + /** + * Returns the monitor id used to look up Fleet package policies for this monitor. + * Prefers `MONITOR_QUERY_ID` when present; otherwise falls back to the saved object id. + * Project monitors store a journey-based id in `MONITOR_QUERY_ID` (e.g. `journey-project-namespace`), + * which differs from `so.id`. + * + * @param so Saved object for the synthetics monitor. + */ + private static getMonitorPolicyId(so: SavedObject): string { + return so.attributes[ConfigKey.MONITOR_QUERY_ID] || so.id; + } + async getHealth(monitorIds: string[]): Promise { const { foundMonitors, errors } = await this.fetchMonitors(monitorIds); @@ -86,6 +98,11 @@ export class MonitorIntegrationHealthApi { const monitors: MonitorHealthStatus[] = foundMonitors.map(({ so }) => { const locations = so.attributes[ConfigKey.LOCATIONS] ?? []; const privateLocations = locations.filter((loc) => !loc.isServiceManaged); + const monitorPolicyId = MonitorIntegrationHealthApi.getMonitorPolicyId(so); + const policyConfig = { + origin: so.attributes[ConfigKey.MONITOR_SOURCE_TYPE], + id: monitorPolicyId, + }; // Status checks are ordered by root-cause severity (most fundamental first). // Only the first matching status is returned per location — downstream issues @@ -94,10 +111,7 @@ export class MonitorIntegrationHealthApi { // 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( - { origin: so.attributes[ConfigKey.MONITOR_SOURCE_TYPE], id: so.id }, - loc.id - ); + const newFormatPolicyId = privateLocationAPI.getPolicyId(policyConfig, loc.id); if (!existingPrivateLocation) { return MonitorIntegrationHealthApi.buildLocationStatus( @@ -120,7 +134,7 @@ export class MonitorIntegrationHealthApi { const { hasNewFormatPolicyId, hasAnyLegacyPolicyId, legacyPolicyIds } = privateLocationAPI.getPolicyIdFormatInfo( - { id: so.id }, + { id: monitorPolicyId }, loc.id, existingPoliciesArray, allSpaces @@ -218,14 +232,14 @@ export class MonitorIntegrationHealthApi { for (const { so } of foundMonitors) { const locations = so.attributes[ConfigKey.LOCATIONS] ?? []; const privateLocations = locations.filter((loc) => !loc.isServiceManaged); + const monitorPolicyId = MonitorIntegrationHealthApi.getMonitorPolicyId(so); + const policyConfig = { + origin: so.attributes[ConfigKey.MONITOR_SOURCE_TYPE], + id: monitorPolicyId, + }; for (const loc of privateLocations) { - ids.add( - privateLocationAPI.getPolicyId( - { origin: so.attributes[ConfigKey.MONITOR_SOURCE_TYPE], id: so.id }, - loc.id - ) - ); + ids.add(privateLocationAPI.getPolicyId(policyConfig, loc.id)); for (const legacyId of privateLocationAPI.getLegacyPolicyIdsForAllSpaces( so.id, loc.id,