diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index 6efd5f2a390fd..6cfbe121351cb 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -41776,7 +41776,6 @@ "xpack.synthetics.monitorManagement.createLocationMonitors": "Überwachen erstellen", "xpack.synthetics.monitorManagement.createMonitorLabel": "Überwachen erstellen", "xpack.synthetics.monitorManagement.createPrivateLocations": "Privaten Standort erstellen", - "xpack.synthetics.monitorManagement.deleteLocation": "Standort löschen", "xpack.synthetics.monitorManagement.deleteLocationLabel": "Standort löschen", "xpack.synthetics.monitorManagement.deleteLocationName": "Möchten Sie „{location}“ löschen?", "xpack.synthetics.monitorManagement.deleteMonitorNameLabel": "Möchten Sie {monitorCount, number} ausgewählte {monitorCount, plural, one {monitor} other {Monitors}} löschen?", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 3515848aa5f58..3ab6e513d03a3 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -42206,8 +42206,7 @@ "xpack.synthetics.monitorManagement.createLocationMonitors": "Créer le moniteur", "xpack.synthetics.monitorManagement.createMonitorLabel": "Créer le moniteur", "xpack.synthetics.monitorManagement.createPrivateLocations": "Créer un emplacement privé", - "xpack.synthetics.monitorManagement.deleteLocation": "Supprimer l’emplacement", - "xpack.synthetics.monitorManagement.deleteLocationLabel": "Supprimer l'emplacement", + "xpack.synthetics.monitorManagement.deleteLocationLabel": "Supprimer l’emplacement", "xpack.synthetics.monitorManagement.deleteLocationName": "Supprimer \"{location}\"", "xpack.synthetics.monitorManagement.deleteMonitorNameLabel": "Supprimer {monitorCount, number} {monitorCount, plural, one {moniteur sélectionné} other {moniteurs sélectionnés}} ?", "xpack.synthetics.monitorManagement.disabled.label": "Désactivé", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index b4cf601d0d373..0c6eed0b6803f 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -42268,7 +42268,6 @@ "xpack.synthetics.monitorManagement.createLocationMonitors": "監視の作成", "xpack.synthetics.monitorManagement.createMonitorLabel": "監視の作成", "xpack.synthetics.monitorManagement.createPrivateLocations": "非公開の場所を作成", - "xpack.synthetics.monitorManagement.deleteLocation": "場所を削除", "xpack.synthetics.monitorManagement.deleteLocationLabel": "場所を削除", "xpack.synthetics.monitorManagement.deleteLocationName": "「{location}」を削除", "xpack.synthetics.monitorManagement.deleteMonitorNameLabel": "{monitorCount, number}個の選択した{monitorCount, plural, one {monitor} other {モニター}}を削除しますか?", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index e445ee58b041b..15b113f699d65 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -42246,7 +42246,6 @@ "xpack.synthetics.monitorManagement.createLocationMonitors": "创建监测", "xpack.synthetics.monitorManagement.createMonitorLabel": "创建监测", "xpack.synthetics.monitorManagement.createPrivateLocations": "创建专用位置", - "xpack.synthetics.monitorManagement.deleteLocation": "删除位置", "xpack.synthetics.monitorManagement.deleteLocationLabel": "删除位置", "xpack.synthetics.monitorManagement.deleteLocationName": "删除“{location}”", "xpack.synthetics.monitorManagement.deleteMonitorNameLabel": "删除 {monitorCount, number} 个选定的{monitorCount, plural, one {监测} other {监测}}?", diff --git a/x-pack/solutions/observability/plugins/synthetics/common/constants/synthetics/rest_api.ts b/x-pack/solutions/observability/plugins/synthetics/common/constants/synthetics/rest_api.ts index dfa08c8e6f559..0bd50193f518c 100644 --- a/x-pack/solutions/observability/plugins/synthetics/common/constants/synthetics/rest_api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/common/constants/synthetics/rest_api.ts @@ -30,6 +30,8 @@ export enum SYNTHETICS_API_URLS { SERVICE_ALLOWED = '/internal/synthetics/service/allowed', SYNTHETICS_PROJECT_APIKEY = '/internal/synthetics/service/api_key', SYNTHETICS_HAS_INTEGRATION_MONITORS = '/internal/synthetics/fleet/has_integration_monitors', + SYNTHETICS_MONITORS_HEALTH = '/internal/synthetics/monitors/_health', + SYNTHETICS_MONITOR_HEALTH = '/internal/synthetics/monitors/{monitorId}/_health', PRIVATE_LOCATIONS_CLEANUP = `/internal/synthetics/private_locations/_cleanup`, SYNC_GLOBAL_PARAMS = `/internal/synthetics/sync_global_params`, SYNC_GLOBAL_PARAMS_SETTINGS = `/internal/synthetics/sync_global_params/_settings`, diff --git a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/index.ts b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/index.ts index b4b0e6999c9fc..74af253895000 100644 --- a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/index.ts +++ b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/index.ts @@ -14,3 +14,4 @@ export * from './snapshot'; export * from './network_events'; export * from './monitor_management'; export * from './monitor_management/synthetics_private_locations'; +export * from './monitor_health'; 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 new file mode 100644 index 0000000000000..202497acd3873 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_health.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum PrivateLocationHealthStatusValue { + Healthy = 'healthy', + MissingPackagePolicy = 'missing_package_policy', + MissingAgentPolicy = 'missing_agent_policy', + MissingLocation = 'missing_location', +} + +export interface PrivateLocationHealthStatus { + locationId: string; + locationLabel: string; + status: PrivateLocationHealthStatusValue; + packagePolicyId: string; + agentPolicyId?: string; + reason?: string; +} + +export interface MonitorHealthStatus { + configId: string; + monitorName: string; + isHealthy: boolean; + privateLocations: PrivateLocationHealthStatus[]; +} + +export interface MonitorHealthError { + configId: string; + message: string; + statusCode: number; +} + +export interface MonitorsHealthResponse { + monitors: MonitorHealthStatus[]; + errors: MonitorHealthError[]; +} diff --git a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_management/state.ts b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_management/state.ts index 35f51752018f0..60c7ec4a6eb83 100644 --- a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_management/state.ts +++ b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_management/state.ts @@ -24,6 +24,7 @@ const FetchMonitorQueryArgsCommon = { projects: t.array(t.string), schedules: t.array(t.string), monitorQueryIds: t.array(t.string), + configIds: t.array(t.string), sortField: t.string, sortOrder: t.union([t.literal('desc'), t.literal('asc')]), showFromAllSpaces: t.boolean, 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 new file mode 100644 index 0000000000000..b40588615f80c --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/status_labels.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { PrivateLocationHealthStatusValue } from '../../../../../../common/runtime_types'; + +export const STATUS_LABELS: Record< + Exclude, + string +> = { + [PrivateLocationHealthStatusValue.MissingPackagePolicy]: i18n.translate( + 'xpack.synthetics.monitorHealth.status.missingPackagePolicy', + { + defaultMessage: + 'The Fleet package policy for this monitor and private location pair does not exist.', + } + ), + [PrivateLocationHealthStatusValue.MissingAgentPolicy]: i18n.translate( + 'xpack.synthetics.monitorHealth.status.missingAgentPolicy', + { + defaultMessage: 'The agent policy referenced by this private location no longer exists.', + } + ), + [PrivateLocationHealthStatusValue.MissingLocation]: i18n.translate( + 'xpack.synthetics.monitorHealth.status.missingLocation', + { + defaultMessage: 'The monitor references a private location that no longer exists.', + } + ), +}; + +export const getStatusLabel = (status: PrivateLocationHealthStatusValue): string | undefined => { + if (status === PrivateLocationHealthStatusValue.Healthy) return undefined; + return STATUS_LABELS[status]; +}; + +export const RESET_FIXABLE_STATUSES = new Set([ + PrivateLocationHealthStatusValue.MissingPackagePolicy, +]); + +export const isFixableByResetStatus = (status: PrivateLocationHealthStatusValue): boolean => + RESET_FIXABLE_STATUSES.has(status); 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 new file mode 100644 index 0000000000000..e56c80f5ee8a9 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/use_monitor_integration_health.test.ts @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react'; +import * as reactRedux from 'react-redux'; +import { PrivateLocationHealthStatusValue } from '../../../../../../common/runtime_types'; +import { useMonitorIntegrationHealth } from './use_monitor_integration_health'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../state/monitor_management/api', () => ({ + resetMonitorAPI: jest.fn(), + resetMonitorBulkAPI: jest.fn(), +})); + +import { resetMonitorAPI, resetMonitorBulkAPI } from '../../../state/monitor_management/api'; + +const mockedResetMonitorAPI = resetMonitorAPI as jest.MockedFunction; +const mockedResetMonitorBulkAPI = resetMonitorBulkAPI as jest.MockedFunction< + typeof resetMonitorBulkAPI +>; + +const healthyMonitor = { + configId: 'mon-1', + monitorName: 'Monitor 1', + isHealthy: true, + privateLocations: [ + { + locationId: 'loc-1', + locationLabel: 'Location 1', + status: PrivateLocationHealthStatusValue.Healthy, + packagePolicyId: 'mon-1-loc-1', + }, + ], +}; + +const unhealthyMonitor = { + configId: 'mon-2', + monitorName: 'Monitor 2', + isHealthy: false, + privateLocations: [ + { + locationId: 'loc-1', + locationLabel: 'Location 1', + status: PrivateLocationHealthStatusValue.MissingPackagePolicy, + packagePolicyId: 'mon-2-loc-1', + reason: 'Missing', + }, + { + locationId: 'loc-2', + locationLabel: 'Location 2', + status: PrivateLocationHealthStatusValue.Healthy, + packagePolicyId: 'mon-2-loc-2', + }, + ], +}; + +const setupSelectors = (healthData: { monitors: (typeof healthyMonitor)[]; errors: unknown[] }) => { + (reactRedux.useSelector as jest.Mock).mockImplementation((selector: any) => { + const fakeState = { + monitorList: { + data: { monitors: [] }, + loaded: true, + loading: false, + }, + monitorHealth: { + data: healthData, + loading: false, + loaded: true, + error: null, + }, + }; + return selector(fakeState); + }); +}; + +describe('useMonitorIntegrationHealth', () => { + let dispatchSpy: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + dispatchSpy = jest.fn(); + (reactRedux.useDispatch as jest.Mock).mockReturnValue(dispatchSpy); + }); + + describe('status helpers', () => { + it('isUnhealthy returns true for unhealthy monitors', () => { + setupSelectors({ monitors: [healthyMonitor, unhealthyMonitor], errors: [] }); + + const { result } = renderHook(() => + useMonitorIntegrationHealth({ configIds: ['mon-1', 'mon-2'] }) + ); + + expect(result.current.isUnhealthy('mon-1')).toBe(false); + expect(result.current.isUnhealthy('mon-2')).toBe(true); + expect(result.current.isUnhealthy('non-existent')).toBe(false); + }); + + it('getUnhealthyLocationStatuses returns only unhealthy locations', () => { + setupSelectors({ monitors: [unhealthyMonitor], errors: [] }); + + const { result } = renderHook(() => useMonitorIntegrationHealth({ configIds: ['mon-2'] })); + + const statuses = result.current.getUnhealthyLocationStatuses('mon-2'); + expect(statuses).toHaveLength(1); + expect(statuses[0].locationId).toBe('loc-1'); + expect(statuses[0].status).toBe(PrivateLocationHealthStatusValue.MissingPackagePolicy); + }); + + it('getUnhealthyMonitorCountForLocation counts monitors with unhealthy status at that location', () => { + setupSelectors({ monitors: [healthyMonitor, unhealthyMonitor], errors: [] }); + + const { result } = renderHook(() => + useMonitorIntegrationHealth({ configIds: ['mon-1', 'mon-2'] }) + ); + + expect(result.current.getUnhealthyMonitorCountForLocation('loc-1')).toBe(1); + expect(result.current.getUnhealthyMonitorCountForLocation('loc-2')).toBe(0); + }); + + it('getUnhealthyConfigIdsForLocation returns config IDs of unhealthy monitors', () => { + setupSelectors({ monitors: [healthyMonitor, unhealthyMonitor], errors: [] }); + + const { result } = renderHook(() => + useMonitorIntegrationHealth({ configIds: ['mon-1', 'mon-2'] }) + ); + + expect(result.current.getUnhealthyConfigIdsForLocation('loc-1')).toEqual(['mon-2']); + }); + }); + + describe('resetMonitor', () => { + it('calls resetMonitorAPI and re-fetches health', async () => { + setupSelectors({ monitors: [unhealthyMonitor], errors: [] }); + mockedResetMonitorAPI.mockResolvedValue({ id: 'mon-2', reset: true }); + + const { result } = renderHook(() => useMonitorIntegrationHealth({ configIds: ['mon-2'] })); + + let resetResult: { error?: Error } | undefined; + await act(async () => { + resetResult = await result.current.resetMonitor('mon-2'); + }); + + expect(resetResult).toEqual({}); + expect(mockedResetMonitorAPI).toHaveBeenCalledWith({ id: 'mon-2' }); + expect(result.current.isResetting).toBe(false); + const healthDispatches = dispatchSpy.mock.calls.filter( + ([action]: any) => action.type === '[MONITOR HEALTH] GET' + ); + expect(healthDispatches.length).toBeGreaterThanOrEqual(2); + }); + + it('returns error and sets isResetting to false on API failure', async () => { + setupSelectors({ monitors: [unhealthyMonitor], errors: [] }); + mockedResetMonitorAPI.mockRejectedValue(new Error('Server error')); + + const { result } = renderHook(() => useMonitorIntegrationHealth({ configIds: ['mon-2'] })); + + let resetResult: { error?: Error } | undefined; + await act(async () => { + resetResult = await result.current.resetMonitor('mon-2'); + }); + + expect(resetResult?.error).toBeInstanceOf(Error); + expect(resetResult?.error?.message).toBe('Server error'); + expect(result.current.isResetting).toBe(false); + }); + }); + + describe('resetMonitors (bulk)', () => { + it('returns error and does not refetch when a result item has reset: false', async () => { + setupSelectors({ monitors: [unhealthyMonitor], errors: [] }); + mockedResetMonitorBulkAPI.mockResolvedValue({ + result: [{ id: 'mon-2', reset: false, error: 'fleet error' }], + }); + + const { result } = renderHook(() => useMonitorIntegrationHealth({ configIds: ['mon-2'] })); + + let resetResult: { error?: Error } | undefined; + await act(async () => { + resetResult = await result.current.resetMonitors(['mon-2']); + }); + + expect(resetResult?.error).toBeInstanceOf(Error); + expect(result.current.isResetting).toBe(false); + const healthDispatches = dispatchSpy.mock.calls.filter( + ([action]: any) => action.type === '[MONITOR HEALTH] GET' + ); + expect(healthDispatches.length).toBe(1); // only initial fetch, no refetch + }); + + it('returns error and does not refetch when top-level errors are present', async () => { + setupSelectors({ monitors: [unhealthyMonitor], errors: [] }); + mockedResetMonitorBulkAPI.mockResolvedValue({ + result: [{ id: 'mon-2', reset: false }], + }); + + const { result } = renderHook(() => useMonitorIntegrationHealth({ configIds: ['mon-2'] })); + + let resetResult: { error?: Error } | undefined; + await act(async () => { + resetResult = await result.current.resetMonitors(['mon-2']); + }); + + expect(resetResult?.error).toBeInstanceOf(Error); + expect(result.current.isResetting).toBe(false); + }); + + it('calls resetMonitorBulkAPI and re-fetches health', async () => { + setupSelectors({ monitors: [unhealthyMonitor], errors: [] }); + mockedResetMonitorBulkAPI.mockResolvedValue({ + result: [{ id: 'mon-2', reset: true }], + }); + + const { result } = renderHook(() => useMonitorIntegrationHealth({ configIds: ['mon-2'] })); + + let resetResult: { error?: Error } | undefined; + await act(async () => { + resetResult = await result.current.resetMonitors(['mon-2']); + }); + + expect(resetResult).toEqual({}); + expect(mockedResetMonitorBulkAPI).toHaveBeenCalledWith({ ids: ['mon-2'] }); + expect(result.current.isResetting).toBe(false); + }); + }); +}); 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 new file mode 100644 index 0000000000000..31f7aa3b95a46 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/hooks/use_monitor_integration_health.ts @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + fetchMonitorListAction, + getMonitorListPageStateWithDefaults, + selectMonitorListState, +} from '../../../state'; +import { + ConfigKey, + PrivateLocationHealthStatusValue, +} from '../../../../../../common/runtime_types'; +import { fetchMonitorHealthAction, selectMonitorHealth } from '../../../state/monitor_health'; +import { resetMonitorAPI, resetMonitorBulkAPI } from '../../../state/monitor_management/api'; +import { useSyntheticsRefreshContext } from '../../../contexts'; +import { isFixableByResetStatus } from './status_labels'; + +export interface MonitorIntegrationStatus { + configId: string; + locationId: string; + locationLabel: string; + packagePolicyId: string; + agentPolicyId?: string; + status: PrivateLocationHealthStatusValue; + isUnhealthy: boolean; +} + +interface UseMonitorIntegrationHealthOptions { + configIds?: string[]; +} + +interface UseMonitorIntegrationHealthReturn { + statuses: Map; + loading: boolean; + isResetting: boolean; + resetMonitor: (configId: string) => Promise<{ error?: Error }>; + resetMonitors: (configIds: string[]) => Promise<{ error?: Error }>; + isUnhealthy: (configId: string) => boolean; + isFixableByReset: (configId: string) => boolean; + getUnhealthyLocationStatuses: (configId: string) => MonitorIntegrationStatus[]; + getUnhealthyMonitorCountForLocation: (locationId: string) => number; + getUnhealthyConfigIdsForLocation: (locationId: string) => string[]; + getUnhealthyMonitorsForLocation: ( + locationId: string + ) => Array<{ configId: string; name: string }>; +} + +export const useMonitorIntegrationHealth = ( + options?: UseMonitorIntegrationHealthOptions +): UseMonitorIntegrationHealthReturn => { + const { configIds } = options ?? {}; + const dispatch = useDispatch(); + const [isResetting, setIsResetting] = useState(false); + const { lastRefresh } = useSyntheticsRefreshContext(); + + const { + data: { monitors: listMonitors }, + loaded: listLoaded, + loading: listLoading, + } = useSelector(selectMonitorListState); + + const { data: healthData, loading: healthLoading } = useSelector(selectMonitorHealth); + + useEffect(() => { + if (!configIds && !listLoaded && !listLoading) { + dispatch(fetchMonitorListAction.get(getMonitorListPageStateWithDefaults())); + } + }, [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]); + + useEffect(() => { + if (monitorIdsToFetch.length === 0) return; + dispatch(fetchMonitorHealthAction.get(monitorIdsToFetch)); + }, [dispatch, monitorIdsToFetch, lastRefresh]); + + const statuses = useMemo(() => { + const map = new Map(); + if (!healthData) return map; + + for (const monitor of healthData.monitors) { + const locationStatuses: MonitorIntegrationStatus[] = monitor.privateLocations.map((loc) => ({ + configId: monitor.configId, + locationId: loc.locationId, + locationLabel: loc.locationLabel, + packagePolicyId: loc.packagePolicyId, + agentPolicyId: loc.agentPolicyId, + status: loc.status, + isUnhealthy: loc.status !== PrivateLocationHealthStatusValue.Healthy, + })); + map.set(monitor.configId, locationStatuses); + } + + return map; + }, [healthData]); + + const isUnhealthy = useCallback( + (configId: string): boolean => { + const locationStatuses = statuses.get(configId); + return locationStatuses?.some((s) => s.isUnhealthy) ?? false; + }, + [statuses] + ); + + const isFixableByReset = useCallback( + (configId: string): boolean => { + const locationStatuses = statuses.get(configId); + return locationStatuses?.some((s) => isFixableByResetStatus(s.status)) ?? false; + }, + [statuses] + ); + + const getUnhealthyLocationStatuses = useCallback( + (configId: string): MonitorIntegrationStatus[] => { + const locationStatuses = statuses.get(configId); + return locationStatuses?.filter((s) => s.isUnhealthy) ?? []; + }, + [statuses] + ); + + const getUnhealthyMonitorCountForLocation = useCallback( + (locationId: string): number => { + let count = 0; + for (const locationStatuses of statuses.values()) { + if (locationStatuses.some((s) => s.locationId === locationId && s.isUnhealthy)) count++; + } + return count; + }, + [statuses] + ); + + const getUnhealthyConfigIdsForLocation = useCallback( + (locationId: string): string[] => { + const ids: string[] = []; + for (const entries of statuses.values()) { + if (entries.some((s) => s.locationId === locationId && s.isUnhealthy)) { + ids.push(entries[0].configId); + } + } + return ids; + }, + [statuses] + ); + + const getUnhealthyMonitorsForLocation = useCallback( + (locationId: string): Array<{ configId: string; name: string }> => { + const monitorNameMap = new Map( + listMonitors.map((m) => [m[ConfigKey.CONFIG_ID], m[ConfigKey.NAME]]) + ); + const monitors: Array<{ configId: string; name: string }> = []; + + for (const entries of statuses.values()) { + const entry = entries.find((s) => s.locationId === locationId && s.isUnhealthy); + if (entry) { + monitors.push({ + configId: entry.configId, + name: monitorNameMap.get(entry.configId) || entry.configId, + }); + } + } + + return monitors; + }, + [statuses, listMonitors] + ); + + const refetchHealth = useCallback(() => { + if (monitorIdsToFetch.length > 0) { + dispatch(fetchMonitorHealthAction.get(monitorIdsToFetch)); + } + }, [dispatch, monitorIdsToFetch]); + + const resetMonitor = useCallback( + async (configId: string): Promise<{ error?: Error }> => { + setIsResetting(true); + try { + await resetMonitorAPI({ id: configId }); + refetchHealth(); + return {}; + } catch (err) { + return { error: err instanceof Error ? err : new Error(String(err)) }; + } finally { + setIsResetting(false); + } + }, + [refetchHealth] + ); + + const resetMonitors = useCallback( + async (ids: string[]): Promise<{ error?: Error }> => { + setIsResetting(true); + try { + const response = await resetMonitorBulkAPI({ ids }); + const hasFailures = response.result.some((r) => !r.reset); + if (hasFailures) { + return { error: new Error('Failed to reset one or more monitors') }; + } + refetchHealth(); + return {}; + } catch (err) { + return { error: err instanceof Error ? err : new Error(String(err)) }; + } finally { + setIsResetting(false); + } + }, + [refetchHealth] + ); + + const loading = configIds ? healthLoading : !listLoaded || healthLoading; + + return { + statuses, + loading, + isResetting, + resetMonitor, + resetMonitors, + isUnhealthy, + isFixableByReset, + getUnhealthyLocationStatuses, + getUnhealthyMonitorCountForLocation, + getUnhealthyConfigIdsForLocation, + getUnhealthyMonitorsForLocation, + }; +}; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/missing_integration_callout.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/missing_integration_callout.tsx new file mode 100644 index 0000000000000..b67d225cf0222 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/missing_integration_callout.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { EuiButton, EuiCallOut, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useMonitorIntegrationHealth } from '../../common/hooks/use_monitor_integration_health'; +import { getStatusLabel } from '../../common/hooks/status_labels'; +import { kibanaService } from '../../../../../utils/kibana_service'; + +export const MissingIntegrationCallout = ({ configId }: { configId: string }) => { + const { + isUnhealthy: hasMissingIntegrations, + isFixableByReset, + getUnhealthyLocationStatuses: getMissingStatuses, + resetMonitor, + isResetting, + } = useMonitorIntegrationHealth({ + configIds: [configId], + }); + + const isMissing = hasMissingIntegrations(configId); + const canReset = isFixableByReset(configId); + const missingStatuses = getMissingStatuses(configId); + + const handleReset = useCallback(async () => { + const { error } = await resetMonitor(configId); + if (error) { + kibanaService.toasts.addDanger({ + title: RESET_ERROR_TITLE, + toastLifeTimeMs: 5000, + }); + } else { + kibanaService.toasts.addSuccess({ + title: RESET_SUCCESS_TITLE, + toastLifeTimeMs: 3000, + }); + } + }, [resetMonitor, configId]); + + if (!isMissing) { + return null; + } + + return ( + <> + + {missingStatuses.length > 0 && ( + +
    + {missingStatuses.map((s) => { + const label = getStatusLabel(s.status); + return ( +
  • + {s.locationLabel} + {label ? `: ${label}` : ''} +
  • + ); + })} +
+
+ )} + {canReset && ( + <> + + + {RESET_BUTTON_LABEL} + + + )} +
+ + + ); +}; + +const CALLOUT_TITLE = i18n.translate('xpack.synthetics.missingIntegration.callout.title', { + defaultMessage: 'Missing Fleet integration', +}); + +const RESET_BUTTON_LABEL = i18n.translate('xpack.synthetics.missingIntegration.resetButton', { + defaultMessage: 'Reset monitor', +}); + +const RESET_ERROR_TITLE = i18n.translate('xpack.synthetics.missingIntegration.resetError', { + defaultMessage: 'Failed to reset monitor', +}); + +const RESET_SUCCESS_TITLE = i18n.translate('xpack.synthetics.missingIntegration.resetSuccess', { + defaultMessage: 'Monitor reset successfully', +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx index 8265e3e850e94..e902473c4e182 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx @@ -16,8 +16,10 @@ import { useIsWithinBreakpoints, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useParams } from 'react-router-dom'; import { LoadWhenInView } from '@kbn/observability-shared-plugin/public'; import { MonitorMWsCallout } from '../../common/mws_callout/monitor_mws_callout'; +import { MissingIntegrationCallout } from '../../monitor_add_edit/steps/missing_integration_callout'; import { SummaryPanel } from './summary_panel'; import { useMonitorDetailsPage } from '../use_monitor_details_page'; @@ -33,6 +35,7 @@ import { MonitorPendingWrapper } from '../monitor_pending_wrapper'; import { useMonitorAttachmentConfig } from '../hooks/use_monitor_attachment_config'; export const MonitorSummary = () => { + const { monitorId: configId } = useParams<{ monitorId: string }>(); const { from, to } = useMonitorRangeFrom(); const dateLabel = from === 'now-30d/d' ? LAST_30_DAYS_LABEL : TO_DATE_LABEL; @@ -47,55 +50,58 @@ export const MonitorSummary = () => { } return ( - - - - - - - - - - - - - -

{DURATION_TREND_LABEL}

-
-
- - - {dateLabel} - - -
- -
-
-
- - - - - - - - - - - - - - - - - -
+ <> + + + + + + + + + + + + + + +

{DURATION_TREND_LABEL}

+
+
+ + + {dateLabel} + + +
+ +
+
+
+ + + + + + + + + + + + + + + + + +
+ ); }; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/common/monitor_filters/use_filters.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/common/monitor_filters/use_filters.ts index 35e3a46923935..e36b816b5e364 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/common/monitor_filters/use_filters.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/common/monitor_filters/use_filters.ts @@ -62,6 +62,11 @@ export function useMonitorFiltersState() { }, []); const dispatch = useDispatch(); + + const { configIds } = urlParams; + useEffect(() => { + dispatch(updateManagementPageStateAction({ configIds })); + }, [dispatch, configIds]); const { useLogicalAndFor } = urlParams; useEffect(() => { diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/bulk_operations.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/bulk_operations.tsx index c1f400cde3685..7d32c28c594ab 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/bulk_operations.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/bulk_operations.tsx @@ -7,38 +7,81 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import type { EncryptedSyntheticsSavedMonitor } from '../../../../../../../common/runtime_types'; import { ConfigKey } from '../../../../../../../common/runtime_types'; +import { useMonitorIntegrationHealth } from '../../../common/hooks/use_monitor_integration_health'; export const BulkOperations = ({ selectedItems, setMonitorPendingDeletion, + setMonitorPendingReset, }: { selectedItems: EncryptedSyntheticsSavedMonitor[]; setMonitorPendingDeletion: (val: string[]) => void; + setMonitorPendingReset: (val: { + resetIds: string[]; + skippedMonitors: Array<{ id: string; name: string }>; + }) => void; }) => { + const { isUnhealthy, isFixableByReset } = useMonitorIntegrationHealth(); + const onDeleted = () => { setMonitorPendingDeletion(selectedItems.map((item) => item[ConfigKey.CONFIG_ID])); }; + const selectedConfigIds = selectedItems.map((item) => item[ConfigKey.CONFIG_ID]); + const unhealthyConfigIds = selectedConfigIds.filter((id) => isUnhealthy(id)); + const resetIds = unhealthyConfigIds.filter((id) => isFixableByReset(id)); + const skippedMonitors = selectedItems + .filter((item) => { + const id = item[ConfigKey.CONFIG_ID]; + return isUnhealthy(id) && !isFixableByReset(id); + }) + .map((item) => ({ id: item[ConfigKey.CONFIG_ID], name: item[ConfigKey.NAME] })); + + const onReset = () => { + setMonitorPendingReset({ resetIds, skippedMonitors }); + }; + if (selectedItems.length === 0) { return null; } return ( - - {i18n.translate('xpack.synthetics.bulkOperationPopover.clickMeToLoadButtonLabel', { - defaultMessage: - 'Delete {monitorCount, number} selected {monitorCount, plural, one {monitor} other {monitors}}', - values: { monitorCount: selectedItems.length }, - })} - + + {resetIds.length > 0 && ( + + + {i18n.translate('xpack.synthetics.bulkOperations.resetIntegration', { + defaultMessage: + 'Reset {count, number} {count, plural, one {monitor} other {monitors}}', + values: { count: resetIds.length }, + })} + + + )} + + + {i18n.translate('xpack.synthetics.bulkOperationPopover.clickMeToLoadButtonLabel', { + defaultMessage: + 'Delete {monitorCount, number} selected {monitorCount, plural, one {monitor} other {monitors}}', + values: { monitorCount: selectedItems.length }, + })} + + + ); }; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx index 5405be694b232..7368ae95d5195 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx @@ -6,7 +6,7 @@ */ import type { EuiBasicTableColumn } from '@elastic/eui'; -import { EuiButtonIcon } from '@elastic/eui'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { useHistory } from 'react-router-dom'; @@ -41,15 +41,23 @@ import { MonitorTypeBadge } from '../../../common/components/monitor_type_badge' import { getFrequencyLabel } from './labels'; import { MonitorEnabled } from './monitor_enabled'; import { MonitorLocations } from './monitor_locations'; +import { UnhealthyTooltip } from './unhealthy_tooltip'; export function useMonitorListColumns({ loading, overviewStatus, setMonitorPendingDeletion, + setMonitorPendingReset, + isFixableByReset, }: { loading: boolean; overviewStatus: OverviewStatusState | null; setMonitorPendingDeletion: (configs: string[]) => void; + setMonitorPendingReset: (val: { + resetIds: string[]; + skippedMonitors: Array<{ id: string; name: string }>; + }) => void; + isFixableByReset: (configId: string) => boolean; }): Array> { const history = useHistory(); const { http, spaces } = useKibana().services; @@ -82,9 +90,20 @@ export function useMonitorListColumns({ defaultMessage: 'Monitor', }), sortable: true, - render: (_: string, monitor: EncryptedSyntheticsSavedMonitor) => ( - - ), + render: (_: string, monitor: EncryptedSyntheticsSavedMonitor) => { + const configId = monitor[ConfigKey.CONFIG_ID]; + + return ( + + + + + + + + + ); + }, }, // Only show Project ID column if project monitors are present ...(overviewStatus?.projectMonitorsCount ?? 0 > 0 @@ -298,6 +317,41 @@ export function useMonitorListColumns({ setMonitorPendingDeletion([fields[ConfigKey.CONFIG_ID]]); }, }, + { + 'data-test-subj': 'syntheticsMonitorResetAction', + isPrimary: false, + name: (fields) => ( + + + {labels.RESET_LABEL} + + + ), + description: labels.RESET_LABEL, + icon: 'refresh' as const, + type: 'icon' as const, + color: 'warning' as const, + available: (fields) => isFixableByReset(fields[ConfigKey.CONFIG_ID]), + enabled: (fields) => + canEditSynthetics && + !isActionLoading(fields) && + isServiceAllowed && + isPublicLocationsAllowed(fields), + onClick: (fields) => { + setMonitorPendingReset({ + resetIds: [fields[ConfigKey.CONFIG_ID]], + skippedMonitors: [], + }); + }, + }, { description: labels.DISABLE_STATUS_ALERT, name: (fields) => ( diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/labels.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/labels.tsx index 4d340674b432a..8916dbb91a948 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/labels.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/labels.tsx @@ -77,6 +77,10 @@ export const DELETE_LABEL = i18n.translate('xpack.synthetics.management.deleteLa defaultMessage: 'Delete', }); +export const RESET_LABEL = i18n.translate('xpack.synthetics.management.resetLabel', { + defaultMessage: 'Reset', +}); + export const DELETE_DESCRIPTION_LABEL = i18n.translate( 'xpack.synthetics.management.confirmDescriptionLabel', { diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx index 0fded3261df1f..b55c00abd91f1 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx @@ -15,6 +15,8 @@ import type { SpacesContextProps } from '@kbn/spaces-plugin/public'; import { MonitorListHeader } from './monitor_list_header'; import type { MonitorListSortField } from '../../../../../../../common/runtime_types/monitor_management/sort_field'; import { DeleteMonitor } from './delete_monitor'; +import { ResetMonitorModal } from './reset_monitor_modal'; +import { useMonitorIntegrationHealth } from '../../../common/hooks/use_monitor_integration_health'; import type { IHttpSerializedFetchError } from '../../../../state/utils/http_error'; import type { MonitorListPageState } from '../../../../state'; import type { @@ -51,6 +53,11 @@ export const MonitorList = ({ const isXl = useIsWithinMinBreakpoint('xxl'); const [monitorPendingDeletion, setMonitorPendingDeletion] = useState([]); + const [monitorPendingReset, setMonitorPendingReset] = useState<{ + resetIds: string[]; + skippedMonitors: Array<{ id: string; name: string }>; + } | null>(null); + const { resetMonitors, isFixableByReset } = useMonitorIntegrationHealth(); const handleOnChange = useCallback( ({ @@ -94,6 +101,8 @@ export const MonitorList = ({ loading, overviewStatus, setMonitorPendingDeletion, + setMonitorPendingReset, + isFixableByReset, }); const [selectedItems, setSelectedItems] = useState([]); @@ -125,6 +134,7 @@ export const MonitorList = ({ recordRangeLabel={recordRangeLabel} selectedItems={selectedItems} setMonitorPendingDeletion={setMonitorPendingDeletion} + setMonitorPendingReset={setMonitorPendingReset} /> + {monitorPendingReset !== null && monitorPendingReset.resetIds.length > 0 && ( + setMonitorPendingReset(null)} + resetMonitors={resetMonitors} + /> + )} {monitorPendingDeletion.length > 0 && ( void; + setMonitorPendingReset: (val: { + resetIds: string[]; + skippedMonitors: Array<{ id: string; name: string }>; + }) => void; }) => { return ( @@ -30,6 +35,7 @@ export const MonitorListHeader = ({ diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/reset_monitor_modal.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/reset_monitor_modal.tsx new file mode 100644 index 0000000000000..532e7dd2601d1 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/reset_monitor_modal.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiAccordion, + EuiCallOut, + EuiConfirmModal, + EuiSpacer, + EuiText, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { kibanaService } from '../../../../../../utils/kibana_service'; + +export const ResetMonitorModal = ({ + configIds, + skippedMonitors = [], + onClose, + resetMonitors, +}: { + configIds: string[]; + skippedMonitors?: Array<{ id: string; name: string }>; + onClose: () => void; + resetMonitors: (ids: string[]) => Promise<{ error?: Error }>; +}) => { + const [isResetting, setIsResetting] = useState(false); + const modalTitleId = useGeneratedHtmlId(); + const skippedAccordionId = useGeneratedHtmlId(); + + const handleConfirm = useCallback(async () => { + setIsResetting(true); + const { error } = await resetMonitors(configIds); + setIsResetting(false); + if (!error) { + kibanaService.toasts.addSuccess({ + title: i18n.translate('xpack.synthetics.resetMonitorModal.success', { + defaultMessage: '{count, plural, one {# monitor} other {# monitors}} reset successfully', + values: { count: configIds.length }, + }), + toastLifeTimeMs: 3000, + }); + } else { + kibanaService.toasts.addDanger({ + title: i18n.translate('xpack.synthetics.resetMonitorModal.error', { + defaultMessage: 'Failed to reset monitors', + }), + toastLifeTimeMs: 5000, + }); + } + onClose(); + }, [configIds, onClose, resetMonitors]); + + return ( + + +

+ {i18n.translate('xpack.synthetics.resetMonitorModal.description', { + defaultMessage: + 'This will recreate the Fleet integration for the selected monitors. They should start running again shortly after.', + })} +

+
+ {skippedMonitors.length > 0 && ( + <> + + + +

+ {i18n.translate('xpack.synthetics.resetMonitorModal.skippedWarning.description', { + defaultMessage: + 'These monitors have issues that cannot be fixed by resetting. Check your Fleet agents.', + })} +

+
+ + + +
    + {skippedMonitors.map(({ id, name }) => ( +
  • {name}
  • + ))} +
+
+
+
+ + )} +
+ ); +}; + +const CANCEL_LABEL = i18n.translate('xpack.synthetics.resetMonitorModal.cancel', { + defaultMessage: 'Cancel', +}); + +const CONFIRM_LABEL = i18n.translate('xpack.synthetics.resetMonitorModal.confirm', { + defaultMessage: 'Reset', +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/unhealthy_tooltip.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/unhealthy_tooltip.tsx new file mode 100644 index 0000000000000..e4a63a8c29cf1 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/unhealthy_tooltip.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import React from 'react'; +import { useMonitorIntegrationHealth } from '../../../common/hooks/use_monitor_integration_health'; +import { getStatusLabel } from '../../../common/hooks/status_labels'; + +export const UnhealthyTooltip = ({ configId }: { configId: string }) => { + const { isUnhealthy: isMonitorUnhealthy, getUnhealthyLocationStatuses } = + useMonitorIntegrationHealth(); + + const isUnhealthy = isMonitorUnhealthy(configId); + const unhealthyStatuses = getUnhealthyLocationStatuses(configId); + + const tooltipContent = + unhealthyStatuses.length > 0 ? ( + + {unhealthyStatuses.map((s, index) => ( + + + + + {s.locationLabel} + + {getStatusLabel(s.status) ?? UNHEALTHY_TOOLTIP_BADGE} + {index < unhealthyStatuses.length - 1 && ( + + )} + + + + ))} + + ) : ( + UNHEALTHY_TOOLTIP_BADGE + ); + + if (!isUnhealthy) { + return null; + } + + return ( + + + + ); +}; + +const UNHEALTHY_TOOLTIP_BADGE = i18n.translate( + 'xpack.synthetics.management.monitorList.unhealthyTooltip.badge', + { + defaultMessage: 'Unhealthy', + } +); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/delete_location.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/delete_location.tsx deleted file mode 100644 index cd958e38ae76a..0000000000000 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/delete_location.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState } from 'react'; -import { EuiButtonIcon, EuiConfirmModal, EuiToolTip, useGeneratedHtmlId } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useSyntheticsSettingsContext } from '../../../contexts'; - -export const DeleteLocation = ({ - loading, - id, - label, - locationMonitors, - onDelete, -}: { - id: string; - label: string; - loading?: boolean; - onDelete: (id: string) => void; - locationMonitors: Array<{ id: string; count: number }>; -}) => { - const monCount = locationMonitors?.find((l) => l.id === id)?.count ?? 0; - const canDelete = monCount === 0; - - const { canSave } = useSyntheticsSettingsContext(); - - const [isModalOpen, setIsModalOpen] = useState(false); - - const deleteDisabledReason = i18n.translate( - 'xpack.synthetics.monitorManagement.cannotDelete.description', - { - defaultMessage: `You can't delete this location because it is used in {monCount, number} {monCount, plural,one {monitor} other {monitors}}. - Remove all monitors from this location first.`, - values: { monCount }, - } - ); - - const confirmModalTitleId = useGeneratedHtmlId(); - - const deleteModal = ( - setIsModalOpen(false)} - onConfirm={() => onDelete(id)} - cancelButtonText={CANCEL_LABEL} - confirmButtonText={CONFIRM_LABEL} - buttonColor="danger" - defaultFocusedButton="confirm" - isLoading={loading} - > -

{ARE_YOU_SURE_LABEL}

-
- ); - - return ( - <> - {isModalOpen && deleteModal} - - { - setIsModalOpen(true); - }} - isDisabled={!canDelete || !canSave} - /> - - - ); -}; - -const DELETE_LABEL = i18n.translate('xpack.synthetics.monitorManagement.deleteLocation', { - defaultMessage: 'Delete location', -}); - -const CONFIRM_LABEL = i18n.translate('xpack.synthetics.monitorManagement.deleteLocationLabel', { - defaultMessage: 'Delete location', -}); - -const CANCEL_LABEL = i18n.translate('xpack.synthetics.monitorManagement.cancelLabel', { - defaultMessage: 'Cancel', -}); - -const ARE_YOU_SURE_LABEL = i18n.translate('xpack.synthetics.monitorManagement.areYouSure', { - defaultMessage: 'Are you sure you want to delete this location?', -}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/delete_location_modal.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/delete_location_modal.tsx new file mode 100644 index 0000000000000..7353d7de1a390 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/delete_location_modal.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiConfirmModal, useGeneratedHtmlId } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export const DeleteLocationModal = ({ + locationId, + loading, + label, + onDelete, + onCancel, +}: { + locationId: string; + loading: boolean; + label: string; + onDelete: (id: string) => void; + onCancel: () => void; +}) => { + const confirmModalTitleId = useGeneratedHtmlId(); + + const handleConfirmDelete = () => { + if (locationId) { + onDelete(locationId); + } + }; + + return ( + <> + +

{ARE_YOU_SURE_LABEL}

+
+ + ); +}; + +const CONFIRM_LABEL = i18n.translate('xpack.synthetics.monitorManagement.deleteLocationLabel', { + defaultMessage: 'Delete location', +}); + +const CANCEL_LABEL = i18n.translate('xpack.synthetics.monitorManagement.cancelLabel', { + defaultMessage: 'Cancel', +}); + +const ARE_YOU_SURE_LABEL = i18n.translate('xpack.synthetics.monitorManagement.areYouSure', { + defaultMessage: 'Are you sure you want to delete this location?', +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/locations_table.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/locations_table.tsx index 30d8942690021..f9b06f4b411cb 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/locations_table.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/locations_table.tsx @@ -28,12 +28,16 @@ import { useSyntheticsSettingsContext } from '../../../contexts'; import { PrivateLocationDocsLink, START_ADDING_LOCATIONS_DESCRIPTION } from './empty_locations'; import type { PrivateLocation } from '../../../../../../common/runtime_types'; import { NoPermissionsTooltip } from '../../common/components/permissions'; -import { DeleteLocation } from './delete_location'; import { useLocationMonitors } from './hooks/use_location_monitors'; import { PolicyName } from './policy_name'; import { LOCATION_NAME_LABEL } from './location_form'; import { setIsPrivateLocationFlyoutVisible } from '../../../state/private_locations/actions'; import type { ClientPluginsStart } from '../../../../../plugin'; +import { UnhealthyCountBadge } from './unhealthy_count_badge'; +import { ResetMonitorModal } from '../../monitors_page/management/monitor_list_table/reset_monitor_modal'; +import { useMonitorIntegrationHealth } from '../../common/hooks/use_monitor_integration_health'; +import { isFixableByResetStatus } from '../../common/hooks/status_labels'; +import { DeleteLocationModal } from './delete_location_modal'; interface ListItem extends PrivateLocation { monitors: number; @@ -54,6 +58,14 @@ export const PrivateLocationsTable = ({ const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(10); + const [monitorPendingReset, setMonitorPendingReset] = useState<{ + resetIds: string[]; + skippedMonitors: Array<{ id: string; name: string }>; + } | null>(null); + const { resetMonitors, getUnhealthyLocationStatuses, getUnhealthyMonitorsForLocation } = + useMonitorIntegrationHealth(); + + const [locationPendingDelete, setLocationPendingDelete] = useState(null); const { locationMonitors, loading } = useLocationMonitors(); @@ -77,9 +89,16 @@ export const PrivateLocationsTable = ({ { field: 'monitors', name: MONITORS, - render: (monitors: number, item: ListItem) => ( - - ), + render: (monitors: number, item: ListItem) => { + return ( + + + + + + + ); + }, }, { field: 'agentPolicyId', @@ -126,18 +145,59 @@ export const PrivateLocationsTable = ({ icon: 'pencil', type: 'icon', }, + { + name: RESET_MONITORS_LABEL, + description: RESET_MONITORS_LABEL, + icon: 'refresh', + type: 'icon' as const, + color: 'warning', + isPrimary: false, + 'data-test-subj': 'action-reset', + available: (item: ListItem) => { + const unhealthyMonitors = getUnhealthyMonitorsForLocation(item.id); + return unhealthyMonitors.some((monitor) => { + const locationStatuses = getUnhealthyLocationStatuses(monitor.configId); + const locationStatus = locationStatuses.find((s) => s.locationId === item.id); + return locationStatus != null && isFixableByResetStatus(locationStatus.status); + }); + }, + onClick: (item: ListItem) => { + const unhealthyMonitors = getUnhealthyMonitorsForLocation(item.id); + + const resetIds: string[] = []; + const skippedMonitors: Array<{ id: string; name: string }> = []; + + for (const monitor of unhealthyMonitors) { + const locationStatuses = getUnhealthyLocationStatuses(monitor.configId); + const locationStatus = locationStatuses.find((s) => s.locationId === item.id); + if (locationStatus && isFixableByResetStatus(locationStatus.status)) { + resetIds.push(monitor.configId); + } else { + skippedMonitors.push({ + id: monitor.configId, + name: monitor.name, + }); + } + } + + if (resetIds.length > 0) { + setMonitorPendingReset({ resetIds, skippedMonitors }); + } + }, + }, { name: DELETE_LOCATION, - description: DELETE_LOCATION, - render: (item: ListItem) => ( - - ), + description: (item: ListItem) => getDeleteDescription(item.monitors === 0, item.monitors), + icon: 'trash', + type: 'icon' as const, + color: 'danger', + enabled: (item: ListItem) => { + const canDelete = item.monitors === 0; + return canDelete && canSave; + }, + onClick: (item: ListItem) => { + setLocationPendingDelete(item.id); + }, isPrimary: true, 'data-test-subj': 'action-delete', }, @@ -221,6 +281,25 @@ export const PrivateLocationsTable = ({ ], }} /> + {monitorPendingReset && ( + setMonitorPendingReset(null)} + resetMonitors={resetMonitors} + skippedMonitors={monitorPendingReset.skippedMonitors} + /> + )} + {locationPendingDelete && ( + item.id === locationPendingDelete)?.label ?? ''} + locationId={locationPendingDelete} + onDelete={(id) => { + onDelete(id); + }} + onCancel={() => setLocationPendingDelete(null)} + loading={deleteLoading ?? false} + /> + )} ); }; @@ -248,6 +327,14 @@ const DELETE_LOCATION = i18n.translate( } ); +const getDeleteDescription = (canDelete: boolean, monCount: number) => + canDelete + ? DELETE_LOCATION + : i18n.translate('xpack.synthetics.monitorManagement.cannotDelete.description', { + defaultMessage: `You can't delete this location because it is used in {monCount, number} {monCount, plural,one {monitor} other {monitors}}. Remove all monitors from this location first.`, + values: { monCount }, + }); + const EDIT_LOCATION = i18n.translate('xpack.synthetics.settingsRoute.privateLocations.editLabel', { defaultMessage: 'Edit private location', }); @@ -259,3 +346,10 @@ const ADD_LABEL = i18n.translate('xpack.synthetics.monitorManagement.createLocat export const LEARN_MORE = i18n.translate('xpack.synthetics.privateLocations.learnMore.label', { defaultMessage: 'Learn more.', }); + +const RESET_MONITORS_LABEL = i18n.translate( + 'xpack.synthetics.settingsRoute.privateLocations.resetMonitors', + { + defaultMessage: 'Reset monitors', + } +); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/unhealthy_count_badge.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/unhealthy_count_badge.tsx new file mode 100644 index 0000000000000..c87bd06a12cae --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/unhealthy_count_badge.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexItem, EuiPopover, EuiBadge, EuiSpacer, EuiButton, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useMonitorIntegrationHealth } from '../../common/hooks/use_monitor_integration_health'; + +export const UnhealthyCountBadge = ({ item }: { item: { id: string; label: string } }) => { + const { getUnhealthyMonitorCountForLocation, getUnhealthyConfigIdsForLocation } = + useMonitorIntegrationHealth(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const history = useHistory(); + + const unhealthyMonitorCount = getUnhealthyMonitorCountForLocation(item.id); + + if (unhealthyMonitorCount === 0) { + return null; + } + + const unhealthyConfigIds = getUnhealthyConfigIdsForLocation(item.id); + const href = history.createHref({ + pathname: '/monitors', + search: `?locations=${JSON.stringify([item.label])}&configIds=${JSON.stringify( + unhealthyConfigIds + )}`, + }); + + const badge = ( + setIsPopoverOpen((prev) => !prev)} + onClickAriaLabel={UNHEALTHY_MONITORS_ARIA_LABEL} + > + {i18n.translate('xpack.synthetics.privateLocations.missingIntegrations.count', { + defaultMessage: '{count, plural, one {# unhealthy monitor} other {# unhealthy monitors}}', + values: { count: unhealthyMonitorCount }, + })} + + ); + + return ( + + setIsPopoverOpen(false)} + > + + {chunks}, + }} + /> + + + + {VIEW_MONITORS_LABEL} + + + + ); +}; + +const VIEW_MONITORS_LABEL = i18n.translate( + 'xpack.synthetics.privateLocations.missingIntegrations.viewMonitors', + { defaultMessage: 'View monitors' } +); + +const UNHEALTHY_MONITORS_ARIA_LABEL = i18n.translate( + 'xpack.synthetics.privateLocations.missingIntegrations.ariaLabel', + { defaultMessage: 'View unhealthy monitors' } +); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/actions.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/actions.ts new file mode 100644 index 0000000000000..c8ae205dd16bf --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/actions.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createAsyncAction } from '../utils/actions'; +import type { MonitorsHealthResponse } from './models'; + +export const fetchMonitorHealthAction = createAsyncAction( + '[MONITOR HEALTH] GET' +); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/api.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/api.ts new file mode 100644 index 0000000000000..5ad764f891e56 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/api.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SYNTHETICS_API_URLS } from '../../../../../common/constants'; +import { apiService } from '../../../../utils/api_service/api_service'; +import type { MonitorsHealthResponse } from './models'; + +export const fetchMonitorsHealth = async ( + monitorIds: string[] +): Promise => { + return await apiService.post(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_HEALTH, { + monitorIds, + }); +}; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/effects.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/effects.ts new file mode 100644 index 0000000000000..74b55384e5c78 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/effects.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { debounce } from 'redux-saga/effects'; +import { fetchEffectFactory } from '../utils/fetch_effect'; +import { fetchMonitorsHealth } from './api'; +import { fetchMonitorHealthAction } from './actions'; + +export function* fetchMonitorHealthEffect() { + yield debounce( + 100, + fetchMonitorHealthAction.get, + fetchEffectFactory( + fetchMonitorsHealth, + fetchMonitorHealthAction.success, + fetchMonitorHealthAction.fail + ) + ); +} diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/index.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/index.ts new file mode 100644 index 0000000000000..f5489b75a7b15 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/index.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createReducer } from '@reduxjs/toolkit'; +import type { IHttpSerializedFetchError } from '..'; +import type { MonitorsHealthResponse } from './models'; +import { fetchMonitorHealthAction } from './actions'; + +export interface MonitorHealthState { + data: MonitorsHealthResponse | null; + loading: boolean; + loaded: boolean; + error: IHttpSerializedFetchError | null; +} + +const initialState: MonitorHealthState = { + data: null, + loading: false, + loaded: false, + error: null, +}; + +export const monitorHealthReducer = createReducer(initialState, (builder) => { + builder + .addCase(fetchMonitorHealthAction.get, (state) => { + state.loading = true; + state.loaded = false; + state.error = null; + }) + .addCase(fetchMonitorHealthAction.success, (state, action) => { + state.data = action.payload; + state.loading = false; + state.loaded = true; + state.error = null; + }) + .addCase(fetchMonitorHealthAction.fail, (state, action) => { + state.loading = false; + state.error = action.payload; + }); +}); + +export * from './actions'; +export * from './effects'; +export * from './selectors'; +export type * from './models'; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/models.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/models.ts new file mode 100644 index 0000000000000..adb56a779c314 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/models.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + PrivateLocationHealthStatusValue as LocationHealthStatusValue, + type PrivateLocationHealthStatus, + type MonitorHealthStatus, + type MonitorHealthError, + type MonitorsHealthResponse, +} from '../../../../../common/runtime_types'; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/selectors.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/selectors.ts new file mode 100644 index 0000000000000..f73abfcc0008c --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_health/selectors.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SyntheticsAppState } from '../root_reducer'; + +export const selectMonitorHealth = (state: SyntheticsAppState) => state.monitorHealth; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_list/api.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_list/api.ts index 3886fb99c999d..4af11597b92bc 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_list/api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_list/api.ts @@ -34,6 +34,7 @@ function toMonitorManagementListQueryArgs( projects: pageState.projects, schedules: pageState.schedules, monitorQueryIds: pageState.monitorQueryIds, + configIds: pageState.configIds, searchFields: [], internal: true, showFromAllSpaces: pageState.showFromAllSpaces, diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_list/models.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_list/models.ts index 23776ecf262ba..dd343c83e6242 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_list/models.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_list/models.ts @@ -25,6 +25,7 @@ export interface MonitorFilterState { schedules?: string[]; locations?: string[]; monitorQueryIds?: string[]; // Monitor Query IDs + configIds?: string[]; // Config IDs (UUIDs) showFromAllSpaces?: boolean; useLogicalAndFor?: UseLogicalAndField[]; } diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_management/api.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_management/api.ts index 60182b45a1495..d02c4e1a974d7 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_management/api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_management/api.ts @@ -95,7 +95,7 @@ export const resetMonitorAPI = async ({ force?: boolean; }): Promise<{ id: string; reset: boolean } | ServiceLocationErrorsResponse> => { const url = SYNTHETICS_API_URLS.SYNTHETICS_MONITOR_RESET.replace('{monitorId}', id); - return await apiService.post(url, undefined, { force }); + return await apiService.post(url, undefined, undefined, { force }); }; export const resetMonitorBulkAPI = async ({ diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/root_effect.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/root_effect.ts index f493f10fdeeff..ecc98d0aad030 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/root_effect.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/root_effect.ts @@ -47,6 +47,7 @@ import { fetchOverviewStatusEffect } from './overview_status'; import { fetchMonitorStatusHeatmap, quietFetchMonitorStatusHeatmap } from './status_heatmap'; import { fetchOverviewTrendStats, refreshOverviewTrendStats } from './overview/effects'; import { fetchAgentPoliciesEffect } from './agent_policies'; +import { fetchMonitorHealthEffect } from './monitor_health'; export const rootEffect = function* root(): Generator { yield all([ @@ -85,5 +86,6 @@ export const rootEffect = function* root(): Generator { fork(inspectTLSRuleEffect), fork(getMaintenanceWindowsEffect), ...privateLocationsEffects.map((effect) => fork(effect)), + fork(fetchMonitorHealthEffect), ]); }; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts index b6d2462c70d1e..10826569962e1 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts @@ -47,6 +47,8 @@ import type { MonitorStatusHeatmap } from './status_heatmap'; import { monitorStatusHeatmapReducer } from './status_heatmap'; import type { AgentPoliciesState } from './agent_policies'; import { agentPoliciesReducer } from './agent_policies'; +import type { MonitorHealthState } from './monitor_health'; +import { monitorHealthReducer } from './monitor_health'; export interface SyntheticsAppState { agentPolicies: AgentPoliciesState; @@ -69,6 +71,7 @@ export interface SyntheticsAppState { syntheticsEnablement: SyntheticsEnablementState; ui: UiState; maintenanceWindows: MaintenanceWindowsState; + monitorHealth: MonitorHealthState; } export const rootReducer = combineReducers({ @@ -92,4 +95,5 @@ export const rootReducer = combineReducers({ syntheticsEnablement: syntheticsEnablementReducer, ui: uiReducer, maintenanceWindows: maintenanceWindowsReducer, + monitorHealth: monitorHealthReducer, }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts index 1286cc4004bac..d4f54b952e311 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts @@ -164,6 +164,12 @@ export const mockState: SyntheticsAppState = { error: null, }, maintenanceWindows: {}, + monitorHealth: { + data: null, + loading: false, + loaded: false, + error: null, + }, }; function getBrowserJourneyMockSlice() { diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.test.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.test.ts index 10ed699fde970..f121d4f9e98db 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.test.ts @@ -67,6 +67,7 @@ describe('getSupportedUrlParams', () => { query: '', locations: [], monitorTypes: [], + configIds: [], projects: [], schedules: [], tags: [], diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.ts index 341bbb7800aa4..a90ce96538c4d 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/url_params/get_supported_url_params.ts @@ -28,6 +28,7 @@ export interface SyntheticsUrlParams { tags?: string[]; locations?: string[]; monitorTypes?: string[] | string; + configIds?: string[]; status?: string[]; locationId?: string; projects?: string[] | string; @@ -87,6 +88,7 @@ export const getSupportedUrlParams = (params: { query, tags, monitorTypes, + configIds, locations, locationId, projects, @@ -123,6 +125,7 @@ export const getSupportedUrlParams = (params: { query: query || '', tags: parseFilters(tags), monitorTypes: parseFilters(monitorTypes), + configIds: parseFilters(configIds), locations: parseFilters(locations), projects: parseFilters(projects), schedules: parseFilters(schedules), diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/common.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/common.ts index 8d590b6c3ba33..4a0a29aebe56e 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/common.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/common.ts @@ -35,6 +35,7 @@ const CommonQuerySchema = { schedules: StringOrArraySchema, status: StringOrArraySchema, monitorQueryIds: StringOrArraySchema, + configIds: StringOrArraySchema, showFromAllSpaces: schema.maybe(schema.boolean()), useLogicalAndFor: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.oneOf(UseLogicalAndFieldLiterals))]) @@ -96,6 +97,7 @@ export const getMonitorFilters = async ( projects, schedules, monitorQueryIds, + configIds, locations: queryLocations, useLogicalAndFor, } = context.request.query; @@ -109,6 +111,7 @@ export const getMonitorFilters = async ( projects, schedules, monitorQueryIds, + configIds, locations, }, useLogicalAndFor, @@ -243,6 +246,7 @@ export const isMonitorsQueryFiltered = (monitorQuery: MonitorsQuery) => { projects, schedules, monitorQueryIds, + configIds, } = monitorQuery; return ( @@ -254,7 +258,8 @@ export const isMonitorsQueryFiltered = (monitorQuery: MonitorsQuery) => { !!status?.length || !!projects?.length || !!schedules?.length || - !!monitorQueryIds?.length + !!monitorQueryIds?.length || + !!configIds?.length ); }; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts index 593cc2a54c399..56206d05e4855 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts @@ -69,6 +69,8 @@ import { getLocationMonitors } from './settings/private_locations/get_location_m import { addSyntheticsParamsRoute } from './settings/params/add_param'; import { deleteSyntheticsParamsRoute } from './settings/params/delete_param'; import { createOverviewTrendsRoute } from './overview_trends/overview_trends'; +import { getMonitorsHealthRoute } from './monitor_health/get_monitor_health'; +import { getMonitorHealthRoute } from './monitor_health/get_monitor_health_single'; export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ addSyntheticsProjectMonitorRoute, @@ -114,6 +116,8 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ cleanupPrivateLocationRoute, syncParamsSyntheticsParamsRoute, syncParamsSettingsParamsRoute, + getMonitorsHealthRoute, + getMonitorHealthRoute, ]; export const syntheticsAppPublicRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/reset_monitor.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/reset_monitor.ts index 00b102a4772a9..3763d5a7ecb67 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/reset_monitor.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/reset_monitor.ts @@ -42,7 +42,8 @@ export const resetSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => } if (errors && errors.length > 0) { - return response.ok({ + return response.customError({ + statusCode: 500, body: { message: 'error resetting monitor Fleet resources', attributes: { errors }, diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/reset_monitor_api.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/reset_monitor_api.test.ts index 3ee446d1a14a5..1f1c105930bd6 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/reset_monitor_api.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/reset_monitor_api.test.ts @@ -16,6 +16,26 @@ jest.mock('../edit_monitor', () => ({ validatePermissions: jest.fn().mockResolvedValue(null), })); +const mockMonitorPairWithLocations = ( + id: string, + locations: Array<{ id: string; isServiceManaged: boolean }> +) => ({ + decryptedMonitor: { + id, + attributes: { locations, id }, + type: 'synthetics-monitor', + references: [], + }, + normalizedMonitor: { + id, + attributes: { locations, id }, + type: 'synthetics-monitor', + references: [], + updated_at: '2026-01-01T00:00:00Z', + created_at: '2026-01-01T00:00:00Z', + }, +}); + const mockMonitorPair = (id: string) => ({ decryptedMonitor: { id, @@ -39,6 +59,17 @@ const mockMonitorPair = (id: string) => ({ }, }); +const createMockRouteContextWithFleet = (existingAgentPolicyIds: string[]) => { + const base = createMockRouteContext(); + const getByIds = jest.fn().mockResolvedValue(existingAgentPolicyIds.map((id) => ({ id }))); + base.routeContext.server = { + ...base.routeContext.server, + fleet: { agentPolicyService: { getByIds } }, + coreStart: { savedObjects: { createInternalRepository: jest.fn().mockReturnValue({}) } }, + } as any; + return { ...base, mocks: { ...base.mocks, getByIds } }; +}; + const createMockRouteContext = () => { const editMonitors = jest.fn().mockResolvedValue({ failedPolicyUpdates: [], @@ -217,6 +248,56 @@ describe('ResetMonitorAPI', () => { }); }); + describe('location filtering — getLocationIdsWithExistingAgentPolicy', () => { + const { getPrivateLocations } = jest.requireMock( + '../../../synthetics_service/get_private_locations' + ); + + beforeEach(() => { + const { validatePermissions } = jest.requireMock('../edit_monitor'); + validatePermissions.mockResolvedValue(null); + }); + + afterEach(() => { + getPrivateLocations.mockResolvedValue([]); + }); + + it('excludes private locations whose agent policy no longer exists', async () => { + getPrivateLocations.mockResolvedValue([ + { id: 'loc-valid', agentPolicyId: 'ap-1' }, + { id: 'loc-deleted', agentPolicyId: 'ap-2' }, + ]); + const { routeContext, mocks } = createMockRouteContextWithFleet(['ap-1']); + mocks.getDecrypted.mockResolvedValue( + mockMonitorPairWithLocations('mon-1', [ + { id: 'loc-valid', isServiceManaged: false }, + { id: 'loc-deleted', isServiceManaged: false }, + ]) + ); + + const api = new ResetMonitorAPI(routeContext); + await api.execute({ monitorIds: ['mon-1'] }); + + const passedLocations = mocks.editMonitors.mock.calls[0][0][0].monitor.locations; + expect(passedLocations).toHaveLength(1); + expect(passedLocations[0].id).toBe('loc-valid'); + }); + + it('excludes private locations with no agentPolicyId', async () => { + getPrivateLocations.mockResolvedValue([{ id: 'loc-no-policy', agentPolicyId: null }]); + const { routeContext, mocks } = createMockRouteContextWithFleet([]); + mocks.getDecrypted.mockResolvedValue( + mockMonitorPairWithLocations('mon-1', [{ id: 'loc-no-policy', isServiceManaged: false }]) + ); + + const api = new ResetMonitorAPI(routeContext); + await api.execute({ monitorIds: ['mon-1'] }); + + const passedLocations = mocks.editMonitors.mock.calls[0][0][0].monitor.locations; + expect(passedLocations).toHaveLength(0); + }); + }); + describe('empty input', () => { it('returns empty results for empty monitorIds', async () => { const { routeContext, mocks } = createMockRouteContext(); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/reset_monitor_api.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/reset_monitor_api.ts index 17854cb05ff61..abf5bf5f71c91 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/reset_monitor_api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/reset_monitor_api.ts @@ -117,22 +117,57 @@ export class ResetMonitorAPI { if (this.force) { return this.forceReset(monitors, allPrivateLocations, spaceId); } - return this.defaultReset(monitors, allPrivateLocations, spaceId); + + const validLocationIds = await this.getLocationIdsWithExistingAgentPolicy(allPrivateLocations); + return this.defaultReset(monitors, allPrivateLocations, spaceId, validLocationIds); + } + + private async getLocationIdsWithExistingAgentPolicy( + allPrivateLocations: Awaited> + ): Promise> { + const { server } = this.routeContext; + const agentPolicyIds = allPrivateLocations.map((loc) => loc.agentPolicyId).filter(Boolean); + if (agentPolicyIds.length === 0) { + return new Set(); + } + + const internalSoClient = server.coreStart.savedObjects.createInternalRepository(); + const existingPolicies = await server.fleet.agentPolicyService.getByIds( + internalSoClient, + agentPolicyIds, + { ignoreMissing: true } + ); + const existingAgentPolicyIds = new Set(existingPolicies.map((p) => p.id)); + + return new Set( + allPrivateLocations + .filter((loc) => existingAgentPolicyIds.has(loc.agentPolicyId)) + .map((loc) => loc.id) + ); } private async defaultReset( monitors: DecryptedMonitorPair[], allPrivateLocations: Awaited>, - spaceId: string + spaceId: string, + validLocationIds: Set ) { const { syntheticsMonitorClient } = this.routeContext; const { failedPolicyUpdates, publicSyncErrors } = await syntheticsMonitorClient.editMonitors( - monitors.map(({ normalizedMonitor, decryptedMonitor }) => ({ - monitor: normalizedMonitor.attributes as MonitorFields, - id: normalizedMonitor.id, - decryptedPreviousMonitor: decryptedMonitor, - })), + monitors.map(({ normalizedMonitor, decryptedMonitor }) => { + const monitor = normalizedMonitor.attributes as MonitorFields; + return { + monitor: { + ...monitor, + locations: (monitor.locations ?? []).filter( + (loc) => loc.isServiceManaged || validLocationIds.has(loc.id) + ), + }, + id: normalizedMonitor.id, + decryptedPreviousMonitor: decryptedMonitor, + }; + }), allPrivateLocations, spaceId ); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_health/get_monitor_health.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_health/get_monitor_health.ts new file mode 100644 index 0000000000000..073405f2ee7aa --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_health/get_monitor_health.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { SYNTHETICS_API_URLS } from '../../../common/constants'; +import type { SyntheticsRestApiRouteFactory } from '../types'; + +export const getMonitorsHealthRoute: SyntheticsRestApiRouteFactory = () => ({ + method: 'POST', + path: SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_HEALTH, + writeAccess: false, + validate: { + body: schema.object({ + monitorIds: schema.arrayOf(schema.string(), { minSize: 1, maxSize: 500 }), + }), + }, + handler: async (routeContext) => { + const { monitorIds } = routeContext.request.body; + return routeContext.monitorIntegrationHealthApi.getHealth(monitorIds); + }, +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_health/get_monitor_health_single.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_health/get_monitor_health_single.ts new file mode 100644 index 0000000000000..e91f8b2ff91df --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_health/get_monitor_health_single.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { SYNTHETICS_API_URLS } from '../../../common/constants'; +import type { SyntheticsRestApiRouteFactory } from '../types'; + +export const getMonitorHealthRoute: SyntheticsRestApiRouteFactory = () => ({ + method: 'GET', + path: SYNTHETICS_API_URLS.SYNTHETICS_MONITOR_HEALTH, + writeAccess: false, + validate: { + params: schema.object({ + monitorId: schema.string({ minLength: 1, maxLength: 1024 }), + }), + }, + handler: async (routeContext) => { + const { monitorId } = routeContext.request.params; + const { monitors, errors } = await routeContext.monitorIntegrationHealthApi.getHealth([ + monitorId, + ]); + + if (monitors.length === 0) { + const error = errors.find((e) => e.configId === monitorId); + + if (!error || error.statusCode === 404) { + return routeContext.response.notFound({ + body: { message: error?.message ?? `Monitor ${monitorId} not found` }, + }); + } + + return routeContext.response.customError({ + statusCode: error.statusCode, + body: { message: error.message }, + }); + } + + return monitors[0]; + }, +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts index 74a94167244db..ca804dddceb2d 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts @@ -23,6 +23,7 @@ import type { ResponseError, } from '@kbn/core-http-server'; import type { MonitorConfigRepository } from '../services/monitor_config_repository'; +import type { MonitorIntegrationHealthApi } from '../services/monitor_integration_health_api'; import type { SyntheticsEsClient } from '../lib'; import type { SyntheticsServerSetup, UptimeRequestHandlerContext } from '../types'; import type { SyntheticsMonitorClient } from '../synthetics_service/synthetics_monitor/synthetics_monitor_client'; @@ -103,6 +104,7 @@ export interface RouteContext< subject?: Subject; spaceId: string; monitorConfigRepository: MonitorConfigRepository; + monitorIntegrationHealthApi: MonitorIntegrationHealthApi; } export type SyntheticsRouteHandler< 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 new file mode 100644 index 0000000000000..4f8e358881cbc --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.test.ts @@ -0,0 +1,628 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObject, SavedObjectsClientContract } from '@kbn/core/server'; +import { SavedObjectsErrorHelpers } from '@kbn/core/server'; +import type { PackagePolicy } from '@kbn/fleet-plugin/common'; +import { + ConfigKey, + PrivateLocationHealthStatusValue, + SourceType, + type EncryptedSyntheticsMonitorAttributes, +} from '../../common/runtime_types'; +import type { PrivateLocationAttributes } from '../runtime_types/private_locations'; +import type { SyntheticsServerSetup } from '../types'; +import type { MonitorConfigRepository } from './monitor_config_repository'; +import { MonitorIntegrationHealthApi } from './monitor_integration_health_api'; + +jest.mock('../synthetics_service/get_private_locations'); +jest.mock('../synthetics_service/private_location/synthetics_private_location'); + +import { getPrivateLocations } from '../synthetics_service/get_private_locations'; +import { SyntheticsPrivateLocation } from '../synthetics_service/private_location/synthetics_private_location'; + +const mockedGetPrivateLocations = getPrivateLocations as jest.MockedFunction< + typeof getPrivateLocations +>; +const MockedSyntheticsPrivateLocation = SyntheticsPrivateLocation as jest.MockedClass< + typeof SyntheticsPrivateLocation +>; + +const SPACE_ID = 'default'; + +const createMonitorSO = ( + id: string, + opts: { + name?: string; + origin?: string; + locations?: Array<{ id: string; label?: string; isServiceManaged: boolean }>; + } = {} +): SavedObject => + ({ + id, + attributes: { + [ConfigKey.NAME]: opts.name ?? `Monitor ${id}`, + [ConfigKey.MONITOR_SOURCE_TYPE]: opts.origin ?? SourceType.UI, + [ConfigKey.LOCATIONS]: opts.locations ?? [], + }, + } as unknown as SavedObject); + +const createPrivateLocation = ( + id: string, + agentPolicyId: string, + label?: string +): PrivateLocationAttributes => ({ + id, + label: label ?? `Private Location ${id}`, + agentPolicyId, + isServiceManaged: false, +}); + +const createPackagePolicy = (policyId: string, agentPolicyIds: string[]): PackagePolicy => + ({ + id: policyId, + policy_ids: agentPolicyIds, + } as unknown as PackagePolicy); + +const buildApi = (overrides: { + monitorConfigRepository?: { get: jest.Mock }; + fleetGetByIDs?: jest.Mock; + fleetAgentPolicyGetByIds?: jest.Mock; + fleetGetInstallation?: jest.Mock; +}): MonitorIntegrationHealthApi => { + const fleetGetByIDs = overrides.fleetGetByIDs ?? jest.fn().mockResolvedValue([]); + + const fleetAgentPolicyGetByIds = + overrides.fleetAgentPolicyGetByIds ?? + jest + .fn() + .mockImplementation(async (_soClient: any, ids: string[]) => ids.map((id) => ({ id }))); + + const fleetGetInstallation = + overrides.fleetGetInstallation ?? jest.fn().mockResolvedValue({ install_status: 'installed' }); + + const server = { + coreStart: { + savedObjects: { + createInternalRepository: jest.fn().mockReturnValue({}), + }, + }, + fleet: { + packagePolicyService: { getByIDs: fleetGetByIDs }, + agentPolicyService: { getByIds: fleetAgentPolicyGetByIds }, + packageService: { + asInternalUser: { getInstallation: fleetGetInstallation }, + }, + }, + } as unknown as SyntheticsServerSetup; + + const savedObjectsClient = {} as SavedObjectsClientContract; + + const monitorConfigRepository = (overrides.monitorConfigRepository ?? { + get: jest.fn(), + }) as unknown as MonitorConfigRepository; + + return new MonitorIntegrationHealthApi( + server, + savedObjectsClient, + monitorConfigRepository, + SPACE_ID + ); +}; + +describe('MonitorIntegrationHealthApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + + MockedSyntheticsPrivateLocation.mockImplementation( + () => + ({ + getPolicyId: jest.fn( + (config: { origin?: string; id: string }, locId: string) => `${config.id}-${locId}` + ), + getLegacyPolicyIdsForAllSpaces: jest.fn( + (configId: string, locId: string, spaces: Set) => + [...spaces].map((s) => `${configId}-${locId}-${s}`) + ), + getAllSpacesWithMonitors: jest.fn().mockResolvedValue([SPACE_ID]), + getPolicyIdFormatInfo: jest.fn( + ( + config: { id: string }, + locId: string, + existingPolicies: Array<{ id: string }> | undefined, + spaces: Set + ) => { + const newId = `${config.id}-${locId}`; + const hasNewFormatPolicyId = existingPolicies?.some((p) => p.id === newId) ?? false; + const legacyPrefix = `${config.id}-${locId}-`; + const legacyPolicyIds = + existingPolicies + ?.filter( + (p) => + p.id.startsWith(legacyPrefix) && spaces.has(p.id.slice(legacyPrefix.length)) + ) + .map((p) => p.id) ?? []; + return { + hasNewFormatPolicyId, + hasAnyLegacyPolicyId: legacyPolicyIds.length > 0, + legacyPolicyIds, + }; + } + ), + } as any) + ); + + mockedGetPrivateLocations.mockResolvedValue([]); + }); + + describe('monitor fetching and partial errors', () => { + it('returns empty monitors and errors when all monitors fail to fetch', async () => { + const notFoundError = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'synthetics-monitor', + 'mon-1' + ); + const api = buildApi({ + monitorConfigRepository: { + get: jest.fn().mockRejectedValue(notFoundError), + }, + }); + + const result = await api.getHealth(['mon-1', 'mon-2']); + + expect(result.monitors).toHaveLength(0); + expect(result.errors).toEqual([ + { configId: 'mon-1', message: notFoundError.message, statusCode: 404 }, + { configId: 'mon-2', message: notFoundError.message, statusCode: 404 }, + ]); + }); + + it('returns partial results when some monitors fail', async () => { + const successSO = createMonitorSO('mon-1', { name: 'Good Monitor' }); + const notFoundError = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'synthetics-monitor', + 'mon-2' + ); + const getMock = jest + .fn() + .mockResolvedValueOnce(successSO) + .mockRejectedValueOnce(notFoundError); + + const api = buildApi({ monitorConfigRepository: { get: getMock } }); + + const result = await api.getHealth(['mon-1', 'mon-2']); + + expect(result.monitors).toHaveLength(1); + expect(result.monitors[0].configId).toBe('mon-1'); + expect(result.errors).toEqual([ + { configId: 'mon-2', message: notFoundError.message, statusCode: 404 }, + ]); + }); + + it('provides a default error message when rejection has no message', async () => { + const api = buildApi({ + monitorConfigRepository: { + get: jest.fn().mockRejectedValue({}), + }, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.errors).toEqual([ + { configId: 'mon-1', message: 'Failed to fetch monitor', statusCode: 500 }, + ]); + }); + + it('includes statusCode for non-404 SavedObjects errors via output.statusCode', async () => { + const forbiddenError = SavedObjectsErrorHelpers.decorateForbiddenError( + new Error('Access denied') + ); + const api = buildApi({ + monitorConfigRepository: { + get: jest.fn().mockRejectedValue(forbiddenError), + }, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.errors).toEqual([ + { configId: 'mon-1', message: 'Access denied', statusCode: 403 }, + ]); + }); + + it('defaults statusCode to 500 for generic errors without output.statusCode', async () => { + const api = buildApi({ + monitorConfigRepository: { + get: jest.fn().mockRejectedValue(new Error('Something went wrong')), + }, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.errors).toEqual([ + { configId: 'mon-1', message: 'Something went wrong', statusCode: 500 }, + ]); + }); + }); + + describe('monitors with no private locations', () => { + it('returns healthy status with empty locations for monitors using only managed locations', async () => { + const so = createMonitorSO('mon-1', { + locations: [{ id: 'us-east-1', label: 'US East', isServiceManaged: true }], + }); + + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.monitors).toEqual([ + { + configId: 'mon-1', + monitorName: 'Monitor mon-1', + isHealthy: true, + privateLocations: [], + }, + ]); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('healthy monitors', () => { + it('returns healthy when package policy exists and agent policy matches', 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 api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.monitors).toEqual([ + { + configId: 'mon-1', + monitorName: 'Monitor mon-1', + isHealthy: true, + privateLocations: [ + { + locationId: 'priv-loc-1', + locationLabel: 'Private Location priv-loc-1', + status: PrivateLocationHealthStatusValue.Healthy, + packagePolicyId: expectedPolicyId, + agentPolicyId: 'agent-policy-1', + }, + ], + }, + ]); + }); + }); + + describe('missing package policy', () => { + it('returns MissingPackagePolicy when the fleet package policy does not exist', 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 fleetGetByIDs = jest.fn().mockResolvedValue([]); + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + }); + + const result = await api.getHealth(['mon-1']); + + const locStatus = result.monitors[0].privateLocations[0]; + expect(locStatus.status).toBe(PrivateLocationHealthStatusValue.MissingPackagePolicy); + expect(locStatus.reason).toBeDefined(); + expect(result.monitors[0].isHealthy).toBe(false); + }); + }); + + describe('missing private location', () => { + it('returns MissingLocation when monitor references a private location that no longer exists', async () => { + const so = createMonitorSO('mon-1', { + locations: [{ id: 'gone-loc', label: 'Gone Location', isServiceManaged: false }], + }); + + mockedGetPrivateLocations.mockResolvedValue([]); + + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + }); + + const result = await api.getHealth(['mon-1']); + + const locStatus = result.monitors[0].privateLocations[0]; + expect(locStatus.status).toBe(PrivateLocationHealthStatusValue.MissingLocation); + expect(locStatus.locationLabel).toBe('Gone Location'); + expect(locStatus.reason).toBeDefined(); + expect(result.monitors[0].isHealthy).toBe(false); + }); + + it('falls back to location id when label is missing', async () => { + const so = createMonitorSO('mon-1', { + locations: [{ id: 'gone-loc', isServiceManaged: false }], + }); + + mockedGetPrivateLocations.mockResolvedValue([]); + + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.monitors[0].privateLocations[0].locationLabel).toBe('gone-loc'); + }); + }); + + describe('missing agent policy', () => { + it('returns MissingAgentPolicy when the agent policy referenced by the private location no longer exists', async () => { + const privateLoc = createPrivateLocation('priv-loc-1', 'deleted-agent-policy'); + const so = createMonitorSO('mon-1', { + locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], + }); + + mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + + const fleetAgentPolicyGetByIds = jest.fn().mockResolvedValue([]); + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetAgentPolicyGetByIds, + }); + + const result = await api.getHealth(['mon-1']); + + const locStatus = result.monitors[0].privateLocations[0]; + expect(locStatus.status).toBe(PrivateLocationHealthStatusValue.MissingAgentPolicy); + expect(locStatus.reason).toBeDefined(); + expect(result.monitors[0].isHealthy).toBe(false); + }); + + it('correctly distinguishes between existing and missing agent policies across locations', async () => { + const privateLoc1 = createPrivateLocation('loc-1', 'existing-agent', 'Location 1'); + const privateLoc2 = createPrivateLocation('loc-2', 'deleted-agent', 'Location 2'); + + const so = createMonitorSO('mon-1', { + locations: [ + { id: 'loc-1', label: 'Location 1', isServiceManaged: false }, + { id: 'loc-2', label: 'Location 2', isServiceManaged: false }, + ], + }); + + mockedGetPrivateLocations.mockResolvedValue([privateLoc1, privateLoc2]); + + const expectedPolicyId1 = 'mon-1-loc-1'; + const fleetGetByIDs = jest + .fn() + .mockResolvedValue([createPackagePolicy(expectedPolicyId1, ['existing-agent'])]); + const fleetAgentPolicyGetByIds = jest.fn().mockResolvedValue([{ id: 'existing-agent' }]); + + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + fleetAgentPolicyGetByIds, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.monitors[0].privateLocations[0].status).toBe( + PrivateLocationHealthStatusValue.Healthy + ); + expect(result.monitors[0].privateLocations[1].status).toBe( + PrivateLocationHealthStatusValue.MissingAgentPolicy + ); + }); + }); + + describe('project monitors use different policy ID format', () => { + it('generates policy ID without spaceId for project-origin monitors', async () => { + const privateLoc = createPrivateLocation('priv-loc-1', 'agent-policy-1'); + const so = createMonitorSO('mon-1', { + origin: SourceType.PROJECT, + 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 api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.monitors[0].privateLocations[0].status).toBe( + PrivateLocationHealthStatusValue.Healthy + ); + expect(result.monitors[0].privateLocations[0].packagePolicyId).toBe(expectedPolicyId); + }); + }); + + describe('multiple monitors and locations', () => { + it('handles mixed statuses across monitors and locations', async () => { + const privateLoc1 = createPrivateLocation('loc-1', 'agent-1', 'Location 1'); + const privateLoc2 = createPrivateLocation('loc-2', 'agent-2', 'Location 2'); + + const so1 = createMonitorSO('mon-1', { + name: 'Monitor A', + locations: [ + { id: 'loc-1', label: 'Location 1', isServiceManaged: false }, + { id: 'loc-2', label: 'Location 2', isServiceManaged: false }, + ], + }); + + const so2 = createMonitorSO('mon-2', { + name: 'Monitor B', + locations: [ + { id: 'loc-1', label: 'Location 1', isServiceManaged: false }, + { id: 'vanished', label: 'Vanished', isServiceManaged: false }, + ], + }); + + mockedGetPrivateLocations.mockResolvedValue([privateLoc1, privateLoc2]); + + const fleetGetByIDs = jest + .fn() + .mockResolvedValue([ + createPackagePolicy('mon-1-loc-1', ['agent-1']), + createPackagePolicy('mon-2-loc-1', ['agent-1']), + ]); + + const getMock = jest.fn().mockResolvedValueOnce(so1).mockResolvedValueOnce(so2); + + const api = buildApi({ + monitorConfigRepository: { get: getMock }, + fleetGetByIDs, + }); + + const result = await api.getHealth(['mon-1', 'mon-2']); + + expect(result.monitors).toHaveLength(2); + + const mon1 = result.monitors[0]; + expect(mon1.configId).toBe('mon-1'); + expect(mon1.privateLocations[0].status).toBe(PrivateLocationHealthStatusValue.Healthy); + expect(mon1.privateLocations[1].status).toBe( + PrivateLocationHealthStatusValue.MissingPackagePolicy + ); + expect(mon1.isHealthy).toBe(false); + + const mon2 = result.monitors[1]; + expect(mon2.configId).toBe('mon-2'); + expect(mon2.privateLocations[0].status).toBe(PrivateLocationHealthStatusValue.Healthy); + expect(mon2.privateLocations[1].status).toBe( + PrivateLocationHealthStatusValue.MissingLocation + ); + expect(mon2.isHealthy).toBe(false); + }); + }); + + describe('legacy policy ID format', () => { + it('reports healthy when only a legacy-format policy exists', 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 legacyPolicyId = `mon-1-priv-loc-1-${SPACE_ID}`; + const packagePolicy = createPackagePolicy(legacyPolicyId, ['agent-policy-1']); + const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.monitors[0].privateLocations[0].status).toBe( + PrivateLocationHealthStatusValue.Healthy + ); + expect(result.monitors[0].privateLocations[0].packagePolicyId).toBe(legacyPolicyId); + expect(result.monitors[0].isHealthy).toBe(true); + }); + + it('prefers new-format policy ID when both formats exist', 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 newPolicyId = 'mon-1-priv-loc-1'; + const legacyPolicyId = `mon-1-priv-loc-1-${SPACE_ID}`; + const fleetGetByIDs = jest + .fn() + .mockResolvedValue([ + createPackagePolicy(newPolicyId, ['agent-policy-1']), + createPackagePolicy(legacyPolicyId, ['agent-policy-1']), + ]); + + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.monitors[0].privateLocations[0].status).toBe( + PrivateLocationHealthStatusValue.Healthy + ); + expect(result.monitors[0].privateLocations[0].packagePolicyId).toBe(newPolicyId); + }); + + it('reports healthy for legacy policy even when attached to a different agent', async () => { + const privateLoc = createPrivateLocation('priv-loc-1', 'expected-agent'); + const so = createMonitorSO('mon-1', { + locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], + }); + + mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + + const legacyPolicyId = `mon-1-priv-loc-1-${SPACE_ID}`; + const packagePolicy = createPackagePolicy(legacyPolicyId, ['wrong-agent']); + const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + + const api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.monitors[0].privateLocations[0].status).toBe( + PrivateLocationHealthStatusValue.Healthy + ); + expect(result.monitors[0].privateLocations[0].packagePolicyId).toBe(legacyPolicyId); + }); + }); + + describe('healthy status has no reason field', () => { + it('omits reason for healthy locations', 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 api = buildApi({ + monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + fleetGetByIDs, + }); + + const result = await api.getHealth(['mon-1']); + + expect(result.monitors[0].privateLocations[0]).not.toHaveProperty('reason'); + }); + }); +}); 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 new file mode 100644 index 0000000000000..7809f4cb20abc --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.ts @@ -0,0 +1,260 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObject } from '@kbn/core/server'; +import type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { AgentPolicy, PackagePolicy } from '@kbn/fleet-plugin/common'; +import { + ConfigKey, + PrivateLocationHealthStatusValue, + type EncryptedSyntheticsMonitorAttributes, + type PrivateLocationHealthStatus, + type MonitorHealthError, + type MonitorHealthStatus, + type MonitorsHealthResponse, +} from '../../common/runtime_types'; +import { SyntheticsPrivateLocation } from '../synthetics_service/private_location/synthetics_private_location'; +import { getPrivateLocations } from '../synthetics_service/get_private_locations'; +import type { PrivateLocationAttributes } from '../runtime_types/private_locations'; +import type { SyntheticsServerSetup } from '../types'; +import type { MonitorConfigRepository } from './monitor_config_repository'; + +const STATUS_REASONS: Record< + Exclude, + string +> = { + [PrivateLocationHealthStatusValue.MissingPackagePolicy]: + 'The Fleet package policy for this monitor and private location pair does not exist.', + [PrivateLocationHealthStatusValue.MissingAgentPolicy]: + 'The agent policy referenced by this private location no longer exists.', + [PrivateLocationHealthStatusValue.MissingLocation]: + 'The monitor references a private location that no longer exists.', +}; + +interface FoundMonitor { + id: string; + so: SavedObject; +} + +export class MonitorIntegrationHealthApi { + constructor( + private readonly server: SyntheticsServerSetup, + private readonly savedObjectsClient: SavedObjectsClientContract, + private readonly monitorConfigRepository: MonitorConfigRepository, + private readonly spaceId: string + ) {} + + async getHealth(monitorIds: string[]): Promise { + const { foundMonitors, errors } = await this.fetchMonitors(monitorIds); + + if (foundMonitors.length === 0) { + return { monitors: [], errors }; + } + + const allPrivateLocations = await getPrivateLocations(this.savedObjectsClient); + const allPrivateLocationsMap = new Map( + allPrivateLocations.map((loc) => [loc.id, loc]) + ); + + const privateLocationAPI = new SyntheticsPrivateLocation(this.server); + + const allSpacesWithMonitors = await privateLocationAPI.getAllSpacesWithMonitors(); + const allSpaces = new Set([this.spaceId, ...allSpacesWithMonitors]); + + 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 existingPoliciesArray = [...existingPackagePoliciesMap.values()]; + + const monitors: MonitorHealthStatus[] = foundMonitors.map(({ so }) => { + const locations = so.attributes[ConfigKey.LOCATIONS] ?? []; + const privateLocations = locations.filter((loc) => !loc.isServiceManaged); + + // Status checks are ordered by root-cause severity (most fundamental first). + // 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 + 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 + ); + + if (!existingPrivateLocation) { + return MonitorIntegrationHealthApi.buildLocationStatus( + loc.id, + loc.label ?? loc.id, + PrivateLocationHealthStatusValue.MissingLocation, + newFormatPolicyId + ); + } + + if (!existingAgentPoliciesMap.has(existingPrivateLocation.agentPolicyId)) { + return MonitorIntegrationHealthApi.buildLocationStatus( + loc.id, + existingPrivateLocation.label, + PrivateLocationHealthStatusValue.MissingAgentPolicy, + newFormatPolicyId, + existingPrivateLocation.agentPolicyId + ); + } + + const { hasNewFormatPolicyId, hasAnyLegacyPolicyId, legacyPolicyIds } = + privateLocationAPI.getPolicyIdFormatInfo( + { id: so.id }, + loc.id, + existingPoliciesArray, + allSpaces + ); + + if (!hasNewFormatPolicyId && !hasAnyLegacyPolicyId) { + return MonitorIntegrationHealthApi.buildLocationStatus( + loc.id, + existingPrivateLocation.label, + PrivateLocationHealthStatusValue.MissingPackagePolicy, + newFormatPolicyId, + existingPrivateLocation.agentPolicyId + ); + } + + const resolvedPolicyId = hasNewFormatPolicyId ? newFormatPolicyId : legacyPolicyIds[0]; + const expectedAgentPolicyId = existingPrivateLocation.agentPolicyId; + + return MonitorIntegrationHealthApi.buildLocationStatus( + loc.id, + existingPrivateLocation.label, + PrivateLocationHealthStatusValue.Healthy, + resolvedPolicyId, + expectedAgentPolicyId + ); + }); + + return { + configId: so.id, + monitorName: so.attributes[ConfigKey.NAME], + isHealthy: locationStatuses.every( + (s) => s.status === PrivateLocationHealthStatusValue.Healthy + ), + privateLocations: locationStatuses, + }; + }); + + return { monitors, errors }; + } + + private async fetchMonitors(monitorIds: string[]) { + const errors: MonitorHealthError[] = []; + const foundMonitors: FoundMonitor[] = []; + + const settledResults = await Promise.allSettled( + monitorIds.map((id) => this.monitorConfigRepository.get(id)) + ); + + for (let i = 0; i < monitorIds.length; i++) { + const result = settledResults[i]; + if (result.status === 'fulfilled') { + foundMonitors.push({ id: monitorIds[i], so: result.value }); + } else { + const reason = result.reason; + errors.push({ + configId: monitorIds[i], + message: reason?.message ?? 'Failed to fetch monitor', + statusCode: reason?.output?.statusCode ?? 500, + }); + } + } + + return { foundMonitors, errors }; + } + + private getExpectedPackagePolicyIds( + foundMonitors: FoundMonitor[], + privateLocationAPI: SyntheticsPrivateLocation, + allSpaces: Set + ): string[] { + const ids = new Set(); + + for (const { so } of foundMonitors) { + const locations = so.attributes[ConfigKey.LOCATIONS] ?? []; + const privateLocations = locations.filter((loc) => !loc.isServiceManaged); + + for (const loc of privateLocations) { + ids.add( + privateLocationAPI.getPolicyId( + { origin: so.attributes[ConfigKey.MONITOR_SOURCE_TYPE], id: so.id }, + loc.id + ) + ); + for (const legacyId of privateLocationAPI.getLegacyPolicyIdsForAllSpaces( + so.id, + loc.id, + allSpaces + )) { + ids.add(legacyId); + } + } + } + + return [...ids]; + } + + private async getExistingPackagePoliciesMap(expectedPackagePolicyIds: string[]) { + if (expectedPackagePolicyIds.length === 0) { + return new Map(); + } + + const internalSoClient = this.server.coreStart.savedObjects.createInternalRepository(); + const existingPackagePolicies = await this.server.fleet.packagePolicyService.getByIDs( + internalSoClient, + expectedPackagePolicyIds, + { ignoreMissing: true } + ); + return new Map((existingPackagePolicies ?? []).map((policy) => [policy.id, policy])); + } + + private async getExistingAgentPoliciesMap(agentPolicyIds: string[]) { + if (agentPolicyIds.length === 0) { + return new Map(); + } + + const internalSoClient = this.server.coreStart.savedObjects.createInternalRepository(); + const existingAgentPolicies = await this.server.fleet.agentPolicyService.getByIds( + internalSoClient, + agentPolicyIds, + { ignoreMissing: true, withPackagePolicies: false } + ); + return new Map((existingAgentPolicies ?? []).map((policy) => [policy.id, policy])); + } + + private static buildLocationStatus( + locationId: string, + locationLabel: string, + status: PrivateLocationHealthStatusValue, + packagePolicyId: string, + agentPolicyId?: string + ): PrivateLocationHealthStatus { + return { + locationId, + locationLabel, + status, + packagePolicyId, + agentPolicyId, + ...(status !== PrivateLocationHealthStatusValue.Healthy + ? { reason: STATUS_REASONS[status] } + : {}), + }; + } +} diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_route_wrapper.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_route_wrapper.ts index b61ae56e14756..6f13be82ce0e2 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_route_wrapper.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_route_wrapper.ts @@ -9,6 +9,7 @@ import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import { isEmpty } from 'lodash'; import { isKibanaResponse } from '@kbn/core-http-server'; import { MonitorConfigRepository } from './services/monitor_config_repository'; +import { MonitorIntegrationHealthApi } from './services/monitor_integration_health_api'; import { syntheticsServiceApiKey } from './saved_objects/service_api_key'; import { isTestUser, SyntheticsEsClient } from './lib'; import { SYNTHETICS_INDEX_PATTERN } from '../common/constants'; @@ -65,6 +66,13 @@ export const syntheticsRouteWrapper: SyntheticsRouteWrapper = ( const spaceId = server.spaces?.spacesService.getSpaceId(request) ?? DEFAULT_SPACE_ID; + const monitorIntegrationHealthApi = new MonitorIntegrationHealthApi( + server, + savedObjectsClient, + monitorConfigRepository, + spaceId + ); + try { const data = { syntheticsEsClient, @@ -76,6 +84,7 @@ export const syntheticsRouteWrapper: SyntheticsRouteWrapper = ( spaceId, syntheticsMonitorClient, monitorConfigRepository, + monitorIntegrationHealthApi, }; const res = await server.fleet.runWithCache(() => syntheticsRoute.handler(data)); diff --git a/x-pack/solutions/observability/plugins/synthetics/test/scout/ui/fixtures/page_objects/synthetics_app.ts b/x-pack/solutions/observability/plugins/synthetics/test/scout/ui/fixtures/page_objects/synthetics_app.ts index 188df9a6200f4..fe34a58f13f0e 100644 --- a/x-pack/solutions/observability/plugins/synthetics/test/scout/ui/fixtures/page_objects/synthetics_app.ts +++ b/x-pack/solutions/observability/plugins/synthetics/test/scout/ui/fixtures/page_objects/synthetics_app.ts @@ -336,8 +336,8 @@ export class SyntheticsAppPage { } async deleteLocation() { - await this.page.click('[aria-label="Delete location"]'); - await this.page.click('button:has-text("Delete location")'); + await this.page.testSubj.click('action-delete'); + await this.page.testSubj.click('confirmModalConfirmButton'); } async createGlobalParameter({ diff --git a/x-pack/solutions/observability/plugins/synthetics/test/scout/ui/tests/private_locations.spec.ts b/x-pack/solutions/observability/plugins/synthetics/test/scout/ui/tests/private_locations.spec.ts index 688fa24d74444..8d87f2bf95191 100644 --- a/x-pack/solutions/observability/plugins/synthetics/test/scout/ui/tests/private_locations.spec.ts +++ b/x-pack/solutions/observability/plugins/synthetics/test/scout/ui/tests/private_locations.spec.ts @@ -12,7 +12,6 @@ import { test } from '../fixtures'; test.describe('PrivateLocationsSettings', { tag: tags.stateful.classic }, () => { const NEW_LOCATION_LABEL = 'Updated Test Location'; const FLEET_POLICY_NAME = 'Test fleet policy'; - let locationId: string; test.beforeAll(async ({ syntheticsServices }) => { await syntheticsServices.deletePrivateLocations(); @@ -66,7 +65,6 @@ test.describe('PrivateLocationsSettings', { tag: tags.stateful.classic }, () => await pageObjects.syntheticsApp.navigateToSettingsTab('Private Locations'); const privateLocations = await syntheticsServices.getPrivateLocations(); expect(privateLocations).toHaveLength(1); - locationId = privateLocations[0].id; await syntheticsServices.addMonitorSimple('test-monitor', { locations: [privateLocations[0]], type: 'browser', @@ -103,7 +101,7 @@ test.describe('PrivateLocationsSettings', { tag: tags.stateful.classic }, () => await page.testSubj.click('settings-page-link'); await pageObjects.syntheticsApp.navigateToSettingsTab('Private Locations'); await expect(page.locator(`td:has-text("${NEW_LOCATION_LABEL}")`)).toBeVisible(); - const deleteLocationButton = page.locator(`[data-test-subj="deleteLocation-${locationId}"]`); + const deleteLocationButton = page.testSubj.locator('action-delete'); await expect(deleteLocationButton).toBeDisabled(); });