diff --git a/x-pack/solutions/observability/plugins/synthetics/common/get_synthetics_indices.test.ts b/x-pack/solutions/observability/plugins/synthetics/common/get_synthetics_indices.test.ts new file mode 100644 index 0000000000000..11040affda477 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/common/get_synthetics_indices.test.ts @@ -0,0 +1,74 @@ +/* + * 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_INDEX_PATTERN } from './constants'; +import { getSyntheticsIndices } from './get_synthetics_indices'; + +describe('getSyntheticsIndices', () => { + it('returns only the local index when CCS is disabled', () => { + const result = getSyntheticsIndices({ + useAllRemoteClusters: false, + selectedRemoteClusters: [], + }); + expect(result).toStrictEqual([SYNTHETICS_INDEX_PATTERN]); + }); + + it('returns a wildcard remote and the local index when useAllRemoteClusters is true', () => { + const result = getSyntheticsIndices({ + useAllRemoteClusters: true, + selectedRemoteClusters: [], + remoteClusters: [ + { name: 'cluster1', isConnected: true }, + { name: 'cluster2', isConnected: true }, + ], + }); + expect(result).toStrictEqual([SYNTHETICS_INDEX_PATTERN, `*:${SYNTHETICS_INDEX_PATTERN}`]); + }); + + it('returns only connected clusters from the selected list', () => { + const result = getSyntheticsIndices({ + useAllRemoteClusters: false, + selectedRemoteClusters: ['cluster1', 'cluster3'], + remoteClusters: [ + { name: 'cluster1', isConnected: true }, + { name: 'cluster2', isConnected: true }, + { name: 'cluster3', isConnected: false }, + ], + }); + expect(result).toStrictEqual([ + SYNTHETICS_INDEX_PATTERN, + `cluster1:${SYNTHETICS_INDEX_PATTERN}`, + ]); + }); + + it('excludes clusters not in the selected list even if connected', () => { + const result = getSyntheticsIndices({ + useAllRemoteClusters: false, + selectedRemoteClusters: ['cluster1'], + remoteClusters: [ + { name: 'cluster1', isConnected: true }, + { name: 'cluster2', isConnected: true }, + ], + }); + expect(result).toStrictEqual([ + SYNTHETICS_INDEX_PATTERN, + `cluster1:${SYNTHETICS_INDEX_PATTERN}`, + ]); + }); + + it('returns only the local index when selected clusters are all disconnected', () => { + const result = getSyntheticsIndices({ + useAllRemoteClusters: false, + selectedRemoteClusters: ['cluster1', 'cluster2'], + remoteClusters: [ + { name: 'cluster1', isConnected: false }, + { name: 'cluster2', isConnected: false }, + ], + }); + expect(result).toStrictEqual([SYNTHETICS_INDEX_PATTERN]); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/common/get_synthetics_indices.ts b/x-pack/solutions/observability/plugins/synthetics/common/get_synthetics_indices.ts new file mode 100644 index 0000000000000..9933fed32ab37 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/common/get_synthetics_indices.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 { SYNTHETICS_INDEX_PATTERN } from './constants'; + +export interface RemoteCluster { + name: string; + isConnected: boolean; +} + +interface Props { + useAllRemoteClusters: boolean; + selectedRemoteClusters: string[]; + remoteClusters?: RemoteCluster[]; +} + +/** + * @returns the local synthetics index or the remote cluster indices based on the CCS settings. + * If `useAllRemoteClusters` is false and no remote clusters are selected, returns only the local index. + * If `useAllRemoteClusters` is true, returns both the local index and a wildcard remote index. + * If specific clusters are selected, returns the local index plus indices for connected selected clusters. + */ +export const getSyntheticsIndices = ({ + useAllRemoteClusters, + selectedRemoteClusters, + remoteClusters = [], +}: Props): string[] => { + if (!useAllRemoteClusters && selectedRemoteClusters.length === 0) { + return [SYNTHETICS_INDEX_PATTERN]; + } + + if (useAllRemoteClusters) { + return [SYNTHETICS_INDEX_PATTERN, `*:${SYNTHETICS_INDEX_PATTERN}`]; + } + + return remoteClusters.reduce( + (acc, { name, isConnected }) => { + if (isConnected && selectedRemoteClusters.includes(name)) { + acc.push(`${name}:${SYNTHETICS_INDEX_PATTERN}`); + } + return acc; + }, + [SYNTHETICS_INDEX_PATTERN] as string[] + ); +}; diff --git a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/ccs_settings.ts b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/ccs_settings.ts new file mode 100644 index 0000000000000..5f813e5ec162e --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/ccs_settings.ts @@ -0,0 +1,15 @@ +/* + * 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 * as t from 'io-ts'; + +export const syntheticsCCSSettingsSchema = t.type({ + useAllRemoteClusters: t.boolean, + selectedRemoteClusters: t.array(t.string), +}); + +export type SyntheticsCCSSettings = t.TypeOf; 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 74af253895000..438607d17c9b0 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 @@ -15,3 +15,5 @@ export * from './network_events'; export * from './monitor_management'; export * from './monitor_management/synthetics_private_locations'; export * from './monitor_health'; +export * from './ccs_settings'; +export * from './remote'; diff --git a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts index eee5d4f91fe8d..a5fb78b914fac 100644 --- a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts +++ b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts @@ -9,6 +9,7 @@ import * as t from 'io-ts'; import { ObserverCodec } from '../ping/observer'; import { ErrorStateCodec } from '../ping/error_state'; import { AgentType, MonitorType, PingErrorType, UrlType } from '..'; +import { remoteMonitorInfoSchema } from '../remote'; export const OverviewPingCodec = t.intersection([ t.interface({ @@ -55,6 +56,7 @@ export const OverviewStatusMetaDataCodec = t.intersection([ spaces: t.array(t.string), urls: t.string, maintenanceWindows: t.array(t.string), + remote: remoteMonitorInfoSchema, }), ]); diff --git a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/ping/ping.ts b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/ping/ping.ts index e1cc9439678f1..ae13a8e0a72c2 100644 --- a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/ping/ping.ts +++ b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/ping/ping.ts @@ -10,6 +10,7 @@ import { ObserverCodec } from './observer'; import { ErrorStateCodec } from './error_state'; import { DateRangeType } from '../common'; import { SyntheticsDataType } from './synthetics'; +import { remoteMonitorInfoSchema } from '../remote'; // IO type for validation export const PingErrorType = t.intersection([ @@ -239,6 +240,7 @@ export const PingType = t.intersection([ dataset: t.string, }), labels: t.record(t.string, t.string), + remote: remoteMonitorInfoSchema, }), ]); diff --git a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/remote.ts b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/remote.ts new file mode 100644 index 0000000000000..ef33d150e9c10 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/remote.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. + */ + +import * as t from 'io-ts'; + +export const remoteMonitorInfoSchema = t.type({ + remoteName: t.string, +}); + +export type RemoteMonitorInfo = t.TypeOf; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/page_header.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/page_header.tsx index a48857c133af7..c9f66a30f7492 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/page_header.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/page_header.tsx @@ -10,6 +10,7 @@ import { useRouteMatch } from 'react-router-dom'; import type { EuiPageHeaderProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SYNTHETICS_SETTINGS_ROUTE } from '../../../../../common/constants'; +import { useSyntheticsSettingsContext } from '../../contexts'; export type SettingsTabId = | 'data-retention' @@ -17,7 +18,8 @@ export type SettingsTabId = | 'alerting' | 'private-locations' | 'api-keys' - | 'advanced'; + | 'advanced' + | 'remote-clusters'; export const getSettingsPageHeader = ( history: ReturnType, @@ -25,6 +27,7 @@ export const getSettingsPageHeader = ( ): EuiPageHeaderProps => { // Not a component, but it doesn't matter. Hooks are just functions const match = useRouteMatch<{ tabId: SettingsTabId }>(SYNTHETICS_SETTINGS_ROUTE); // eslint-disable-line react-hooks/rules-of-hooks + const { isServerless, isCCSEnabled } = useSyntheticsSettingsContext(); // eslint-disable-line react-hooks/rules-of-hooks if (!match) { return {}; @@ -84,6 +87,17 @@ export const getSettingsPageHeader = ( isSelected: tabId === 'advanced', href: replaceTab('advanced'), }, + ...(!isServerless && isCCSEnabled + ? [ + { + label: i18n.translate('xpack.synthetics.settingsTabs.remoteClusters', { + defaultMessage: 'Remote Clusters', + }), + isSelected: tabId === 'remote-clusters', + href: replaceTab('remote-clusters'), + }, + ] + : []), ], }; }; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/hooks/use_get_ccs_settings.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/hooks/use_get_ccs_settings.ts new file mode 100644 index 0000000000000..c809ca3bdb856 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/hooks/use_get_ccs_settings.ts @@ -0,0 +1,45 @@ +/* + * 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 { useContext } from 'react'; +import { useFetcher } from '@kbn/observability-shared-plugin/public'; +import type { SyntheticsCCSSettings } from '../../../../../../../common/runtime_types'; +import { SYNTHETICS_API_URLS } from '../../../../../../../common/constants'; +import { apiService } from '../../../../../../utils/api_service'; +import { SyntheticsRefreshContext } from '../../../../contexts'; + +export const DEFAULT_CCS_SETTINGS: SyntheticsCCSSettings = { + useAllRemoteClusters: false, + selectedRemoteClusters: [], +}; + +const fetchCCSSettings = async (): Promise => { + try { + const dynamicSettings = await apiService.get<{ + useAllRemoteClusters?: boolean; + selectedRemoteClusters?: string[]; + }>(SYNTHETICS_API_URLS.DYNAMIC_SETTINGS); + return { + useAllRemoteClusters: dynamicSettings.useAllRemoteClusters ?? false, + selectedRemoteClusters: dynamicSettings.selectedRemoteClusters ?? [], + }; + } catch (e) { + return DEFAULT_CCS_SETTINGS; + } +}; + +export const useGetCCSSettings = () => { + const { lastRefresh } = useContext(SyntheticsRefreshContext); + + const { data, error, loading } = useFetcher(fetchCCSSettings, [lastRefresh]); + + return { + data: data ?? DEFAULT_CCS_SETTINGS, + error, + loading, + }; +}; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/hooks/use_put_ccs_settings.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/hooks/use_put_ccs_settings.ts new file mode 100644 index 0000000000000..1f2d9799ffe13 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/hooks/use_put_ccs_settings.ts @@ -0,0 +1,47 @@ +/* + * 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 { useState, useCallback, useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { SyntheticsCCSSettings } from '../../../../../../../common/runtime_types'; +import { SYNTHETICS_API_URLS } from '../../../../../../../common/constants'; +import { apiService } from '../../../../../../utils/api_service'; +import { SyntheticsRefreshContext } from '../../../../contexts'; + +export const usePutCCSSettings = () => { + const [isSaving, setIsSaving] = useState(false); + const { notifications } = useKibana().services; + const { refreshApp } = useContext(SyntheticsRefreshContext); + + const saveSettings = useCallback( + async (settings: SyntheticsCCSSettings) => { + setIsSaving(true); + try { + const result = await apiService.put(SYNTHETICS_API_URLS.DYNAMIC_SETTINGS, settings); + notifications?.toasts.addSuccess({ + title: i18n.translate('xpack.synthetics.settings.ccs.saveSuccess', { + defaultMessage: 'CCS settings saved successfully', + }), + }); + refreshApp(); + return result; + } catch (error) { + notifications?.toasts.addError(error, { + title: i18n.translate('xpack.synthetics.settings.ccs.saveError', { + defaultMessage: 'Failed to save CCS settings', + }), + }); + } finally { + setIsSaving(false); + } + }, + [notifications, refreshApp] + ); + + return { saveSettings, isSaving }; +}; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/remote_clusters_form.test.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/remote_clusters_form.test.tsx new file mode 100644 index 0000000000000..0a2cd36c82180 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/remote_clusters_form.test.tsx @@ -0,0 +1,108 @@ +/* + * 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 * as observabilitySharedPublic from '@kbn/observability-shared-plugin/public'; +import { screen, fireEvent } from '@testing-library/react'; +import { RemoteClustersForm } from './remote_clusters_form'; +import { render } from '../../../utils/testing'; +import * as settingsHooks from '../../../contexts/synthetics_settings_context'; +import type { SyntheticsSettingsContextValues } from '../../../contexts'; + +jest.mock('@kbn/observability-shared-plugin/public', () => ({ + ...jest.requireActual('@kbn/observability-shared-plugin/public'), + useFetcher: jest.fn(), +})); + +const mockSaveSettings = jest.fn(); + +const mockCCSSettingsData = { + useAllRemoteClusters: false, + selectedRemoteClusters: [] as string[], +}; + +jest.mock('./hooks/use_get_ccs_settings', () => ({ + ...jest.requireActual('./hooks/use_get_ccs_settings'), + useGetCCSSettings: () => ({ + data: mockCCSSettingsData, + loading: false, + error: undefined, + }), +})); + +jest.mock('./hooks/use_put_ccs_settings', () => ({ + usePutCCSSettings: () => ({ + saveSettings: mockSaveSettings, + isSaving: false, + }), +})); + +const mockRemoteClusters = [ + { name: 'cluster-a', isConnected: true }, + { name: 'cluster-b', isConnected: false }, +]; + +jest.mock('../../../contexts/synthetics_settings_context', () => ({ + ...jest.requireActual('../../../contexts/synthetics_settings_context'), + useSyntheticsSettingsContext: jest.fn(), +})); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + (settingsHooks.useSyntheticsSettingsContext as jest.Mock).mockReturnValue({ + isServerless: false, + isCCSEnabled: true, + } as SyntheticsSettingsContextValues); + }); + + const renderWithClusters = (clusters = mockRemoteClusters) => { + jest.spyOn(observabilitySharedPublic, 'useFetcher').mockReturnValue({ + data: clusters, + status: observabilitySharedPublic.FETCH_STATUS.SUCCESS, + loading: false, + refetch: () => {}, + }); + return render(); + }; + + it('renders the form with toggle and cluster selector', () => { + renderWithClusters(); + + expect(screen.getByTestId('syntheticsUseAllRemoteClusters')).toBeInTheDocument(); + expect(screen.getByTestId('syntheticsSelectRemoteClusters')).toBeInTheDocument(); + }); + + it('shows a warning callout when no remote clusters are available', () => { + renderWithClusters([]); + + expect(screen.getByText(/No remote clusters configured/)).toBeInTheDocument(); + expect(screen.getByText(/configure remote clusters in Stack Management/)).toBeInTheDocument(); + }); + + it('disables the combo box when "Use all remote clusters" is toggled on', () => { + renderWithClusters(); + + const toggle = screen.getByTestId('syntheticsUseAllRemoteClusters'); + fireEvent.click(toggle); + + const comboBox = screen.getByTestId('syntheticsSelectRemoteClusters'); + expect(comboBox).toHaveAttribute('disabled'); + }); + + it('enables the Apply button only when form is dirty', () => { + renderWithClusters(); + + const applyButton = screen.getByTestId('syntheticsCCSSettingsApplyButton'); + expect(applyButton).toBeDisabled(); + + const toggle = screen.getByTestId('syntheticsUseAllRemoteClusters'); + fireEvent.click(toggle); + + expect(applyButton).not.toBeDisabled(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/remote_clusters_form.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/remote_clusters_form.tsx new file mode 100644 index 0000000000000..f9b3596375c57 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/remote_clusters/remote_clusters_form.tsx @@ -0,0 +1,270 @@ +/* + * 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, useEffect, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiComboBox, + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiHealth, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { useFetcher } from '@kbn/observability-shared-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { isEqual } from 'lodash'; +import type { SyntheticsCCSSettings } from '../../../../../../common/runtime_types'; +import type { RemoteCluster } from '../../../../../../common/get_synthetics_indices'; +import { useGetCCSSettings, DEFAULT_CCS_SETTINGS } from './hooks/use_get_ccs_settings'; +import { usePutCCSSettings } from './hooks/use_put_ccs_settings'; +import { useSyntheticsSettingsContext } from '../../../contexts'; + +export const RemoteClustersForm = () => { + const { http } = useKibana().services; + const { isServerless, isCCSEnabled } = useSyntheticsSettingsContext(); + const { data: savedSettings, loading: loadingSettings } = useGetCCSSettings(); + const { saveSettings, isSaving } = usePutCCSSettings(); + + // Fetch available remote clusters + const { data: remoteClusters, loading: loadingClusters } = useFetcher(async () => { + try { + const response = await http?.get('/api/remote_clusters'); + return (response ?? []).map( + (cluster): RemoteCluster => ({ + name: cluster.name, + isConnected: cluster.isConnected, + }) + ); + } catch (e) { + return [] as RemoteCluster[]; + } + }, [http]); + + // Local form state + const [useAllRemoteClusters, setUseAllRemoteClusters] = useState(false); + const [selectedRemoteClusters, setSelectedRemoteClusters] = useState([]); + + // Sync local state from saved settings when loaded + useEffect(() => { + if (savedSettings) { + setUseAllRemoteClusters(savedSettings.useAllRemoteClusters); + setSelectedRemoteClusters(savedSettings.selectedRemoteClusters); + } + }, [savedSettings]); + + const loading = loadingSettings || loadingClusters; + + const canEdit: boolean = + !!useKibana().services?.application?.capabilities.uptime.configureSettings || false; + + // Build the current form values for dirty checking and saving + const currentFormValues: SyntheticsCCSSettings = useMemo( + () => ({ + useAllRemoteClusters, + selectedRemoteClusters, + }), + [useAllRemoteClusters, selectedRemoteClusters] + ); + + const isFormDirty = !isEqual(currentFormValues, savedSettings ?? DEFAULT_CCS_SETTINGS); + + const handleDiscard = useCallback(() => { + if (savedSettings) { + setUseAllRemoteClusters(savedSettings.useAllRemoteClusters); + setSelectedRemoteClusters(savedSettings.selectedRemoteClusters); + } + }, [savedSettings]); + + const handleSave = useCallback(async () => { + await saveSettings(currentFormValues); + }, [saveSettings, currentFormValues]); + + // Combo box options for cluster selection + const clusterOptions: EuiComboBoxOptionOption[] = useMemo(() => { + return (remoteClusters ?? []).map((cluster) => ({ + label: cluster.name, + value: cluster.name, + append: cluster.isConnected ? ( + {CONNECTED_LABEL} + ) : ( + {DISCONNECTED_LABEL} + ), + })); + }, [remoteClusters]); + + const selectedOptions: EuiComboBoxOptionOption[] = useMemo(() => { + return selectedRemoteClusters.map((name) => ({ label: name, value: name })); + }, [selectedRemoteClusters]); + + if (isServerless || !isCCSEnabled) { + return null; + } + + const hasNoClusters = !loading && (remoteClusters ?? []).length === 0; + + return ( + + + + {!canEdit && ( + <> + + + + )} + + {hasNoClusters && ( + <> + +

{NO_CLUSTERS_DESCRIPTION}

+
+ + + )} + + {SOURCE_SETTINGS_TITLE}} + description={

{SOURCE_SETTINGS_DESCRIPTION}

} + > + + setUseAllRemoteClusters(e.target.checked)} + disabled={!canEdit || hasNoClusters} + showLabel={false} + /> + +
+ + {SELECT_CLUSTERS_TITLE}} + description={

{SELECT_CLUSTERS_DESCRIPTION}

} + > + + { + setSelectedRemoteClusters(selected.map((s) => s.value as string)); + }} + isDisabled={useAllRemoteClusters || !canEdit || hasNoClusters} + placeholder={SELECT_CLUSTERS_PLACEHOLDER} + /> + +
+ + + + + + {DISCARD_CHANGES} + + + + + {APPLY_CHANGES} + + + +
+ ); +}; + +// i18n labels + +const USE_ALL_CLUSTERS_LABEL = i18n.translate( + 'xpack.synthetics.settings.ccs.useAllRemoteClusters', + { defaultMessage: 'Use all remote clusters' } +); + +const SOURCE_SETTINGS_TITLE = i18n.translate('xpack.synthetics.settings.ccs.sourceSettingsTitle', { + defaultMessage: 'Source settings', +}); + +const SOURCE_SETTINGS_DESCRIPTION = i18n.translate( + 'xpack.synthetics.settings.ccs.sourceSettingsDescription', + { + defaultMessage: + 'Include monitor data from remote clusters. When enabled, monitors from remote clusters will appear alongside local monitors.', + } +); + +const SELECT_CLUSTERS_TITLE = i18n.translate('xpack.synthetics.settings.ccs.selectClustersTitle', { + defaultMessage: 'Remote clusters', +}); + +const SELECT_CLUSTERS_DESCRIPTION = i18n.translate( + 'xpack.synthetics.settings.ccs.selectClustersDescription', + { defaultMessage: 'Select which remote clusters to include in cross-cluster search queries.' } +); + +const SELECT_CLUSTERS_LABEL = i18n.translate('xpack.synthetics.settings.ccs.selectClustersLabel', { + defaultMessage: 'Select remote clusters', +}); + +const SELECT_CLUSTERS_PLACEHOLDER = i18n.translate( + 'xpack.synthetics.settings.ccs.selectClustersPlaceholder', + { defaultMessage: 'Search for remote clusters' } +); + +const CONNECTED_LABEL = i18n.translate('xpack.synthetics.settings.ccs.connected', { + defaultMessage: 'Connected', +}); + +const DISCONNECTED_LABEL = i18n.translate('xpack.synthetics.settings.ccs.disconnected', { + defaultMessage: 'Disconnected', +}); + +const NO_CLUSTERS_TITLE = i18n.translate('xpack.synthetics.settings.ccs.noClustersTitle', { + defaultMessage: 'No remote clusters configured', +}); + +const NO_CLUSTERS_DESCRIPTION = i18n.translate( + 'xpack.synthetics.settings.ccs.noClustersDescription', + { + defaultMessage: + 'To use cross-cluster search, configure remote clusters in Stack Management > Remote Clusters.', + } +); + +const READ_ONLY_MESSAGE = i18n.translate('xpack.synthetics.settings.ccs.readOnly', { + defaultMessage: + 'You do not have sufficient permissions to edit these settings. Contact your administrator.', +}); + +const DISCARD_CHANGES = i18n.translate('xpack.synthetics.settings.ccs.discardChanges', { + defaultMessage: 'Discard changes', +}); + +const APPLY_CHANGES = i18n.translate('xpack.synthetics.settings.ccs.applyChanges', { + defaultMessage: 'Apply changes', +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/settings_page.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/settings_page.tsx index 34619c2a6490b..bf3a7bb3b4187 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/settings_page.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/settings_page.tsx @@ -16,6 +16,7 @@ import { DataRetentionTab } from './data_retention'; import { useSettingsBreadcrumbs } from './use_settings_breadcrumbs'; import { ManagePrivateLocations } from './private_locations/manage_private_locations'; import { AdvancedSettingsForm } from './advanced/advanced_settings_form'; +import { RemoteClustersForm } from './remote_clusters/remote_clusters_form'; export const SettingsPage = () => { useSettingsBreadcrumbs(); @@ -44,6 +45,12 @@ export const SettingsPage = () => { ); + case 'remote-clusters': + return ( + + + + ); default: return ; } diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_settings_context.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_settings_context.tsx index 39d743900150c..1f516e5b792e5 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_settings_context.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_settings_context.tsx @@ -42,6 +42,7 @@ export interface SyntheticsAppProps { appMountParameters: AppMountParameters; isDev: boolean; isServerless: boolean; + isCCSEnabled: boolean; } export interface SyntheticsSettingsContextValues { @@ -56,6 +57,7 @@ export interface SyntheticsSettingsContextValues { commonlyUsedRanges?: CommonlyUsedDateRange[]; isDev?: boolean; isServerless?: boolean; + isCCSEnabled?: boolean; setBreadcrumbs?: (crumbs: ChromeBreadcrumb[]) => void; darkMode: boolean; } @@ -76,6 +78,7 @@ const defaultContext: SyntheticsSettingsContextValues = { isInfraAvailable: true, isLogsAvailable: true, isDev: false, + isCCSEnabled: false, canSave: false, canManagePrivateLocations: false, darkMode: false, @@ -94,6 +97,7 @@ export const SyntheticsSettingsContextProvider: React.FC { - const { isDev, isServerless, coreStart, startPlugins, setupPlugins, appMountParameters } = - kibanaService; + const { + isDev, + isServerless, + isCCSEnabled, + coreStart, + startPlugins, + setupPlugins, + appMountParameters, + } = kibanaService; const { application: { capabilities }, @@ -62,6 +69,7 @@ export const getSyntheticsAppProps = (): SyntheticsAppProps => { setBadge, appMountParameters, isServerless, + isCCSEnabled, }; }; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/plugin.ts b/x-pack/solutions/observability/plugins/synthetics/public/plugin.ts index 895493753c03d..48310cca53fba 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/plugin.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/plugin.ts @@ -165,12 +165,16 @@ export class SyntheticsPlugin registerSyntheticsRoutesWithNavigation(coreSetup, plugins); coreSetup.getStartServices().then(([coreStart, clientPluginsStart]) => { + const browserConfig = this.initContext.config.get<{ + experimental?: { ccs?: { enabled?: boolean } }; + }>(); kibanaService.init({ coreSetup, coreStart, startPlugins: clientPluginsStart, isDev: this.initContext.env.mode.dev, isServerless: this._isServerless, + isCCSEnabled: !this._isServerless && (browserConfig.experimental?.ccs?.enabled ?? false), }); }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/utils/kibana_service/kibana_service.ts b/x-pack/solutions/observability/plugins/synthetics/public/utils/kibana_service/kibana_service.ts index 35ee6a7a322e5..0784914aa0fd1 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/utils/kibana_service/kibana_service.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/utils/kibana_service/kibana_service.ts @@ -19,6 +19,7 @@ class KibanaService { public setupPlugins!: ClientPluginsSetup; public isDev!: boolean; public isServerless!: boolean; + public isCCSEnabled!: boolean; public appMountParameters!: AppMountParameters; public startPlugins!: ClientPluginsStart; @@ -28,12 +29,14 @@ class KibanaService { startPlugins, isDev, isServerless, + isCCSEnabled, }: { coreSetup: CoreSetup; coreStart: CoreStart; startPlugins: ClientPluginsStart; isDev: boolean; isServerless: boolean; + isCCSEnabled: boolean; }) { this.coreSetup = coreSetup; this.coreStart = coreStart; @@ -42,6 +45,7 @@ class KibanaService { apiService.http = coreStart.http; this.isDev = isDev; this.isServerless = isServerless; + this.isCCSEnabled = isCCSEnabled; } public get toasts() { diff --git a/x-pack/solutions/observability/plugins/synthetics/server/constants/settings.ts b/x-pack/solutions/observability/plugins/synthetics/server/constants/settings.ts index cdf45888dd683..0aa85ab248621 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/constants/settings.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/constants/settings.ts @@ -16,6 +16,8 @@ export const DYNAMIC_SETTINGS_DEFAULTS: DynamicSettingsAttributes = { cc: [], bcc: [], }, + useAllRemoteClusters: false, + selectedRemoteClusters: [], }; export const DYNAMIC_SETTINGS_DEFAULT_ATTRIBUTES: DynamicSettingsAttributes = diff --git a/x-pack/solutions/observability/plugins/synthetics/server/lib.ts b/x-pack/solutions/observability/plugins/synthetics/server/lib.ts index 7962e9c2f3e66..2ccb4144e5fd7 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/lib.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/lib.ts @@ -48,6 +48,7 @@ export class SyntheticsEsClient { inspectableEsQueries: InspectResponse = []; uiSettings?: CoreRequestHandlerContext['uiSettings']; savedObjectsClient: SavedObjectsClientContract; + heartbeatIndices: string; constructor( savedObjectsClient: SavedObjectsClientContract, @@ -59,12 +60,13 @@ export class SyntheticsEsClient { heartbeatIndices?: string; } ) { - const { isDev = false, uiSettings, request } = options ?? {}; + const { isDev = false, uiSettings, request, heartbeatIndices } = options ?? {}; this.uiSettings = uiSettings; this.baseESClient = esClient; this.savedObjectsClient = savedObjectsClient; this.request = request; this.isDev = isDev; + this.heartbeatIndices = heartbeatIndices ?? SYNTHETICS_INDEX_PATTERN; this.inspectableEsQueries = []; this.getInspectEnabled().catch(() => {}); } @@ -76,7 +78,7 @@ export class SyntheticsEsClient { let res: any; let esError: any; - const esParams = { index: SYNTHETICS_INDEX_PATTERN, ignore_unavailable: true, ...params }; + const esParams = { index: this.heartbeatIndices, ignore_unavailable: true, ...params }; const startTimeNow = Date.now(); let esRequestStatus: RequestStatus = RequestStatus.PENDING; @@ -126,7 +128,7 @@ export class SyntheticsEsClient { ): Promise<{ responses: Array> }> { const searches: Array = []; for (const request of requests) { - searches.push({ index: SYNTHETICS_INDEX_PATTERN, ignore_unavailable: true }); + searches.push({ index: this.heartbeatIndices, ignore_unavailable: true }); searches.push(request); } @@ -153,7 +155,7 @@ export class SyntheticsEsClient { getInspectResponse({ esError, esRequestParams: { - index: SYNTHETICS_INDEX_PATTERN, + index: this.heartbeatIndices, ignore_unavailable: true, ...request, }, @@ -179,7 +181,7 @@ export class SyntheticsEsClient { let res: any; let esError: any; - const esParams = { index: SYNTHETICS_INDEX_PATTERN, ignore_unavailable: true, ...params }; + const esParams = { index: this.heartbeatIndices, ignore_unavailable: true, ...params }; try { res = await this.baseESClient.count(esParams, { @@ -196,7 +198,7 @@ export class SyntheticsEsClient { throw esError; } - return { result: res, indices: SYNTHETICS_INDEX_PATTERN }; + return { result: res, indices: this.heartbeatIndices }; } getSavedObjectsClient() { return this.savedObjectsClient; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/lib/remote_result_utils.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/lib/remote_result_utils.test.ts new file mode 100644 index 0000000000000..c9fdfbbfd2b2d --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/lib/remote_result_utils.test.ts @@ -0,0 +1,48 @@ +/* + * 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 { getRemoteClusterName, getRemoteMonitorInfo } from './remote_result_utils'; + +describe('remote_result_utils', () => { + describe('getRemoteClusterName', () => { + it('returns the cluster name for a CCS index', () => { + expect(getRemoteClusterName('cluster1:synthetics-browser-default')).toBe('cluster1'); + }); + + it('returns undefined for a local index', () => { + expect(getRemoteClusterName('synthetics-browser-default')).toBeUndefined(); + }); + + it('handles cluster names with hyphens', () => { + expect(getRemoteClusterName('my-remote-cluster:synthetics-http-default')).toBe( + 'my-remote-cluster' + ); + }); + + it('returns undefined for an empty string', () => { + expect(getRemoteClusterName('')).toBeUndefined(); + }); + }); + + describe('getRemoteMonitorInfo', () => { + it('returns remote info for a known remote cluster', () => { + expect(getRemoteMonitorInfo('cluster1:synthetics-browser-default')).toEqual({ + remoteName: 'cluster1', + }); + }); + + it('returns undefined for a local index', () => { + expect(getRemoteMonitorInfo('synthetics-browser-default')).toBeUndefined(); + }); + + it('returns remote info for a cluster with hyphens', () => { + expect(getRemoteMonitorInfo('my-remote-cluster:synthetics-http-default')).toEqual({ + remoteName: 'my-remote-cluster', + }); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/lib/remote_result_utils.ts b/x-pack/solutions/observability/plugins/synthetics/server/lib/remote_result_utils.ts new file mode 100644 index 0000000000000..6938215e2dadd --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/lib/remote_result_utils.ts @@ -0,0 +1,43 @@ +/* + * 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 { isCCSRemoteIndexName } from '@kbn/es-query'; +import type { RemoteMonitorInfo } from '../../common/runtime_types'; +import type { SyntheticsServerSetup } from '../types'; + +export const isCCSEnabled = ( + server: Pick +) => !server.isElasticsearchServerless && Boolean(server.config.experimental?.ccs?.enabled); + +/** + * Extracts the remote cluster name from an ES `_index` field. + * Returns undefined if the index is local. + * + * Example: "cluster1:synthetics-*" → "cluster1" + */ +export function getRemoteClusterName(index: string): string | undefined { + if (isCCSRemoteIndexName(index)) { + return index.substring(0, index.indexOf(':')); + } +} + +/** + * Builds a RemoteMonitorInfo object for a search hit if it originates from a remote cluster. + * Returns undefined for local hits. + * + * @param index - The `_index` field from the ES search hit + */ +export function getRemoteMonitorInfo(index: string): RemoteMonitorInfo | undefined { + const remoteName = getRemoteClusterName(index); + if (!remoteName) { + return undefined; + } + + return { + remoteName, + }; +} diff --git a/x-pack/solutions/observability/plugins/synthetics/server/queries/query_pings.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/queries/query_pings.test.ts index 0441e679191bd..76b6b2cb5f6c1 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/queries/query_pings.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/queries/query_pings.test.ts @@ -73,7 +73,13 @@ describe('queryPings', () => { const mockResponse = { body: { hits: { - hits: [{ _source: { '@timestamp': '2023-01-01T00:00:00Z' }, _id: 'doc1' }], + hits: [ + { + _source: { '@timestamp': '2023-01-01T00:00:00Z' }, + _id: 'doc1', + _index: 'synthetics-browser-default', + }, + ], total: { value: 1 }, }, }, @@ -163,4 +169,126 @@ describe('queryPings', () => { await expect(queryPings(params)).rejects.toThrow('Query failed'); expect(mockEsClient.search).toHaveBeenCalledTimes(1); }); + + describe('CCS remote decoration', () => { + it('should attach remote info to pings from a remote cluster', async () => { + const params = { + syntheticsEsClient: mockEsClient as SyntheticsEsClient, + dateRange: { from: '2023-01-01', to: '2023-01-02' }, + size: 10, + pageIndex: 0, + }; + + const mockResponse = { + body: { + hits: { + hits: [ + { + _id: 'doc1', + _index: 'cluster1:synthetics-browser-default', + _source: { '@timestamp': '2023-01-01T00:00:00Z' }, + }, + ], + total: { value: 1 }, + }, + }, + }; + + (mockEsClient.search as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await queryPings(params); + expect(result).toEqual({ + total: 1, + pings: [ + { + '@timestamp': '2023-01-01T00:00:00Z', + docId: 'doc1', + timestamp: '2023-01-01T00:00:00Z', + remote: { + remoteName: 'cluster1', + }, + }, + ], + }); + }); + + it('should not attach remote info for local pings', async () => { + const params = { + syntheticsEsClient: mockEsClient as SyntheticsEsClient, + dateRange: { from: '2023-01-01', to: '2023-01-02' }, + size: 10, + pageIndex: 0, + }; + + const mockResponse = { + body: { + hits: { + hits: [ + { + _id: 'doc1', + _index: 'synthetics-browser-default', + _source: { '@timestamp': '2023-01-01T00:00:00Z' }, + }, + ], + total: { value: 1 }, + }, + }, + }; + + (mockEsClient.search as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await queryPings(params); + expect(result).toEqual({ + total: 1, + pings: [ + { + '@timestamp': '2023-01-01T00:00:00Z', + docId: 'doc1', + timestamp: '2023-01-01T00:00:00Z', + }, + ], + }); + }); + + it('should attach remote info for unknown remote clusters', async () => { + const params = { + syntheticsEsClient: mockEsClient as SyntheticsEsClient, + dateRange: { from: '2023-01-01', to: '2023-01-02' }, + size: 10, + pageIndex: 0, + }; + + const mockResponse = { + body: { + hits: { + hits: [ + { + _id: 'doc1', + _index: 'unknown-cluster:synthetics-browser-default', + _source: { '@timestamp': '2023-01-01T00:00:00Z' }, + }, + ], + total: { value: 1 }, + }, + }, + }; + + (mockEsClient.search as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await queryPings(params); + expect(result).toEqual({ + total: 1, + pings: [ + { + '@timestamp': '2023-01-01T00:00:00Z', + docId: 'doc1', + timestamp: '2023-01-01T00:00:00Z', + remote: { + remoteName: 'unknown-cluster', + }, + }, + ], + }); + }); + }); }); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/queries/query_pings.ts b/x-pack/solutions/observability/plugins/synthetics/server/queries/query_pings.ts index c96ccd83b4b4d..deb5d28a5dbf5 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/queries/query_pings.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/queries/query_pings.ts @@ -17,6 +17,7 @@ import type { PingsResponse, } from '../../common/runtime_types'; import type { SyntheticsEsClient } from '../lib'; +import { getRemoteMonitorInfo } from '../lib/remote_result_utils'; import { SUMMARY_FILTER } from '../../common/constants/client_defaults'; const DEFAULT_PAGE_SIZE = 25; @@ -117,7 +118,7 @@ export async function queryPings( } = await syntheticsEsClient.search(searchBody); const pings: Ping[] = hits.map((doc: any) => { - const { _id, _source } = doc; + const { _id, _index, _source } = doc; // Calculate here the length of the content string in bytes, this is easier than in client JS, where // we don't have access to Buffer.byteLength. There are some hacky ways to do this in the // client but this is cleaner. @@ -126,7 +127,9 @@ export async function queryPings( httpBody.content_bytes = Buffer.byteLength(httpBody.content); } - return { ..._source, timestamp: _source['@timestamp'], docId: _id }; + const remote = getRemoteMonitorInfo(_index); + + return { ..._source, timestamp: _source['@timestamp'], docId: _id, ...(remote && { remote }) }; }); return { diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/default_alerts/default_alert_service.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/default_alerts/default_alert_service.test.ts index 3a52ebaf72d3f..e3c9ca908ee39 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/default_alerts/default_alert_service.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/default_alerts/default_alert_service.test.ts @@ -41,6 +41,8 @@ describe('DefaultAlertService', () => { defaultEmail: undefined, defaultStatusRuleEnabled: true, defaultTLSRuleEnabled: true, + useAllRemoteClusters: false, + selectedRemoteClusters: [], }); expect(soClient.get).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/dynamic_settings.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/dynamic_settings.ts index d889746eb5d64..f6af0489dfdb9 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/dynamic_settings.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/dynamic_settings.ts @@ -122,6 +122,8 @@ export const fromSettingsAttribute = ( defaultEmail: attr.defaultEmail, defaultStatusRuleEnabled: attr.defaultStatusRuleEnabled ?? true, defaultTLSRuleEnabled: attr.defaultTLSRuleEnabled ?? true, + useAllRemoteClusters: attr.useAllRemoteClusters ?? false, + selectedRemoteClusters: attr.selectedRemoteClusters ?? [], }; }; @@ -158,4 +160,6 @@ export const DynamicSettingsSchema = schema.object({ validate: validateInteger, }) ), + useAllRemoteClusters: schema.maybe(schema.boolean()), + selectedRemoteClusters: schema.maybe(schema.arrayOf(schema.string(), { maxSize: 100 })), }); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/runtime_types/settings.ts b/x-pack/solutions/observability/plugins/synthetics/server/runtime_types/settings.ts index def512e2f73a8..4490ffff2d16d 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/runtime_types/settings.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/runtime_types/settings.ts @@ -27,6 +27,8 @@ export const DynamicSettingsAttributesCodec = t.intersection([ defaultEmail: DefaultEmailCodec, defaultStatusRuleEnabled: t.boolean, defaultTLSRuleEnabled: t.boolean, + useAllRemoteClusters: t.boolean, + selectedRemoteClusters: t.array(t.string), }), ]); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/services/get_synthetics_indices.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/services/get_synthetics_indices.test.ts new file mode 100644 index 0000000000000..e3633adf1bae3 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/services/get_synthetics_indices.test.ts @@ -0,0 +1,90 @@ +/* + * 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_INDEX_PATTERN } from '../../common/constants'; +import { getSyntheticsIndices } from './get_synthetics_indices'; + +const buildEsClientMock = (remoteInfo: Record = {}) => ({ + cluster: { + remoteInfo: jest.fn().mockResolvedValue(remoteInfo), + }, +}); + +describe('getSyntheticsIndices (server)', () => { + it('returns only the local index when no clusters are selected and useAll is false', async () => { + const esClient = buildEsClientMock(); + + const result = await getSyntheticsIndices(esClient as any, { + useAllRemoteClusters: false, + selectedRemoteClusters: [], + }); + + expect(result).toEqual({ indices: SYNTHETICS_INDEX_PATTERN }); + expect(esClient.cluster.remoteInfo).not.toHaveBeenCalled(); + }); + + it('returns local + wildcard remote when useAllRemoteClusters is true', async () => { + const esClient = buildEsClientMock(); + + const result = await getSyntheticsIndices(esClient as any, { + useAllRemoteClusters: true, + selectedRemoteClusters: [], + }); + + expect(result).toEqual({ + indices: `${SYNTHETICS_INDEX_PATTERN},*:${SYNTHETICS_INDEX_PATTERN}`, + }); + expect(esClient.cluster.remoteInfo).not.toHaveBeenCalled(); + }); + + it('calls cluster.remoteInfo and includes only connected selected clusters', async () => { + const esClient = buildEsClientMock({ + 'cluster-a': { connected: true }, + 'cluster-b': { connected: false }, + 'cluster-c': { connected: true }, + }); + + const result = await getSyntheticsIndices(esClient as any, { + useAllRemoteClusters: false, + selectedRemoteClusters: ['cluster-a', 'cluster-b'], + }); + + expect(esClient.cluster.remoteInfo).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + indices: `${SYNTHETICS_INDEX_PATTERN},cluster-a:${SYNTHETICS_INDEX_PATTERN}`, + }); + }); + + it('returns only local index when all selected clusters are disconnected', async () => { + const esClient = buildEsClientMock({ + 'cluster-x': { connected: false }, + }); + + const result = await getSyntheticsIndices(esClient as any, { + useAllRemoteClusters: false, + selectedRemoteClusters: ['cluster-x'], + }); + + expect(result).toEqual({ indices: SYNTHETICS_INDEX_PATTERN }); + }); + + it('excludes clusters not in the selected list', async () => { + const esClient = buildEsClientMock({ + 'cluster-a': { connected: true }, + 'cluster-b': { connected: true }, + }); + + const result = await getSyntheticsIndices(esClient as any, { + useAllRemoteClusters: false, + selectedRemoteClusters: ['cluster-b'], + }); + + expect(result).toEqual({ + indices: `${SYNTHETICS_INDEX_PATTERN},cluster-b:${SYNTHETICS_INDEX_PATTERN}`, + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/services/get_synthetics_indices.ts b/x-pack/solutions/observability/plugins/synthetics/server/services/get_synthetics_indices.ts new file mode 100644 index 0000000000000..9a1e5771e8732 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/services/get_synthetics_indices.ts @@ -0,0 +1,39 @@ +/* + * 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 { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { getSyntheticsIndices as buildSyntheticsIndices } from '../../common/get_synthetics_indices'; +import type { SyntheticsCCSSettings } from '../../common/runtime_types'; +import type { RemoteCluster } from '../../common/get_synthetics_indices'; + +export const getSyntheticsIndices = async ( + esClient: ElasticsearchClient, + settings: SyntheticsCCSSettings +): Promise<{ indices: string }> => { + const { useAllRemoteClusters, selectedRemoteClusters } = settings; + + if (useAllRemoteClusters || (!useAllRemoteClusters && selectedRemoteClusters.length === 0)) { + return { + indices: buildSyntheticsIndices({ useAllRemoteClusters, selectedRemoteClusters }).join(','), + }; + } + + const clustersByName = await esClient.cluster.remoteInfo(); + const clusterNames = (clustersByName && Object.keys(clustersByName)) || []; + const remoteClusters: RemoteCluster[] = clusterNames.map((clusterName) => ({ + name: clusterName, + isConnected: clustersByName[clusterName].connected, + })); + + return { + indices: buildSyntheticsIndices({ + useAllRemoteClusters: settings.useAllRemoteClusters, + selectedRemoteClusters: settings.selectedRemoteClusters, + remoteClusters, + }).join(','), + }; +}; 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 6f13be82ce0e2..f9c4db76974b4 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 @@ -14,6 +14,9 @@ import { syntheticsServiceApiKey } from './saved_objects/service_api_key'; import { isTestUser, SyntheticsEsClient } from './lib'; import { SYNTHETICS_INDEX_PATTERN } from '../common/constants'; import { checkIndicesReadPrivileges } from './synthetics_service/authentication/check_has_privilege'; +import { getSyntheticsDynamicSettings } from './saved_objects/synthetics_settings'; +import { getSyntheticsIndices } from './services/get_synthetics_indices'; +import { isCCSEnabled } from './lib/remote_result_utils'; import type { SyntheticsRouteWrapper } from './routes/types'; export const syntheticsRouteWrapper: SyntheticsRouteWrapper = ( @@ -46,6 +49,21 @@ export const syntheticsRouteWrapper: SyntheticsRouteWrapper = ( // specifically needed for the synthetics service api key generation server.authSavedObjectsClient = savedObjectsClient; + let heartbeatIndices = SYNTHETICS_INDEX_PATTERN; + if (isCCSEnabled(server)) { + try { + const dynamicSettings = await getSyntheticsDynamicSettings(savedObjectsClient); + const ccsSettings = { + useAllRemoteClusters: dynamicSettings.useAllRemoteClusters ?? false, + selectedRemoteClusters: dynamicSettings.selectedRemoteClusters ?? [], + }; + const { indices } = await getSyntheticsIndices(esClient.asCurrentUser, ccsSettings); + heartbeatIndices = indices; + } catch (e) { + server.logger.warn(`Failed to resolve CCS indices, falling back to local: ${e.message}`); + } + } + const syntheticsEsClient = new SyntheticsEsClient( savedObjectsClient, esClient.asCurrentUser, @@ -53,7 +71,7 @@ export const syntheticsRouteWrapper: SyntheticsRouteWrapper = ( request, uiSettings, isDev: Boolean(server.isDev) && !isTestUser(server), - heartbeatIndices: SYNTHETICS_INDEX_PATTERN, + heartbeatIndices, } );