[Synthetics] Maintenance windows !! (#222174)

## Summary

fixes https://github.com/elastic/kibana/issues/211540

User will be able to choose maintenance window in the form 

<img width="1723" alt="image"
src="https://github.com/user-attachments/assets/c4d75aff-687f-40d3-a614-160e99ce9ac2"
/>

A callout will be displayed on the form 
<img width="1728" alt="image"
src="https://github.com/user-attachments/assets/124727bd-0bb6-4934-9406-a36c3584670a"
/>


### Task manager 
When changes are made to maintenance windows, those are sync via task
manager to private location monitors, public location monitors are
automatically synced as well in already existing task.


### Testing

Create a maintenance window in stack management UI, apply it to monitor,
make sure, it never runs during maintenance window.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Shahzad 2025-06-19 17:49:29 +02:00 committed by GitHub
parent c8d07ed3d6
commit b991e82700
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 1384 additions and 97 deletions

View file

@ -1160,6 +1160,7 @@
"locations",
"locations.id",
"locations.label",
"maintenance_windows",
"name",
"origin",
"project_id",

View file

@ -3757,6 +3757,9 @@
}
}
},
"maintenance_windows": {
"type": "keyword"
},
"name": {
"fields": {
"keyword": {

View file

@ -176,7 +176,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"space": "953a72d8962d829e7ea465849297c5e44d8e9a2d",
"spaces-usage-stats": "3abca98713c52af8b30300e386c7779b3025a20e",
"synthetics-dynamic-settings": "4b40a93eb3e222619bf4e7fe34a9b9e7ab91a0a7",
"synthetics-monitor": "5ceb25b6249bd26902c9b34273c71c3dce06dbea",
"synthetics-monitor": "078401644f1a6cecdd4294093df20f8ff4063405",
"synthetics-param": "3ebb744e5571de678b1312d5c418c8188002cf5e",
"synthetics-private-location": "8cecc9e4f39637d2f8244eb7985c0690ceab24be",
"synthetics-privates-locations": "f53d799d5c9bc8454aaa32c6abc99a899b025d5c",

View file

@ -10,6 +10,7 @@
export { AlertLifecycleStatusBadge } from './src/alert_lifecycle_status_badge';
export type { AlertLifecycleStatusBadgeProps } from './src/alert_lifecycle_status_badge';
export { MaintenanceWindowCallout } from './src/maintenance_window_callout';
export { useFetchActiveMaintenanceWindows } from './src/maintenance_window_callout/use_fetch_active_maintenance_windows';
export { AddMessageVariables } from './src/add_message_variables';
export * from './src/common/hooks';

View file

@ -154,6 +154,7 @@ export const DEFAULT_COMMON_FIELDS: CommonFields = {
[ConfigKey.PARAMS]: '',
[ConfigKey.LABELS]: {},
[ConfigKey.MAX_ATTEMPTS]: 2,
[ConfigKey.MAINTENANCE_WINDOWS]: [],
revision: 1,
};

View file

@ -78,6 +78,7 @@ export enum ConfigKey {
WAIT = 'wait',
MONITOR_QUERY_ID = 'id',
MAX_ATTEMPTS = 'max_attempts',
MAINTENANCE_WINDOWS = 'maintenance_windows',
}
export const secretKeys = [

View file

@ -46,6 +46,7 @@ export enum SYNTHETICS_API_URLS {
CERTS = '/internal/synthetics/certs',
SUGGESTIONS = `/internal/synthetics/suggestions`,
MAINTENANCE_WINDOWS = `/internal/synthetics/monitors/maintenance_windows`,
// Project monitor public endpoint
SYNTHETICS_MONITORS_PROJECT = '/api/synthetics/project/{projectName}/monitors',

View file

@ -87,6 +87,7 @@ export const CommonFieldsCodec = t.intersection([
[ConfigKey.ALERT_CONFIG]: AlertConfigsCodec,
[ConfigKey.PARAMS]: t.string,
[ConfigKey.LABELS]: t.record(t.string, t.string),
[ConfigKey.MAINTENANCE_WINDOWS]: t.array(t.string),
retest_on_failure: t.boolean,
}),
]);

View file

@ -64,6 +64,7 @@ export const ProjectMonitorCodec = t.intersection([
retestOnFailure: t.boolean,
fields: t.record(t.string, t.string),
'service.name': t.string,
maintenanceWindows: t.array(t.string),
}),
]);

View file

@ -54,6 +54,7 @@ export const OverviewStatusMetaDataCodec = t.intersection([
timestamp: t.string,
spaceId: t.string,
urls: t.string,
maintenanceWindows: t.array(t.string),
}),
]);

View file

@ -0,0 +1,37 @@
/*
* 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 { useFetchActiveMaintenanceWindows } from '@kbn/alerts-ui-shared';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { MwsCalloutContent } from './mws_callout_content';
import { ConfigKey } from '../../../../../../common/runtime_types';
import { useSelectedMonitor } from '../../monitor_details/hooks/use_selected_monitor';
import { ClientPluginsStart } from '../../../../../plugin';
export const MonitorMWsCallout = () => {
const { monitor } = useSelectedMonitor();
const services = useKibana<ClientPluginsStart>().services;
const { data } = useFetchActiveMaintenanceWindows(services, {
enabled: true,
});
if (!monitor) {
return null;
}
const monitorMWs = monitor[ConfigKey.MAINTENANCE_WINDOWS];
const hasMonitorMWs = monitorMWs && monitorMWs.length > 0;
if (data?.length && hasMonitorMWs) {
const activeMWs = data.filter((mw) => monitorMWs.includes(mw.id));
if (activeMWs) {
return <MwsCalloutContent activeMWs={activeMWs} />;
}
}
return null;
};

View file

@ -0,0 +1,35 @@
/*
* 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 { useFetchActiveMaintenanceWindows } from '@kbn/alerts-ui-shared';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useSelector } from 'react-redux';
import { MwsCalloutContent } from './mws_callout_content';
import { ClientPluginsStart } from '../../../../../plugin';
import { selectOverviewStatus } from '../../../state/overview_status';
export const MonitorsMWsCallout = () => {
const { allConfigs } = useSelector(selectOverviewStatus);
const services = useKibana<ClientPluginsStart>().services;
const { data } = useFetchActiveMaintenanceWindows(services, {
enabled: true,
});
const monitorMWs = new Set(allConfigs?.flatMap((config) => config.maintenanceWindows ?? []));
const hasMonitorMWs = monitorMWs && monitorMWs.size > 0;
if (data?.length && hasMonitorMWs) {
const activeMWs = data.filter((mw) => monitorMWs.has(mw.id));
if (activeMWs.length) {
return <MwsCalloutContent activeMWs={activeMWs} />;
}
}
return null;
};

View file

@ -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 React from 'react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { MaintenanceWindow } from '@kbn/alerts-ui-shared/src/maintenance_window_callout/types';
import { MaintenanceWindowsLink } from '../../monitor_add_edit/fields/maintenance_windows/create_maintenance_windows_btn';
export const MwsCalloutContent = ({ activeMWs }: { activeMWs: MaintenanceWindow[] }) => {
if (activeMWs.length) {
return (
<>
<EuiCallOut
title={i18n.translate(
'xpack.synthetics.maintenanceWindowCallout.maintenanceWindowActive.monitors',
{
defaultMessage: 'Maintenance windows are active',
}
)}
color="warning"
iconType="iInCircle"
data-test-subj="maintenanceWindowCallout"
>
{i18n.translate(
'xpack.synthetics.maintenanceWindowCallout.maintenanceWindowActiveDescription.monitors',
{
defaultMessage:
'Monitors are stopped while maintenance windows are running. Active maintenance windows are ',
}
)}
{activeMWs.map((mws, index) => (
<span key={mws.id}>
<MaintenanceWindowsLink id={mws.id} label={mws.title} />
{index !== activeMWs.length - 1 ? <span>, </span> : <span>.</span>}
</span>
))}
</EuiCallOut>
<EuiSpacer size="s" />
</>
);
} else {
return null;
}
};

View file

@ -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 React from 'react';
import { EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
export const MaintenanceWindowsLink = ({ id, label }: { id?: string; label?: string }) => {
const { http } = useKibana().services;
if (!id) {
return (
<EuiLink
external
data-test-subj="syntheticsCreateMaintenanceWindowsBtnButton"
href={http?.basePath.prepend(
'/app/management/insightsAndAlerting/maintenanceWindows/create'
)}
target="_blank"
>
{i18n.translate('xpack.synthetics.monitorConfig.maintenanceWindows.createButton', {
defaultMessage: 'Create',
})}
</EuiLink>
);
} else {
return (
<EuiLink
external
data-test-subj="syntheticsEditMaintenanceWindowsBtnButton"
href={http?.basePath.prepend(
`/app/management/insightsAndAlerting/maintenanceWindows/edit/${id}`
)}
target="_blank"
>
{label}
</EuiLink>
);
}
};

View file

@ -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 React from 'react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useMaintenanceWindows } from './use_maintenance_windows';
export interface MaintenanceWindowsFieldProps {
fullWidth?: boolean;
onChange: (value: string[]) => void;
value?: string[];
readOnly?: boolean;
}
export const MaintenanceWindowsField = ({
value,
readOnly,
onChange,
fullWidth,
}: MaintenanceWindowsFieldProps) => {
const { data } = useMaintenanceWindows();
const options: Array<EuiComboBoxOptionOption<string>> =
data?.data?.map((option) => ({
value: option.id,
label: option.title,
})) ?? [];
return (
<EuiComboBox<string>
placeholder={i18n.translate('xpack.synthetics.monitorConfig.maintenanceWindows.placeholder', {
defaultMessage: 'Select maintenance windows',
})}
options={options}
onChange={(newValue) => {
onChange(newValue.map((option) => option.value as string));
}}
defaultValue={[]}
selectedOptions={options.filter((option) => value?.includes(option.value as string))}
fullWidth={fullWidth}
isDisabled={readOnly}
/>
);
};

View file

@ -0,0 +1,27 @@
/*
* 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 { useDispatch, useSelector } from 'react-redux';
import { useEffect } from 'react';
import {
getMaintenanceWindowsAction,
selectMaintenanceWindowsState,
} from '../../../../state/maintenance_windows';
export function useMaintenanceWindows() {
const dispatch = useDispatch();
const { isLoading, data } = useSelector(selectMaintenanceWindowsState);
useEffect(() => {
dispatch(getMaintenanceWindowsAction.get());
}, [dispatch]);
return {
isLoading,
data,
};
}

View file

@ -20,6 +20,7 @@ export const Field = memo<Props>(
component: Component,
helpText,
label,
labelAppend,
ariaLabel,
props,
fieldKey,
@ -57,6 +58,7 @@ export const Field = memo<Props>(
'aria-label': ariaLabel,
helpText,
fullWidth: true,
labelAppend,
};
return controlled ? (

View file

@ -30,6 +30,8 @@ import {
EuiBadge,
EuiToolTip,
} from '@elastic/eui';
import { MaintenanceWindowsLink } from '../fields/maintenance_windows/create_maintenance_windows_btn';
import { MaintenanceWindowsFieldProps } from '../fields/maintenance_windows/maintenance_windows';
import { kibanaService } from '../../../../../utils/kibana_service';
import {
PROFILE_OPTIONS,
@ -60,6 +62,7 @@ import {
KeyValuePairsField,
TextArea,
ThrottlingWrapper,
MaintenanceWindowsFieldWrapper,
} from './field_wrappers';
import { useMonitorName } from '../../../hooks/use_monitor_name';
import {
@ -1676,4 +1679,26 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({
'data-test-subj': 'syntheticsEnableAttemptSwitch',
}),
},
[ConfigKey.MAINTENANCE_WINDOWS]: {
fieldKey: ConfigKey.MAINTENANCE_WINDOWS,
label: i18n.translate('xpack.synthetics.monitorConfig.maintenanceWindows.label', {
defaultMessage: 'Maintenance windows',
}),
controlled: true,
component: MaintenanceWindowsFieldWrapper,
helpText: i18n.translate('xpack.synthetics.monitorConfig.maintenanceWindows.helpText', {
defaultMessage:
'A list of maintenance windows to apply to this monitor. The monitor will not run during these times.',
}),
props: ({ field, setValue, trigger }): MaintenanceWindowsFieldProps => ({
readOnly,
onChange: async (value) => {
setValue(ConfigKey.MAINTENANCE_WINDOWS, value);
await trigger(ConfigKey.MAINTENANCE_WINDOWS);
},
fullWidth: true,
value: field?.value as string[],
}),
labelAppend: <MaintenanceWindowsLink />,
},
});

View file

@ -28,6 +28,10 @@ import {
EuiTextArea,
EuiTextAreaProps,
} from '@elastic/eui';
import {
MaintenanceWindowsField,
MaintenanceWindowsFieldProps,
} from '../fields/maintenance_windows/maintenance_windows';
import {
ThrottlingConfigField,
ThrottlingConfigFieldProps,
@ -154,3 +158,8 @@ export const ResponseBodyIndexField = React.forwardRef<unknown, DefaultResponseB
export const ThrottlingWrapper = React.forwardRef<unknown, ThrottlingConfigFieldProps>(
(props, _ref) => <ThrottlingConfigField {...props} />
);
export const MaintenanceWindowsFieldWrapper = React.forwardRef<
unknown,
MaintenanceWindowsFieldProps
>((props, _ref) => <MaintenanceWindowsField {...props} />);

View file

@ -25,6 +25,20 @@ const DEFAULT_DATA_OPTIONS = (readOnly: boolean) => ({
],
});
const MAINTENANCE_WINDOWS_OPTIONS = (readOnly: boolean) => ({
title: i18n.translate('xpack.synthetics.monitorConfig.section.maintenanceWindows.title', {
defaultMessage: 'Maintenance windows',
}),
description: i18n.translate(
'xpack.synthetics.monitorConfig.section.maintenanceWindows.description',
{
defaultMessage:
'Configure maintenance windows to prevent alerts from being triggered during scheduled downtime.',
}
),
components: [FIELD(readOnly)[ConfigKey.MAINTENANCE_WINDOWS]],
});
const HTTP_ADVANCED = (readOnly: boolean) => ({
requestConfig: {
title: i18n.translate('xpack.synthetics.monitorConfig.section.requestConfiguration.title', {
@ -206,6 +220,7 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({
],
advanced: [
DEFAULT_DATA_OPTIONS(readOnly),
MAINTENANCE_WINDOWS_OPTIONS(readOnly),
HTTP_ADVANCED(readOnly).requestConfig,
HTTP_ADVANCED(readOnly).responseConfig,
HTTP_ADVANCED(readOnly).responseChecks,
@ -227,6 +242,7 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({
],
advanced: [
DEFAULT_DATA_OPTIONS(readOnly),
MAINTENANCE_WINDOWS_OPTIONS(readOnly),
TCP_ADVANCED(readOnly).requestConfig,
TCP_ADVANCED(readOnly).responseChecks,
TLS_OPTIONS(readOnly),
@ -255,6 +271,7 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({
FIELD(readOnly)[ConfigKey.NAMESPACE],
],
},
MAINTENANCE_WINDOWS_OPTIONS(readOnly),
...BROWSER_ADVANCED(readOnly),
],
},
@ -281,6 +298,7 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({
FIELD(readOnly)[ConfigKey.NAMESPACE],
],
},
MAINTENANCE_WINDOWS_OPTIONS(readOnly),
...BROWSER_ADVANCED(readOnly),
],
},
@ -297,6 +315,10 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({
FIELD(readOnly)[ConfigKey.MAX_ATTEMPTS],
FIELD(readOnly)[AlertConfigKey.STATUS_ENABLED],
],
advanced: [DEFAULT_DATA_OPTIONS(readOnly), ICMP_ADVANCED(readOnly).requestConfig],
advanced: [
DEFAULT_DATA_OPTIONS(readOnly),
MAINTENANCE_WINDOWS_OPTIONS(readOnly),
ICMP_ADVANCED(readOnly).requestConfig,
],
},
});

View file

@ -77,9 +77,10 @@ export interface FieldMeta<TFieldKey extends keyof FormConfig> {
fieldKey: keyof FormConfig;
component: React.ComponentType<any>;
label?: string | React.ReactNode;
labelAppend?: React.ReactNode;
ariaLabel?: string;
helpText?: string | React.ReactNode;
hidden?: (depenencies: unknown[]) => boolean;
hidden?: (dependencies: unknown[]) => boolean;
props?: (params: {
field?: ControllerRenderProps<FormConfig, TFieldKey>;
formState: FormState<FormConfig>;
@ -166,4 +167,5 @@ export interface FieldMap {
[ConfigKey.IPV4]: FieldMeta<ConfigKey.IPV4>;
[ConfigKey.MAX_ATTEMPTS]: FieldMeta<ConfigKey.MAX_ATTEMPTS>;
[ConfigKey.LABELS]: FieldMeta<ConfigKey.LABELS>;
[ConfigKey.MAINTENANCE_WINDOWS]: FieldMeta<ConfigKey.MAINTENANCE_WINDOWS>;
}

View file

@ -9,6 +9,7 @@ import React from 'react';
import { EuiTitle, EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { LoadWhenInView } from '@kbn/observability-shared-plugin/public';
import { MonitorMWsCallout } from '../../common/mws_callout/monitor_mws_callout';
import { SummaryPanel } from './summary_panel';
import { useMonitorDetailsPage } from '../use_monitor_details_page';
@ -34,6 +35,7 @@ export const MonitorSummary = () => {
return (
<MonitorPendingWrapper>
<MonitorMWsCallout />
<SummaryPanel dateLabel={dateLabel} from={from} to={to} />
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="m" wrap={true} responsive={false}>

View file

@ -0,0 +1,22 @@
/*
* 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 { useKibana } from '@kbn/kibana-react-plugin/public';
import { useFetchActiveMaintenanceWindows } from '@kbn/alerts-ui-shared';
import { OverviewStatusMetaData } from '../../../../../../common/runtime_types';
import { ClientPluginsStart } from '../../../../../plugin';
export const useMonitorMWs = (monitor: OverviewStatusMetaData) => {
const services = useKibana<ClientPluginsStart>().services;
const { data } = useFetchActiveMaintenanceWindows(services, {
enabled: true,
});
const monitorMWs = monitor.maintenanceWindows;
return { activeMWs: data?.filter((mw) => monitorMWs?.includes(mw.id)) ?? [] };
};

View file

@ -9,6 +9,7 @@ import React from 'react';
import { Redirect } from 'react-router-dom';
import { useTrackPageview } from '@kbn/observability-shared-plugin/public';
import { MonitorsMWsCallout } from '../common/mws_callout/monitors_mws_callout';
import { DisabledCallout } from './management/disabled_callout';
import { useOverviewStatus } from './hooks/use_overview_status';
import { GETTING_STARTED_ROUTE } from '../../../../../common/constants';
@ -54,6 +55,7 @@ export const MonitorManagementPage: React.FC = () => {
errorBody={labels.ERROR_HEADING_BODY}
>
<DisabledCallout total={absoluteTotal} />
<MonitorsMWsCallout />
<MonitorListContainer isEnabled={isEnabled} monitorListProps={monitorListProps} />
</Loader>
{showEmptyState && <EnablementEmptyState />}

View file

@ -20,11 +20,13 @@ import {
EuiLink,
EuiSpacer,
EuiSkeletonText,
EuiIcon,
} from '@elastic/eui';
import { useDispatch, useSelector } from 'react-redux';
import styled from '@emotion/styled';
import { i18n } from '@kbn/i18n';
import { useMonitorMWs } from '../../../hooks/use_monitor_mws';
import { MetricErrorIcon } from './metric_error_icon';
import { OverviewStatusMetaData } from '../../../../../../../../common/runtime_types';
import { isTestRunning, manualTestRunSelector } from '../../../../../state/manual_test_runs';
@ -61,6 +63,7 @@ export const MetricItemIcon = ({
});
const dispatch = useDispatch();
const { activeMWs } = useMonitorMWs(monitor);
const inProgress = isTestRunning(testNowRun);
@ -83,6 +86,23 @@ export const MetricItemIcon = ({
);
}
if (activeMWs.length) {
return (
<Container>
<EuiToolTip
content={i18n.translate(
'xpack.synthetics.metricItemIcon.euiButtonIcon.maintenanceWindowActive',
{
defaultMessage: 'Monitor is stopped while maintenance windows are running.',
}
)}
>
<EuiIcon color="warning" data-test-subj="syntheticsMetricItemIconButton" type="pause" />
</EuiToolTip>
</Container>
);
}
const closePopover = () => {
dispatch(toggleErrorPopoverOpen(null));
};

View file

@ -12,6 +12,7 @@ import { Provider as ReduxProvider } from 'react-redux';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { Subject } from 'rxjs';
import { Store } from 'redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SyntheticsRefreshContextProvider } from './synthetics_refresh_context';
import { SyntheticsDataViewContextProvider } from './synthetics_data_view_context';
import { SyntheticsAppProps } from './synthetics_settings_context';
@ -20,6 +21,8 @@ import { storage, store } from '../state';
export const SyntheticsSharedContext: React.FC<
React.PropsWithChildren<SyntheticsAppProps & { reload$?: Subject<boolean>; reduxStore?: Store }>
> = ({ reduxStore, coreStart, setupPlugins, startPlugins, children, darkMode, reload$ }) => {
const queryClient = new QueryClient();
return (
<KibanaContextProvider
services={{
@ -46,20 +49,22 @@ export const SyntheticsSharedContext: React.FC<
>
<EuiThemeProvider darkMode={darkMode}>
<ReduxProvider store={reduxStore ?? store}>
<SyntheticsRefreshContextProvider reload$={reload$}>
<SyntheticsDataViewContextProvider dataViews={startPlugins.dataViews}>
<RedirectAppLinks
coreStart={{
application: coreStart.application,
}}
style={{
height: '100%',
}}
>
{children}
</RedirectAppLinks>
</SyntheticsDataViewContextProvider>
</SyntheticsRefreshContextProvider>
<QueryClientProvider client={queryClient}>
<SyntheticsRefreshContextProvider reload$={reload$}>
<SyntheticsDataViewContextProvider dataViews={startPlugins.dataViews}>
<RedirectAppLinks
coreStart={{
application: coreStart.application,
}}
style={{
height: '100%',
}}
>
{children}
</RedirectAppLinks>
</SyntheticsDataViewContextProvider>
</SyntheticsRefreshContextProvider>
</QueryClientProvider>
</ReduxProvider>
</EuiThemeProvider>
</KibanaContextProvider>

View file

@ -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 type { FindMaintenanceWindowsResult } from '@kbn/alerting-plugin/server/application/maintenance_window/methods/find/types';
import { createAsyncAction } from '../utils/actions';
export const getMaintenanceWindowsAction = createAsyncAction<void, FindMaintenanceWindowsResult>(
'GET MAINTENANCE WINDOWS'
);

View file

@ -0,0 +1,19 @@
/*
* 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 { FindMaintenanceWindowsResult } from '@kbn/alerting-plugin/server/application/maintenance_window/methods/find/types';
import { INITIAL_REST_VERSION } from '../../../../../common/constants';
import { apiService } from '../../../../utils/api_service/api_service';
export const getMaintenanceWindows = async (): Promise<FindMaintenanceWindowsResult> => {
return apiService.get<FindMaintenanceWindowsResult>(
'/internal/alerting/rules/maintenance_window/_find',
{
version: INITIAL_REST_VERSION,
}
);
};

View file

@ -0,0 +1,29 @@
/*
* 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 { takeLeading } from 'redux-saga/effects';
import { i18n } from '@kbn/i18n';
import { getMaintenanceWindows } from './api';
import { getMaintenanceWindowsAction } from './actions';
import { fetchEffectFactory } from '../utils/fetch_effect';
export function* getMaintenanceWindowsEffect() {
yield takeLeading(
getMaintenanceWindowsAction.get,
fetchEffectFactory(
getMaintenanceWindows,
getMaintenanceWindowsAction.success,
getMaintenanceWindowsAction.fail,
undefined,
getFailMessage
)
);
}
const getFailMessage = i18n.translate('xpack.synthetics.settings.mws.failed', {
defaultMessage: 'Failed to fetch maintenance windows.',
});

View file

@ -0,0 +1,38 @@
/*
* 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 { FindMaintenanceWindowsResult } from '@kbn/alerting-plugin/server/application/maintenance_window/methods/find/types';
import { getMaintenanceWindowsAction } from './actions';
export interface MaintenanceWindowsState {
isLoading?: boolean;
data?: FindMaintenanceWindowsResult;
}
const initialState: MaintenanceWindowsState = {
isLoading: false,
};
export const maintenanceWindowsReducer = createReducer(initialState, (builder) => {
builder
.addCase(getMaintenanceWindowsAction.get, (state) => {
state.isLoading = true;
})
.addCase(getMaintenanceWindowsAction.success, (state, action) => {
state.isLoading = false;
state.data = action.payload;
})
.addCase(getMaintenanceWindowsAction.fail, (state, action) => {
state.isLoading = false;
});
});
export * from './actions';
export * from './effects';
export * from './selectors';
export * from './api';

View file

@ -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 { AppState } from '..';
export const selectMaintenanceWindowsState = (state: AppState) => state.maintenanceWindows;

View file

@ -6,6 +6,7 @@
*/
import { all, fork } from 'redux-saga/effects';
import { getMaintenanceWindowsEffect } from './maintenance_windows';
import { getCertsListEffect } from './certs';
import {
addGlobalParamEffect,
@ -82,6 +83,7 @@ export const rootEffect = function* root(): Generator {
fork(refreshOverviewTrendStats),
fork(inspectStatusRuleEffect),
fork(inspectTLSRuleEffect),
fork(getMaintenanceWindowsEffect),
...privateLocationsEffects.map((effect) => fork(effect)),
]);
};

View file

@ -7,6 +7,7 @@
import { combineReducers } from '@reduxjs/toolkit';
import { maintenanceWindowsReducer, MaintenanceWindowsState } from './maintenance_windows';
import { certsListReducer, CertsListState } from './certs';
import { certificatesReducer, CertificatesState } from './certificates/certificates';
import { globalParamsReducer, GlobalParamsState } from './global_params';
@ -48,6 +49,7 @@ export interface SyntheticsAppState {
serviceLocations: ServiceLocationsState;
syntheticsEnablement: SyntheticsEnablementState;
ui: UiState;
maintenanceWindows: MaintenanceWindowsState;
}
export const rootReducer = combineReducers<SyntheticsAppState>({
@ -70,4 +72,5 @@ export const rootReducer = combineReducers<SyntheticsAppState>({
serviceLocations: serviceLocationsReducer,
syntheticsEnablement: syntheticsEnablementReducer,
ui: uiReducer,
maintenanceWindows: maintenanceWindowsReducer,
});

View file

@ -163,6 +163,7 @@ export const mockState: SyntheticsAppState = {
loading: false,
error: null,
},
maintenanceWindows: {},
};
function getBrowserJourneyMockSlice() {

View file

@ -108,6 +108,7 @@ describe('AddNewMonitorsPublicAPI', () => {
'url.port': null,
urls: '',
labels: {},
maintenance_windows: [],
});
});
it('should normalize icmp', async () => {
@ -145,6 +146,7 @@ describe('AddNewMonitorsPublicAPI', () => {
type: 'icmp',
wait: '1',
labels: {},
maintenance_windows: [],
});
});
it('should normalize http', async () => {
@ -204,6 +206,7 @@ describe('AddNewMonitorsPublicAPI', () => {
urls: '',
username: '',
labels: {},
maintenance_windows: [],
});
});
it('should normalize browser', async () => {
@ -259,6 +262,7 @@ describe('AddNewMonitorsPublicAPI', () => {
'url.port': null,
urls: '',
labels: {},
maintenance_windows: [],
});
});
});

View file

@ -96,6 +96,7 @@ describe('syncEditedMonitor', () => {
const syntheticsMonitorClient = new SyntheticsMonitorClient(syntheticsService, serverMock);
syntheticsService.editConfig = jest.fn();
syntheticsService.getMaintenanceWindows = jest.fn();
it('includes the isEdit flag', async () => {
await syncEditedMonitor({
@ -117,7 +118,9 @@ describe('syncEditedMonitor', () => {
expect.objectContaining({
configId: '7af7e2f0-d5dc-11ec-87ac-bdfdb894c53d',
}),
])
]),
true,
undefined
);
expect(serverMock.authSavedObjectsClient?.update).toHaveBeenCalledWith(

View file

@ -182,6 +182,7 @@ describe('current status route', () => {
"isStatusAlertEnabled": false,
"locationId": "europe_germany",
"locationLabel": "Europe - Germany",
"maintenanceWindows": undefined,
"monitorQueryId": "id2",
"name": "test monitor 2",
"projectId": "project-id",
@ -213,6 +214,7 @@ describe('current status route', () => {
"isStatusAlertEnabled": false,
"locationId": "asia_japan",
"locationLabel": "Asia/Pacific - Japan",
"maintenanceWindows": undefined,
"monitorQueryId": "id1",
"name": "test monitor 1",
"projectId": "project-id",
@ -234,6 +236,7 @@ describe('current status route', () => {
"isStatusAlertEnabled": false,
"locationId": "asia_japan",
"locationLabel": "Asia/Pacific - Japan",
"maintenanceWindows": undefined,
"monitorQueryId": "id2",
"name": "test monitor 2",
"projectId": "project-id",
@ -344,6 +347,7 @@ describe('current status route', () => {
"isStatusAlertEnabled": false,
"locationId": "europe_germany",
"locationLabel": "Europe - Germany",
"maintenanceWindows": undefined,
"monitorQueryId": "id2",
"name": "test monitor 2",
"projectId": "project-id",
@ -375,6 +379,7 @@ describe('current status route', () => {
"isStatusAlertEnabled": false,
"locationId": "asia_japan",
"locationLabel": "Asia/Pacific - Japan",
"maintenanceWindows": undefined,
"monitorQueryId": "id1",
"name": "test monitor 1",
"projectId": "project-id",
@ -396,6 +401,7 @@ describe('current status route', () => {
"isStatusAlertEnabled": false,
"locationId": "asia_japan",
"locationLabel": "Asia/Pacific - Japan",
"maintenanceWindows": undefined,
"monitorQueryId": "id2",
"name": "test monitor 2",
"projectId": "project-id",
@ -456,6 +462,7 @@ describe('current status route', () => {
"isStatusAlertEnabled": false,
"locationId": "asia_japan",
"locationLabel": "Asia/Pacific - Japan",
"maintenanceWindows": undefined,
"monitorQueryId": "id1",
"name": "test monitor 1",
"projectId": "project-id",
@ -477,6 +484,7 @@ describe('current status route', () => {
"isStatusAlertEnabled": false,
"locationId": "asia_japan",
"locationLabel": "Asia/Pacific - Japan",
"maintenanceWindows": undefined,
"monitorQueryId": "id2",
"name": "test monitor 2",
"projectId": "project-id",
@ -498,6 +506,7 @@ describe('current status route', () => {
"isStatusAlertEnabled": false,
"locationId": "europe_germany",
"locationLabel": "Europe - Germany",
"maintenanceWindows": undefined,
"monitorQueryId": "id2",
"name": "test monitor 2",
"projectId": "project-id",

View file

@ -350,6 +350,7 @@ export class OverviewStatusService {
ConfigKey.PROJECT_ID,
ConfigKey.ALERT_CONFIG,
ConfigKey.URLS,
ConfigKey.MAINTENANCE_WINDOWS,
],
});
}
@ -371,6 +372,7 @@ export class OverviewStatusService {
updated_at: monitor.updated_at,
spaceId: monitor.namespaces?.[0],
urls: monitor.attributes[ConfigKey.URLS],
maintenanceWindows: monitor.attributes[ConfigKey.MAINTENANCE_WINDOWS]?.map((mw) => mw),
};
}
}

View file

@ -164,6 +164,7 @@ describe('Monitor migrations v8.7.0 -> v8.8.0', () => {
'url.port': null,
urls: 'https://elastic.co',
labels: {},
maintenance_windows: [],
},
coreMigrationVersion: '8.8.0',
created_at: '2023-03-31T20:31:24.177Z',

View file

@ -246,6 +246,9 @@ export const getSyntheticsMonitorSavedObjectType = (
},
},
},
maintenance_windows: {
type: 'keyword',
},
},
},
management: {
@ -269,6 +272,16 @@ export const getSyntheticsMonitorSavedObjectType = (
},
],
},
'2': {
changes: [
{
type: 'mappings_addition',
addedMappings: {
maintenance_windows: { type: 'keyword' },
},
},
],
},
},
};
};

View file

@ -7,6 +7,7 @@
import { Logger } from '@kbn/logging';
import { isEmpty } from 'lodash';
import { MaintenanceWindow } from '@kbn/alerting-plugin/server/application/maintenance_window/types';
import { ConfigKey, MonitorFields } from '../../../common/runtime_types';
import { ParsedVars, replaceVarsWithParams } from './lightweight_param_formatter';
import variableParser from './variable_parser';
@ -83,3 +84,41 @@ export const secondsToCronFormatter: FormatterFn = (fields, key) => {
export const maxAttemptsFormatter: FormatterFn = (fields, key) => {
return (fields[key] as number) ?? 2;
};
enum Frequency {
YEARLY,
MONTHLY,
WEEKLY,
DAILY,
HOURLY,
MINUTELY,
SECONDLY,
}
function frequencyToString(value?: number): string | undefined {
if (value === undefined || value === null) {
return;
}
const name = Frequency[value];
return name ? name.toLowerCase() : 'unknown';
}
export const formatMWs = (mws?: MaintenanceWindow[], strRes = true) => {
if (!mws) {
return;
}
const formatted = mws.map((mw) => {
const mwRule = mw?.rRule;
if (mw && mwRule) {
return {
...mwRule,
freq: frequencyToString(mwRule.freq),
duration: `${mw.duration}ms`,
};
}
});
if (!strRes) {
return formatted;
}
return JSON.stringify(formatted);
};

View file

@ -38,6 +38,7 @@ export const commonFormatters: CommonFormatMap = {
[ConfigKey.MONITOR_QUERY_ID]: stringToJsonFormatter,
[ConfigKey.PARAMS]: null,
[ConfigKey.MAX_ATTEMPTS]: null,
[ConfigKey.MAINTENANCE_WINDOWS]: null,
retest_on_failure: null,
[ConfigKey.SCHEDULE]: (fields) =>
JSON.stringify(

View file

@ -7,15 +7,38 @@
import { ConfigKey, MonitorTypeEnum } from '../../../../common/runtime_types';
import { formatSyntheticsPolicy } from './format_synthetics_policy';
import { PROFILE_VALUES_ENUM, PROFILES_MAP } from '../../../../common/constants/monitor_defaults';
import { MaintenanceWindow } from '@kbn/alerting-plugin/server/application/maintenance_window/types';
const gParams = { proxyUrl: 'https://proxy.com' };
const testMW = [
{
id: '190bd51b-985a-4553-9fba-57222ddde6b7',
title: 'test',
enabled: true,
duration: 1800000,
expirationDate: '2026-06-10T11:43:25.175Z',
events: [{ gte: '2025-06-10T11:40:29.124Z', lte: '2025-06-10T12:10:29.124Z' }],
rRule: { dtstart: '2025-06-10T11:40:29.124Z', tzid: 'Europe/Berlin', freq: 0, count: 1 },
createdBy: 'elastic',
updatedBy: 'elastic',
createdAt: '2025-06-10T11:43:25.176Z',
updatedAt: '2025-06-10T11:43:25.176Z',
eventStartTime: '2025-06-10T11:40:29.124Z',
eventEndTime: '2025-06-10T12:10:29.124Z',
status: 'running',
categoryIds: null,
scopedQuery: null,
},
] as MaintenanceWindow[];
describe('formatSyntheticsPolicy', () => {
it('formats browser policy', () => {
const { formattedPolicy } = formatSyntheticsPolicy(
testNewPolicy,
MonitorTypeEnum.BROWSER,
browserConfig,
gParams
gParams,
testMW
);
expect(formattedPolicy).toEqual({
@ -142,6 +165,9 @@ describe('formatSyntheticsPolicy', () => {
username: {
type: 'text',
},
maintenance_windows: {
type: 'yaml',
},
},
},
],
@ -242,6 +268,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text',
value: 'tcp',
},
maintenance_windows: { type: 'yaml' },
},
},
],
@ -315,6 +342,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text',
value: '1s',
},
maintenance_windows: { type: 'yaml' },
},
},
],
@ -432,6 +460,11 @@ describe('formatSyntheticsPolicy', () => {
type: 'text',
value: 'browser',
},
maintenance_windows: {
type: 'yaml',
value:
'[{"dtstart":"2025-06-10T11:40:29.124Z","tzid":"Europe/Berlin","freq":"yearly","count":1,"duration":"1800000ms"}]',
},
},
},
{
@ -473,7 +506,8 @@ describe('formatSyntheticsPolicy', () => {
...httpPolicy,
[ConfigKey.METADATA]: { is_tls_enabled: isTLSEnabled },
},
gParams
gParams,
[]
);
expect(formattedPolicy).toEqual({
@ -628,6 +662,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text',
value: '"admin"',
},
maintenance_windows: { type: 'yaml' },
},
},
],
@ -728,6 +763,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text',
value: 'tcp',
},
maintenance_windows: { type: 'yaml' },
},
},
],
@ -801,6 +837,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text',
value: '1s',
},
maintenance_windows: { type: 'yaml' },
},
},
],
@ -897,6 +934,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text',
value: 'browser',
},
maintenance_windows: { type: 'yaml' },
},
},
{
@ -987,6 +1025,7 @@ const testNewPolicy = {
origin: { type: 'text' },
'monitor.project.id': { type: 'text' },
'monitor.project.name': { type: 'text' },
maintenance_windows: { type: 'yaml' },
},
},
],
@ -1026,6 +1065,7 @@ const testNewPolicy = {
origin: { type: 'text' },
'monitor.project.id': { type: 'text' },
'monitor.project.name': { type: 'text' },
maintenance_windows: { type: 'yaml' },
},
},
],
@ -1056,6 +1096,7 @@ const testNewPolicy = {
origin: { type: 'text' },
'monitor.project.id': { type: 'text' },
'monitor.project.name': { type: 'text' },
maintenance_windows: { type: 'yaml' },
},
},
],
@ -1094,6 +1135,7 @@ const testNewPolicy = {
origin: { type: 'text' },
'monitor.project.id': { type: 'text' },
'monitor.project.name': { type: 'text' },
maintenance_windows: { type: 'yaml' },
},
},
{ enabled: true, data_stream: { type: 'synthetics', dataset: 'browser.network' } },
@ -1157,6 +1199,7 @@ const browserConfig: any = {
fields: { config_id: '00bb3ceb-a242-4c7a-8405-8da963661374' },
fields_under_root: true,
location_name: 'Test private location 0',
maintenance_windows: ['190bd51b-985a-4553-9fba-57222ddde6b7'],
};
const httpPolicy: any = {

View file

@ -7,11 +7,12 @@
import { NewPackagePolicy } from '@kbn/fleet-plugin/common';
import { cloneDeep } from 'lodash';
import { MaintenanceWindow } from '@kbn/alerting-plugin/server/application/maintenance_window/types';
import { processorsFormatter } from './processors_formatter';
import { LegacyConfigKey } from '../../../../common/constants/monitor_management';
import { ConfigKey, MonitorTypeEnum, MonitorFields } from '../../../../common/runtime_types';
import { throttlingFormatter } from './browser_formatters';
import { replaceStringWithParams } from '../formatting_utils';
import { formatMWs, replaceStringWithParams } from '../formatting_utils';
import { syntheticsPolicyFormatters } from './formatters';
import { PARAMS_KEYS_TO_SKIP } from '../common';
@ -31,6 +32,7 @@ export const formatSyntheticsPolicy = (
monitorType: MonitorTypeEnum,
config: Partial<MonitorFields & ProcessorFields>,
params: Record<string, string>,
mws: MaintenanceWindow[],
isLegacy?: boolean
) => {
const configKeys = Object.keys(config) as ConfigKey[];
@ -74,6 +76,22 @@ export const formatSyntheticsPolicy = (
processorItem.value = processorsFormatter(config as MonitorFields & ProcessorFields);
}
const mwItem = dataStream?.vars?.[ConfigKey.MAINTENANCE_WINDOWS];
if (config[ConfigKey.MAINTENANCE_WINDOWS]?.length && mwItem) {
const maintenanceWindows = config[ConfigKey.MAINTENANCE_WINDOWS];
const formattedVal = formatMWs(
maintenanceWindows.map((window) => {
if (typeof window === 'string') {
return mws.find((m) => m.id === window);
}
return window;
}) as MaintenanceWindow[]
);
if (formattedVal) {
mwItem.value = formattedVal;
}
}
// TODO: remove this once we remove legacy support
const throttling = dataStream?.vars?.[LegacyConfigKey.THROTTLING_CONFIG];
if (throttling) {

View file

@ -56,4 +56,5 @@ export const commonFormatters: CommonFormatMap = {
`@every ${fields[ConfigKey.SCHEDULE]?.number}${fields[ConfigKey.SCHEDULE]?.unit}`,
[ConfigKey.TAGS]: arrayFormatter,
[ConfigKey.LABELS]: null,
[ConfigKey.MAINTENANCE_WINDOWS]: null,
};

View file

@ -104,7 +104,8 @@ describe('formatMonitorConfig', () => {
Object.keys(testHTTPConfig) as ConfigKey[],
testHTTPConfig,
logger,
{ proxyUrl: 'https://www.google.com' }
{ proxyUrl: 'https://www.google.com' },
[]
);
expect(yamlConfig).toEqual({
@ -145,7 +146,8 @@ describe('formatMonitorConfig', () => {
[ConfigKey.METADATA]: { is_tls_enabled: isTLSEnabled },
},
logger,
{ proxyUrl: 'https://www.google.com' }
{ proxyUrl: 'https://www.google.com' },
[]
);
expect(yamlConfig).toEqual({
@ -218,7 +220,8 @@ describe('browser fields', () => {
Object.keys(testBrowserConfig) as ConfigKey[],
testBrowserConfig,
logger,
{ proxyUrl: 'https://www.google.com' }
{ proxyUrl: 'https://www.google.com' },
[]
);
expect(yamlConfig).toEqual(formattedBrowserConfig);
@ -233,7 +236,8 @@ describe('browser fields', () => {
params: '',
},
logger,
{ proxyUrl: 'https://www.google.com' }
{ proxyUrl: 'https://www.google.com' },
[]
);
expect(yamlConfig).toEqual(omit(formattedBrowserConfig, ['params', 'playwright_options']));
@ -251,7 +255,8 @@ describe('browser fields', () => {
},
},
logger,
{ proxyUrl: 'https://www.google.com' }
{ proxyUrl: 'https://www.google.com' },
[]
);
const expected = {
@ -269,7 +274,8 @@ describe('browser fields', () => {
Object.keys(testBrowserConfig) as ConfigKey[],
testBrowserConfig,
logger,
{ proxyUrl: 'https://www.google.com' }
{ proxyUrl: 'https://www.google.com' },
[]
);
const expected = {
@ -287,7 +293,8 @@ describe('browser fields', () => {
Object.keys(testBrowserConfig) as ConfigKey[],
testBrowserConfig,
logger,
{ proxyUrl: 'https://www.google.com' }
{ proxyUrl: 'https://www.google.com' },
[]
);
const expected = { ...formattedConfig, enabled: false };

View file

@ -7,7 +7,8 @@
import { isEmpty, isNil, omitBy } from 'lodash';
import { Logger } from '@kbn/logging';
import { replaceStringWithParams } from '../formatting_utils';
import { MaintenanceWindow } from '@kbn/alerting-plugin/server/application/maintenance_window/types';
import { formatMWs, replaceStringWithParams } from '../formatting_utils';
import { PARAMS_KEYS_TO_SKIP } from '../common';
import {
BrowserFields,
@ -37,7 +38,8 @@ export const formatMonitorConfigFields = (
configKeys: ConfigKey[],
config: Partial<MonitorFields>,
logger: Logger,
params: Record<string, string>
params: Record<string, string>,
mws: MaintenanceWindow[]
) => {
const formattedMonitor = {} as Record<ConfigKey, any>;
@ -76,6 +78,19 @@ export const formatMonitorConfigFields = (
sslKeys.forEach((key) => (formattedMonitor[key] = null));
}
if (config[ConfigKey.MAINTENANCE_WINDOWS]) {
const maintenanceWindows = config[ConfigKey.MAINTENANCE_WINDOWS];
formattedMonitor[ConfigKey.MAINTENANCE_WINDOWS] = formatMWs(
maintenanceWindows.map((window) => {
if (typeof window === 'string') {
return mws.find((m) => m.id === window);
}
return window;
}) as MaintenanceWindow[],
false
);
}
return omitBy(formattedMonitor, isNil) as Partial<MonitorFields>;
};

View file

@ -138,7 +138,8 @@ describe('SyntheticsPrivateLocation', () => {
await syntheticsPrivateLocation.editMonitors(
[{ config: testConfig, globalParams: {} }],
[mockPrivateLocation],
'test-space'
'test-space',
[]
);
} catch (e) {
expect(e).toEqual(new Error(error));
@ -183,7 +184,8 @@ describe('SyntheticsPrivateLocation', () => {
testMonitorPolicy,
MonitorTypeEnum.BROWSER,
dummyBrowserConfig,
{}
{},
[]
);
expect(test.formattedPolicy.inputs[3].streams[1]).toStrictEqual({

View file

@ -8,6 +8,7 @@ import { NewPackagePolicy } from '@kbn/fleet-plugin/common';
import { NewPackagePolicyWithId } from '@kbn/fleet-plugin/server/services/package_policy';
import { cloneDeep } from 'lodash';
import { SavedObjectError } from '@kbn/core-saved-objects-common';
import { MaintenanceWindow } from '@kbn/alerting-plugin/server/application/maintenance_window/types';
import { DEFAULT_NAMESPACE_STRING } from '../../../common/constants/monitor_defaults';
import {
BROWSER_TEST_NOW_RUN,
@ -75,6 +76,7 @@ export class SyntheticsPrivateLocation {
newPolicyTemplate: NewPackagePolicy,
spaceId: string,
globalParams: Record<string, string>,
maintenanceWindows: MaintenanceWindow[],
testRunId?: string,
runOnce?: boolean
): Promise<NewPackagePolicy | null> {
@ -122,7 +124,8 @@ export class SyntheticsPrivateLocation {
: {}),
...(runOnce ? { run_once: runOnce } : {}),
},
globalParams
globalParams,
maintenanceWindows
);
return formattedPolicy;
@ -165,6 +168,7 @@ export class SyntheticsPrivateLocation {
newPolicyTemplate,
spaceId,
globalParams,
[],
testRunId,
runOnce
);
@ -214,10 +218,12 @@ export class SyntheticsPrivateLocation {
privateConfig,
spaceId,
allPrivateLocations,
maintenanceWindows,
}: {
privateConfig?: PrivateConfig;
allPrivateLocations: PrivateLocationAttributes[];
spaceId: string;
maintenanceWindows: MaintenanceWindow[];
}) {
if (!privateConfig) {
return null;
@ -238,7 +244,8 @@ export class SyntheticsPrivateLocation {
location,
newPolicyTemplate,
spaceId,
globalParams
globalParams,
maintenanceWindows
);
const pkgPolicy = {
@ -256,7 +263,8 @@ export class SyntheticsPrivateLocation {
async editMonitors(
configs: Array<{ config: HeartbeatConfig; globalParams: Record<string, string> }>,
allPrivateLocations: SyntheticsPrivateLocations,
spaceId: string
spaceId: string,
maintenanceWindows: MaintenanceWindow[]
) {
if (configs.length === 0) {
return {};
@ -291,7 +299,8 @@ export class SyntheticsPrivateLocation {
privateLocation,
newPolicyTemplate,
spaceId,
globalParams
globalParams,
maintenanceWindows
);
if (!newPolicy) {

View file

@ -175,6 +175,7 @@ describe('getNormalizeCommonFields', () => {
params: '',
max_attempts: 2,
labels: {},
maintenance_windows: [],
},
});
}
@ -241,6 +242,7 @@ describe('getNormalizeCommonFields', () => {
params: '',
max_attempts: 2,
labels: {},
maintenance_windows: [],
},
});
});

View file

@ -101,6 +101,8 @@ export const getNormalizeCommonFields = ({
// picking out keys specifically, so users can't add arbitrary fields
[ConfigKey.ALERT_CONFIG]: getAlertConfig(monitor),
[ConfigKey.LABELS]: monitor.fields || defaultFields[ConfigKey.LABELS],
[ConfigKey.MAINTENANCE_WINDOWS]:
monitor.maintenanceWindows || defaultFields[ConfigKey.MAINTENANCE_WINDOWS],
...(monitor[ConfigKey.APM_SERVICE_NAME] && {
[ConfigKey.APM_SERVICE_NAME]: monitor[ConfigKey.APM_SERVICE_NAME],
}),

View file

@ -130,6 +130,7 @@ describe('ProjectMonitorFormatter', () => {
syntheticsService.addConfigs = jest.fn();
syntheticsService.editConfig = jest.fn();
syntheticsService.deleteConfigs = jest.fn();
syntheticsService.getMaintenanceWindows = jest.fn();
const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createStart().getClient();

View file

@ -67,6 +67,7 @@ describe('SyntheticsMonitorClient', () => {
syntheticsService.addConfigs = jest.fn();
syntheticsService.editConfig = jest.fn();
syntheticsService.deleteConfigs = jest.fn();
syntheticsService.getMaintenanceWindows = jest.fn();
const locations = times(3).map((n) => {
return {
@ -179,16 +180,20 @@ describe('SyntheticsMonitorClient', () => {
);
expect(syntheticsService.editConfig).toHaveBeenCalledTimes(1);
expect(syntheticsService.editConfig).toHaveBeenCalledWith([
{
monitor,
configId: id,
params: {
username: 'elastic',
expect(syntheticsService.editConfig).toHaveBeenCalledWith(
[
{
monitor,
configId: id,
params: {
username: 'elastic',
},
spaceId: 'test-space',
},
spaceId: 'test-space',
},
]);
],
true,
undefined
);
expect(syntheticsService.deleteConfigs).toHaveBeenCalledTimes(1);
expect(client.privateLocationAPI.editMonitors).toHaveBeenCalledTimes(1);
});

View file

@ -53,6 +53,7 @@ export class SyntheticsMonitorClient {
const publicConfigs: ConfigData[] = [];
const paramsBySpace = await this.syntheticsService.getSyntheticsParams({ spaceId });
const maintenanceWindows = await this.syntheticsService.getMaintenanceWindows();
for (const monitorObj of monitors) {
const { formattedConfig, params, config } = await this.formatConfigWithParams(
@ -77,7 +78,7 @@ export class SyntheticsMonitorClient {
spaceId
);
const syncErrors = this.syntheticsService.addConfigs(publicConfigs);
const syncErrors = this.syntheticsService.addConfigs(publicConfigs, maintenanceWindows);
return await Promise.all([newPolicies, syncErrors]);
}
@ -98,6 +99,7 @@ export class SyntheticsMonitorClient {
const deletedPublicConfigs: ConfigData[] = [];
const paramsBySpace = await this.syntheticsService.getSyntheticsParams({ spaceId });
const maintenanceWindows = await this.syntheticsService.getMaintenanceWindows();
for (const editedMonitor of monitors) {
const { str: paramsString, params } = mixParamsWithGlobalParams(
@ -105,7 +107,7 @@ export class SyntheticsMonitorClient {
editedMonitor.monitor
);
const configData = {
const configData: ConfigData = {
spaceId,
params: paramsBySpace[spaceId],
monitor: editedMonitor.monitor,
@ -146,10 +148,15 @@ export class SyntheticsMonitorClient {
const privateEditPromise = this.privateLocationAPI.editMonitors(
privateConfigs,
allPrivateLocations,
spaceId
spaceId,
maintenanceWindows
);
const publicConfigsPromise = this.syntheticsService.editConfig(publicConfigs);
const publicConfigsPromise = this.syntheticsService.editConfig(
publicConfigs,
true,
maintenanceWindows
);
const [publicSyncErrors, privateEditResponse] = await Promise.all([
publicConfigsPromise,
@ -298,6 +305,7 @@ export class SyntheticsMonitorClient {
canSave,
hideParams,
});
const maintenanceWindows = await this.syntheticsService.getMaintenanceWindows();
const { formattedConfig, params, config } = await this.formatConfigWithParams(
monitorObj,
@ -316,11 +324,13 @@ export class SyntheticsMonitorClient {
}
const publicPromise = this.syntheticsService.inspectConfig(
publicLocations.length > 0 ? config : undefined
publicLocations.length > 0 ? config : null,
maintenanceWindows
);
const privatePromise = this.privateLocationAPI.inspectPackagePolicy({
privateConfig: privateConfigs?.[0],
allPrivateLocations,
maintenanceWindows,
spaceId,
});

View file

@ -145,6 +145,8 @@ describe('SyntheticsService', () => {
jest.spyOn(service, 'getOutput').mockResolvedValue({ hosts: ['es'], api_key: 'i:k' });
jest.spyOn(service, 'getSyntheticsParams').mockResolvedValue({});
service.getMaintenanceWindows = jest.fn();
return { service, locations };
};
@ -223,7 +225,7 @@ describe('SyntheticsService', () => {
(axios as jest.MockedFunction<typeof axios>).mockResolvedValue({} as AxiosResponse);
await service.addConfigs({ monitor: payload } as any);
await service.addConfigs({ monitor: payload } as any, []);
expect(axios).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith(
@ -289,7 +291,7 @@ describe('SyntheticsService', () => {
const payload = getFakePayload([locations[0]]);
await service.editConfig({ monitor: payload } as any);
await service.editConfig({ monitor: payload } as any, true, []);
expect(axios).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith(
@ -306,7 +308,7 @@ describe('SyntheticsService', () => {
const payload = getFakePayload([locations[0]]);
await service.editConfig({ monitor: payload } as any);
await service.editConfig({ monitor: payload } as any, true, []);
expect(axios).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith(
@ -323,7 +325,7 @@ describe('SyntheticsService', () => {
const payload = getFakePayload([locations[0]]);
await service.addConfigs({ monitor: payload } as any);
await service.addConfigs({ monitor: payload } as any, []);
expect(axios).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith(
@ -533,6 +535,8 @@ describe('SyntheticsService', () => {
jest.spyOn(service, 'getOutput').mockResolvedValue({ hosts: ['es'], api_key: 'i:k' });
jest.spyOn(service, 'getSyntheticsParams').mockResolvedValue({});
service.getMaintenanceWindows = jest.fn();
it('paginates the results', async () => {
serverMock.config = mockConfig;

View file

@ -18,6 +18,10 @@ import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants';
import pMap from 'p-map';
import moment from 'moment';
import { MaintenanceWindowClient } from '@kbn/alerting-plugin/server/maintenance_window_client';
import { MaintenanceWindow } from '@kbn/alerting-plugin/server/application/maintenance_window/types';
import { isEmpty } from 'lodash';
import { MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/common';
import { registerCleanUpTask } from './private_location/clean_up_task';
import { SyntheticsServerSetup } from '../types';
import { syntheticsMonitorType, syntheticsParamType } from '../../common/types/saved_objects';
@ -326,11 +330,11 @@ export class SyntheticsService {
};
}
async inspectConfig(config?: ConfigData) {
if (!config) {
async inspectConfig(config: ConfigData | null, mws: MaintenanceWindow[]) {
if (!config || isEmpty(config)) {
return null;
}
const monitors = this.formatConfigs(config);
const monitors = this.formatConfigs(config, mws);
const license = await this.getLicense();
const output = await this.getOutput({ inspect: true });
@ -344,13 +348,13 @@ export class SyntheticsService {
return null;
}
async addConfigs(configs: ConfigData[]) {
async addConfigs(configs: ConfigData[], mws: MaintenanceWindow[]) {
try {
if (configs.length === 0 || !this.isAllowed) {
return;
}
const monitors = this.formatConfigs(configs);
const monitors = this.formatConfigs(configs, mws);
const license = await this.getLicense();
const output = await this.getOutput();
@ -376,13 +380,13 @@ export class SyntheticsService {
}
}
async editConfig(monitorConfig: ConfigData[], isEdit = true) {
async editConfig(monitorConfig: ConfigData[], isEdit = true, mws: MaintenanceWindow[]) {
try {
if (monitorConfig.length === 0 || !this.isAllowed) {
return;
}
const license = await this.getLicense();
const monitors = this.formatConfigs(monitorConfig);
const monitors = this.formatConfigs(monitorConfig, mws);
const output = await this.getOutput();
if (output) {
@ -411,6 +415,7 @@ export class SyntheticsService {
let output: ServiceData['output'] | null = null;
const paramsBySpace = await this.getSyntheticsParams();
const maintenanceWindows = await this.getMaintenanceWindows();
const finder = await this.getSOClientFinder({ pageSize: PER_PAGE });
const bucketsByLocation: Record<string, MonitorFields[]> = {};
@ -462,7 +467,11 @@ export class SyntheticsService {
}
const monitors = result.saved_objects.filter(({ error }) => !error);
const formattedConfigs = this.normalizeConfigs(monitors, paramsBySpace);
const formattedConfigs = this.normalizeConfigs(
monitors,
paramsBySpace,
maintenanceWindows
);
this.logger.debug(
`${formattedConfigs.length} monitors will be pushed to synthetics service.`
@ -504,7 +513,7 @@ export class SyntheticsService {
if (!configs) {
return;
}
const monitors = this.formatConfigs(configs);
const monitors = this.formatConfigs(configs, []);
if (monitors.length === 0) {
return;
}
@ -545,7 +554,7 @@ export class SyntheticsService {
const data = {
output,
monitors: this.formatConfigs(configs),
monitors: this.formatConfigs(configs, []),
license,
};
return await this.apiClient.delete(data);
@ -557,7 +566,6 @@ export class SyntheticsService {
async deleteAllConfigs() {
const license = await this.getLicense();
const paramsBySpace = await this.getSyntheticsParams();
const finder = await this.getSOClientFinder({ pageSize: 100 });
const output = await this.getOutput();
if (!output) {
@ -565,7 +573,7 @@ export class SyntheticsService {
}
for await (const result of finder.find()) {
const monitors = this.normalizeConfigs(result.saved_objects, paramsBySpace);
const monitors = this.normalizeConfigs(result.saved_objects, {}, []);
const hasPublicLocations = monitors.some((config) =>
config.locations.some(({ isServiceManaged }) => isServiceManaged)
);
@ -636,7 +644,24 @@ export class SyntheticsService {
return paramsBySpace;
}
formatConfigs(configData: ConfigData[] | ConfigData) {
async getMaintenanceWindows() {
const { savedObjects } = this.server.coreStart;
const soClient = savedObjects.createInternalRepository([MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE]);
const maintenanceWindowClient = new MaintenanceWindowClient({
savedObjectsClient: soClient,
getUserName: async () => '',
uiSettings: this.server.coreStart.uiSettings.asScopedToClient(soClient),
logger: this.logger,
});
const mws = await maintenanceWindowClient.find({
page: 0,
perPage: 1000,
});
return mws.data;
}
formatConfigs(configData: ConfigData[] | ConfigData, mws: MaintenanceWindow[]) {
const configDataList = Array.isArray(configData) ? configData : [configData];
return configDataList.map((config) => {
@ -651,20 +676,22 @@ export class SyntheticsService {
Object.keys(asHeartbeatConfig) as ConfigKey[],
asHeartbeatConfig as Partial<MonitorFields>,
this.logger,
params ?? {}
params ?? {},
mws
);
});
}
normalizeConfigs(
monitors: Array<SavedObject<SyntheticsMonitorWithSecretsAttributes>>,
paramsBySpace: Record<string, Record<string, string>>
paramsBySpace: Record<string, Record<string, string>>,
mws: MaintenanceWindow[]
) {
const configDataList = (monitors ?? []).map((monitor) => {
const attributes = monitor.attributes as unknown as MonitorFields;
const monitorSpace = monitor.namespaces?.[0] ?? DEFAULT_SPACE_ID;
const params = paramsBySpace[monitorSpace];
const params = paramsBySpace[monitorSpace] ?? {};
return {
params: { ...params, ...(paramsBySpace?.[ALL_SPACES_ID] ?? {}) },
@ -675,7 +702,7 @@ export class SyntheticsService {
};
});
return this.formatConfigs(configDataList) as MonitorFields[];
return this.formatConfigs(configDataList, mws) as MonitorFields[];
}
checkMissingSchedule(state: Record<string, string>) {
try {
@ -685,7 +712,7 @@ export class SyntheticsService {
if (lastRunAt) {
// log if it has missed last schedule
const diff = moment(current).diff(lastRunAt, 'minutes');
const syncInterval = Number((this.config.syncInterval ?? '5m').split('m')[0]);
const syncInterval = Number((this.config.syncInterval ?? '5m').split('m')[0]) + 5;
if (diff > syncInterval) {
const message = `Synthetics monitor sync task has missed its schedule, it last ran ${diff} minutes ago.`;
this.logger.warn(message);

View file

@ -0,0 +1,466 @@
/*
* 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 { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server/plugin';
import {
SyncPrivateLocationMonitorsTask,
runSynPrivateLocationMonitorsTaskSoon,
CustomTaskInstance,
} from './sync_private_locations_monitors_task';
import { SyntheticsServerSetup } from '../types';
import { SyntheticsMonitorClient } from '../synthetics_service/synthetics_monitor/synthetics_monitor_client';
import * as getPrivateLocationsModule from '../synthetics_service/get_private_locations';
import { coreMock } from '@kbn/core/server/mocks';
import { CoreStart } from '@kbn/core-lifecycle-server';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { TaskStatus } from '@kbn/task-manager-plugin/server';
import { mockEncryptedSO } from '../synthetics_service/utils/mocks';
const mockTaskManagerStart = taskManagerMock.createStart();
const mockTaskManager = taskManagerMock.createSetup();
const mockSoClient = {
find: jest.fn(),
createInternalRepository: jest.fn(),
};
const mockEncryptedSoClient = mockEncryptedSO();
const mockSyntheticsMonitorClient = {
privateLocationAPI: {
editMonitors: jest.fn(),
},
syntheticsService: {
getSyntheticsParams: jest.fn(),
getMaintenanceWindows: jest.fn(),
},
};
const mockLogger = loggerMock.create();
const mockServerSetup: jest.Mocked<SyntheticsServerSetup> = {
coreStart: coreMock.createStart() as CoreStart,
pluginsStart: {
taskManager: mockTaskManagerStart,
} as any,
encryptedSavedObjects: mockEncryptedSoClient as any,
logger: mockLogger,
} as any;
const getMockTaskInstance = (state: Record<string, any> = {}): CustomTaskInstance => {
return {
id: 'test-task',
taskType: 'Test:Task',
startedAt: new Date(),
scheduledAt: new Date(),
status: TaskStatus.Running,
runAt: new Date(),
attempts: 1,
ownerId: 'test-owner',
retryAt: null,
state: {
lastStartedAt: '2023-01-01T12:00:00.000Z',
lastTotalParams: 1,
lastTotalMWs: 1,
attempts: 1,
...state,
},
params: {},
};
};
describe('SyncPrivateLocationMonitorsTask', () => {
let task: SyncPrivateLocationMonitorsTask;
beforeEach(() => {
jest.clearAllMocks();
task = new SyncPrivateLocationMonitorsTask(
mockServerSetup as any,
mockTaskManager as unknown as TaskManagerSetupContract,
mockSyntheticsMonitorClient as unknown as SyntheticsMonitorClient
);
mockSoClient.createInternalRepository.mockReturnValue(mockSoClient as any);
});
describe('constructor', () => {
it('should register task definitions correctly', () => {
expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledWith({
'Synthetics:Sync-Private-Location-Monitors': expect.objectContaining({
title: 'Synthetics Sync Global Params Task',
description:
'This task is executed so that we can sync private location monitors for example when global params are updated',
timeout: '3m',
maxAttempts: 3,
createTaskRunner: expect.any(Function),
}),
});
});
});
describe('start', () => {
it('should schedule the task correctly', async () => {
await task.start();
expect(mockLogger.debug).toHaveBeenCalledWith('Scheduling private location task');
expect(mockTaskManagerStart.ensureScheduled).toHaveBeenCalledWith({
id: 'Synthetics:Sync-Private-Location-Monitors-single-instance',
state: {},
schedule: {
interval: '5m',
},
taskType: 'Synthetics:Sync-Private-Location-Monitors',
params: {},
});
expect(mockLogger.debug).toHaveBeenCalledWith(
'Sync private location monitors task scheduled successfully'
);
});
});
describe('runTask', () => {
it('should skip sync if no data has changed', async () => {
const taskInstance = getMockTaskInstance();
jest.spyOn(task, 'hasAnyDataChanged').mockResolvedValue({
hasDataChanged: false,
totalParams: 1,
totalMWs: 1,
});
jest.spyOn(getPrivateLocationsModule, 'getPrivateLocations').mockResolvedValue([
{
id: 'pl-1',
label: 'Private Location 1',
isServiceManaged: false,
agentPolicyId: 'policy-1',
},
]);
const result = await task.runTask({ taskInstance });
expect(task.hasAnyDataChanged).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('No data has changed since last run')
);
expect(mockSyntheticsMonitorClient.privateLocationAPI.editMonitors).not.toHaveBeenCalled();
expect(result.error).toBeUndefined();
expect(result.state).toEqual({
lastStartedAt: taskInstance.startedAt?.toISOString(),
lastTotalParams: 1,
lastTotalMWs: 1,
});
});
it('should run sync if data has changed', async () => {
const taskInstance = getMockTaskInstance();
jest.spyOn(task, 'hasAnyDataChanged').mockResolvedValue({
hasDataChanged: true,
totalParams: 2,
totalMWs: 1,
});
jest.spyOn(getPrivateLocationsModule, 'getPrivateLocations').mockResolvedValue([
{
id: 'pl-1',
label: 'Private Location 1',
isServiceManaged: false,
agentPolicyId: 'policy-1',
},
]);
jest.spyOn(task, 'syncGlobalParams').mockResolvedValue(undefined);
const result = await task.runTask({ taskInstance });
expect(mockLogger.debug).toHaveBeenCalledWith(
'Syncing private location monitors because data has changed'
);
expect(task.syncGlobalParams).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith('Sync of private location monitors succeeded');
expect(result.error).toBeUndefined();
expect(result.state).toEqual({
lastStartedAt: taskInstance.startedAt?.toISOString(),
lastTotalParams: 2,
lastTotalMWs: 1,
});
});
it('should not sync if data changed but no private locations exist', async () => {
const taskInstance = getMockTaskInstance();
jest.spyOn(task, 'hasAnyDataChanged').mockResolvedValue({
hasDataChanged: true,
totalParams: 2,
totalMWs: 1,
});
jest.spyOn(getPrivateLocationsModule, 'getPrivateLocations').mockResolvedValue([]);
jest.spyOn(task, 'syncGlobalParams');
await task.runTask({ taskInstance });
expect(getPrivateLocationsModule.getPrivateLocations).toHaveBeenCalled();
expect(task.syncGlobalParams).not.toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith('Sync of private location monitors succeeded');
});
it('should handle errors during the run', async () => {
const taskInstance = getMockTaskInstance();
const error = new Error('Sync failed');
jest.spyOn(task, 'hasAnyDataChanged').mockRejectedValue(error);
const result = await task.runTask({ taskInstance });
expect(mockLogger.error).toHaveBeenCalledWith(
`Sync of private location monitors failed: ${error.message}`
);
expect(result.error).toBe(error);
expect(result.state).toEqual({
lastStartedAt: taskInstance.startedAt?.toISOString(),
lastTotalParams: 1,
lastTotalMWs: 1,
});
});
});
describe('hasAnyDataChanged', () => {
it('should return true if params changed', async () => {
jest
.spyOn(task, 'hasAnyParamChanged')
.mockResolvedValue({ hasParamsChanges: true, totalParams: 2 } as any);
jest
.spyOn(task, 'hasMWsChanged')
.mockResolvedValue({ hasMWsChanged: false, totalMWs: 1 } as any);
const res = await task.hasAnyDataChanged({
taskInstance: getMockTaskInstance(),
soClient: mockSoClient as any,
});
expect(res.hasDataChanged).toBe(true);
expect(res.totalParams).toBe(2);
expect(res.totalMWs).toBe(1);
});
it('should return true if maintenance windows changed', async () => {
jest
.spyOn(task, 'hasAnyParamChanged')
.mockResolvedValue({ hasParamsChanges: false, totalParams: 1 } as any);
jest
.spyOn(task, 'hasMWsChanged')
.mockResolvedValue({ hasMWsChanged: true, totalMWs: 2 } as any);
const res = await task.hasAnyDataChanged({
taskInstance: getMockTaskInstance(),
soClient: mockSoClient as any,
});
expect(res.hasDataChanged).toBe(true);
});
it('should return false if nothing changed', async () => {
jest
.spyOn(task, 'hasAnyParamChanged')
.mockResolvedValue({ hasParamsChanges: false, totalParams: 1 } as any);
jest
.spyOn(task, 'hasMWsChanged')
.mockResolvedValue({ hasMWsChanged: false, totalMWs: 1 } as any);
const res = await task.hasAnyDataChanged({
taskInstance: getMockTaskInstance(),
soClient: mockSoClient as any,
});
expect(res.hasDataChanged).toBe(false);
});
});
describe('hasAnyParamChanged', () => {
it('returns true if updated params are found', async () => {
mockSoClient.find
.mockResolvedValueOnce({ total: 1 }) // updated
.mockResolvedValueOnce({ total: 10 }); // total
const { hasParamsChanges } = await task.hasAnyParamChanged({
soClient: mockSoClient as any,
lastStartedAt: '...',
lastTotalParams: 10,
});
expect(hasParamsChanges).toBe(true);
});
it('returns true if total number of params changed', async () => {
mockSoClient.find
.mockResolvedValueOnce({ total: 0 }) // updated
.mockResolvedValueOnce({ total: 11 }); // total
const { hasParamsChanges } = await task.hasAnyParamChanged({
soClient: mockSoClient as any,
lastStartedAt: '...',
lastTotalParams: 10,
});
expect(hasParamsChanges).toBe(true);
});
it('returns false if no changes are detected', async () => {
mockSoClient.find
.mockResolvedValueOnce({ total: 0 }) // updated
.mockResolvedValueOnce({ total: 10 }); // total
const { hasParamsChanges } = await task.hasAnyParamChanged({
soClient: mockSoClient as any,
lastStartedAt: '...',
lastTotalParams: 10,
});
expect(hasParamsChanges).toBe(false);
});
});
describe('hasMWsChanged', () => {
it('returns true if updated MWs are found', async () => {
mockSoClient.find
.mockResolvedValueOnce({ total: 1 }) // updated
.mockResolvedValueOnce({ total: 5 }); // total
const { hasMWsChanged } = await task.hasMWsChanged({
soClient: mockSoClient as any,
lastStartedAt: '...',
lastTotalMWs: 5,
});
expect(hasMWsChanged).toBe(true);
});
it('returns true if total number of MWs changed', async () => {
mockSoClient.find
.mockResolvedValueOnce({ total: 0 }) // updated
.mockResolvedValueOnce({ total: 6 }); // total
const { hasMWsChanged } = await task.hasMWsChanged({
soClient: mockSoClient as any,
lastStartedAt: '...',
lastTotalMWs: 5,
});
expect(hasMWsChanged).toBe(true);
});
it('returns false if no changes are detected', async () => {
mockSoClient.find
.mockResolvedValueOnce({ total: 0 }) // updated
.mockResolvedValueOnce({ total: 5 }); // total
const { hasMWsChanged } = await task.hasMWsChanged({
soClient: mockSoClient as any,
lastStartedAt: '...',
lastTotalMWs: 5,
});
expect(hasMWsChanged).toBe(false);
});
});
describe('syncGlobalParams', () => {
it('should fetch all configs and edit monitors on private locations', async () => {
const mockAllPrivateLocations = [{ id: 'pl-1', name: 'Private Location 1' }];
// Mocking the return of getAllMonitorConfigs
jest.spyOn(task, 'getAllMonitorConfigs').mockResolvedValue({
configsBySpaces: {
space1: [{ id: 'm1', locations: [{ name: 'pl-1', isServiceManaged: false }] }],
},
spaceIds: new Set(['space1']),
paramsBySpace: { space1: { global: 'param' } },
maintenanceWindows: [],
} as any);
jest
.spyOn(task, 'parseLocations')
.mockReturnValue({ privateLocations: ['pl-1'], publicLocations: [] } as any);
await task.syncGlobalParams({
allPrivateLocations: mockAllPrivateLocations as any,
encryptedSavedObjects: mockEncryptedSoClient as any,
soClient: mockSoClient as any,
});
expect(task.getAllMonitorConfigs).toHaveBeenCalled();
expect(mockSyntheticsMonitorClient.privateLocationAPI.editMonitors).toHaveBeenCalledWith(
expect.any(Array),
mockAllPrivateLocations,
'space1',
[]
);
});
it('should not call editMonitors if no monitors are on private locations', async () => {
jest.spyOn(task, 'getAllMonitorConfigs').mockResolvedValue({
configsBySpaces: {
space1: [{ id: 'm1', locations: [] }],
},
spaceIds: new Set(['space1']),
paramsBySpace: {},
maintenanceWindows: [],
} as any);
// This monitor has no private locations
jest
.spyOn(task, 'parseLocations')
.mockReturnValue({ privateLocations: [], publicLocations: [] } as any);
await task.syncGlobalParams({
allPrivateLocations: [],
soClient: mockSoClient as any,
encryptedSavedObjects: mockEncryptedSoClient as any,
});
expect(mockSyntheticsMonitorClient.privateLocationAPI.editMonitors).not.toHaveBeenCalled();
});
});
describe('parseLocations', () => {
it('separates private and public locations correctly', () => {
const config = {
locations: [
{ name: 'private1', isServiceManaged: false },
{ name: 'public1', isServiceManaged: true },
{ name: 'private2', isServiceManaged: false },
],
};
const { privateLocations, publicLocations } = task.parseLocations(config as any);
expect(privateLocations).toHaveLength(2);
expect(publicLocations).toHaveLength(1);
expect(privateLocations[0]).toEqual({ name: 'private1', isServiceManaged: false });
expect(publicLocations[0]).toEqual({ name: 'public1', isServiceManaged: true });
});
it('handles empty locations array', () => {
const config = { locations: [] };
const { privateLocations, publicLocations } = task.parseLocations(config as any);
expect(privateLocations).toHaveLength(0);
expect(publicLocations).toHaveLength(0);
});
});
});
describe('runSynPrivateLocationMonitorsTaskSoon', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should schedule the task to run soon successfully', async () => {
await runSynPrivateLocationMonitorsTaskSoon({ server: mockServerSetup as any });
expect(mockLogger.debug).toHaveBeenCalledWith(
'Scheduling Synthetics sync private location monitors task soon'
);
expect(mockTaskManagerStart.runSoon).toHaveBeenCalledWith(
'Synthetics:Sync-Private-Location-Monitors-single-instance'
);
expect(mockLogger.debug).toHaveBeenCalledWith(
'Synthetics sync private location task scheduled successfully'
);
});
it('should log an error if scheduling fails', async () => {
const error = new Error('Failed to run soon');
mockTaskManagerStart.runSoon.mockRejectedValue(error);
await runSynPrivateLocationMonitorsTaskSoon({ server: mockServerSetup as any });
expect(mockLogger.error).toHaveBeenCalledWith(
`Error scheduling Synthetics sync private location monitors task: ${error.message}`,
{
error,
}
);
});
});

View file

@ -14,6 +14,7 @@ import { EncryptedSavedObjectsPluginStart } from '@kbn/encrypted-saved-objects-p
import { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants';
import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server';
import moment from 'moment';
import { MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/common';
import { syntheticsParamType } from '../../common/types/saved_objects';
import { normalizeSecrets } from '../synthetics_service/utils';
import type { PrivateLocationAttributes } from '../runtime_types/private_locations';
@ -33,13 +34,17 @@ import {
const TASK_TYPE = 'Synthetics:Sync-Private-Location-Monitors';
const TASK_ID = `${TASK_TYPE}-single-instance`;
const TASK_SCHEDULE = '5m';
interface TaskState extends Record<string, unknown> {
lastStartedAt: string;
lastTotalParams: number;
lastTotalMWs: number;
}
type CustomTaskInstance = Omit<ConcreteTaskInstance, 'state'> & { state: Partial<TaskState> };
export type CustomTaskInstance = Omit<ConcreteTaskInstance, 'state'> & {
state: Partial<TaskState>;
};
export class SyncPrivateLocationMonitorsTask {
constructor(
@ -79,18 +84,23 @@ export class SyncPrivateLocationMonitorsTask {
taskInstance.state.lastStartedAt || moment().subtract(10, 'minute').toISOString();
const startedAt = taskInstance.startedAt || new Date();
let lastTotalParams = taskInstance.state.lastTotalParams || 0;
let lastTotalMWs = taskInstance.state.lastTotalMWs || 0;
try {
logger.debug(
`Syncing private location monitors, last total params ${lastTotalParams}, last run ${lastStartedAt}`
);
const soClient = savedObjects.createInternalRepository();
const soClient = savedObjects.createInternalRepository([
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
]);
const allPrivateLocations = await getPrivateLocations(soClient);
const { updatedParams, totalParams } = await this.hasAnyParamChanged(soClient, lastStartedAt);
if (updatedParams > 0 || totalParams !== lastTotalParams) {
lastTotalParams = totalParams;
logger.debug(
`Syncing private location monitors because params changed, updated params ${updatedParams}, total params ${totalParams}`
);
const { totalMWs, totalParams, hasDataChanged } = await this.hasAnyDataChanged({
soClient,
taskInstance,
});
lastTotalParams = totalParams;
lastTotalMWs = totalMWs;
if (hasDataChanged) {
logger.debug(`Syncing private location monitors because data has changed`);
if (allPrivateLocations.length > 0) {
await this.syncGlobalParams({
@ -101,9 +111,8 @@ export class SyncPrivateLocationMonitorsTask {
}
logger.debug(`Sync of private location monitors succeeded`);
} else {
lastTotalParams = totalParams;
logger.debug(
`No params changed since last run ${lastStartedAt}, skipping sync of private location monitors`
`No data has changed since last run ${lastStartedAt}, skipping sync of private location monitors`
);
}
} catch (error) {
@ -113,6 +122,7 @@ export class SyncPrivateLocationMonitorsTask {
state: {
lastStartedAt: startedAt.toISOString(),
lastTotalParams,
lastTotalMWs,
},
};
}
@ -120,6 +130,7 @@ export class SyncPrivateLocationMonitorsTask {
state: {
lastStartedAt: startedAt.toISOString(),
lastTotalParams,
lastTotalMWs,
},
};
}
@ -134,7 +145,7 @@ export class SyncPrivateLocationMonitorsTask {
id: TASK_ID,
state: {},
schedule: {
interval: '10m',
interval: TASK_SCHEDULE,
},
taskType: TASK_TYPE,
params: {},
@ -142,6 +153,32 @@ export class SyncPrivateLocationMonitorsTask {
logger.debug(`Sync private location monitors task scheduled successfully`);
};
hasAnyDataChanged = async ({
taskInstance,
soClient,
}: {
taskInstance: CustomTaskInstance;
soClient: SavedObjectsClientContract;
}) => {
const lastStartedAt =
taskInstance.state.lastStartedAt || moment().subtract(10, 'minute').toISOString();
const lastTotalParams = taskInstance.state.lastTotalParams || 0;
const lastTotalMWs = taskInstance.state.lastTotalMWs || 0;
const { totalParams, hasParamsChanges } = await this.hasAnyParamChanged({
soClient,
lastStartedAt,
lastTotalParams,
});
const { totalMWs, hasMWsChanged } = await this.hasMWsChanged({
soClient,
lastStartedAt,
lastTotalMWs,
});
const hasDataChanged = hasMWsChanged || hasParamsChanges;
return { hasDataChanged, totalParams, totalMWs };
};
async syncGlobalParams({
allPrivateLocations,
encryptedSavedObjects,
@ -155,10 +192,11 @@ export class SyncPrivateLocationMonitorsTask {
const privateConfigs: Array<{ config: HeartbeatConfig; globalParams: Record<string, string> }> =
[];
const { configsBySpaces, paramsBySpace, spaceIds } = await this.getAllMonitorConfigs({
encryptedSavedObjects,
soClient,
});
const { configsBySpaces, paramsBySpace, spaceIds, maintenanceWindows } =
await this.getAllMonitorConfigs({
encryptedSavedObjects,
soClient,
});
for (const spaceId of spaceIds) {
const monitors = configsBySpaces[spaceId];
@ -173,7 +211,12 @@ export class SyncPrivateLocationMonitorsTask {
}
}
if (privateConfigs.length > 0) {
await privateLocationAPI.editMonitors(privateConfigs, allPrivateLocations, spaceId);
await privateLocationAPI.editMonitors(
privateConfigs,
allPrivateLocations,
spaceId,
maintenanceWindows
);
}
}
}
@ -187,6 +230,7 @@ export class SyncPrivateLocationMonitorsTask {
}) {
const { syntheticsService } = this.syntheticsMonitorClient;
const paramsBySpacePromise = syntheticsService.getSyntheticsParams({ spaceId: ALL_SPACES_ID });
const maintenanceWindowsPromise = syntheticsService.getMaintenanceWindows();
const monitorConfigRepository = new MonitorConfigRepository(
soClient,
encryptedSavedObjects.getClient()
@ -196,11 +240,16 @@ export class SyncPrivateLocationMonitorsTask {
spaceId: ALL_SPACES_ID,
});
const [paramsBySpace, monitors] = await Promise.all([paramsBySpacePromise, monitorsPromise]);
const [paramsBySpace, monitors, maintenanceWindows] = await Promise.all([
paramsBySpacePromise,
monitorsPromise,
maintenanceWindowsPromise,
]);
return {
...this.mixParamsWithMonitors(monitors, paramsBySpace),
paramsBySpace,
maintenanceWindows,
};
}
@ -251,7 +300,15 @@ export class SyncPrivateLocationMonitorsTask {
return { configsBySpaces, spaceIds };
}
async hasAnyParamChanged(soClient: SavedObjectsClientContract, lastStartedAt: string) {
async hasAnyParamChanged({
soClient,
lastStartedAt,
lastTotalParams,
}: {
soClient: SavedObjectsClientContract;
lastStartedAt: string;
lastTotalParams: number;
}) {
const { logger } = this.serverSetup;
const [editedParams, totalParams] = await Promise.all([
soClient.find({
@ -268,10 +325,59 @@ export class SyncPrivateLocationMonitorsTask {
fields: [],
}),
]);
logger.debug(`Found ${editedParams.total} params and ${totalParams.total} total params`);
logger.debug(
`Found ${editedParams.total} params updated and ${totalParams.total} total params`
);
const updatedParams = editedParams.total;
const noOfParams = totalParams.total;
const hasParamsChanges = updatedParams > 0 || noOfParams !== lastTotalParams;
return {
hasParamsChanges,
updatedParams: editedParams.total,
totalParams: totalParams.total,
totalParams: noOfParams,
};
}
async hasMWsChanged({
soClient,
lastStartedAt,
lastTotalMWs,
}: {
soClient: SavedObjectsClientContract;
lastStartedAt: string;
lastTotalMWs: number;
}) {
const { logger } = this.serverSetup;
const [editedMWs, totalMWs] = await Promise.all([
soClient.find({
type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
perPage: 0,
namespaces: [ALL_SPACES_ID],
filter: `${MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE}.updated_at > "${lastStartedAt}"`,
fields: [],
}),
soClient.find({
type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
perPage: 0,
namespaces: [ALL_SPACES_ID],
fields: [],
}),
]);
logger.debug(
`Found ${editedMWs.total} maintenance windows updated and ${totalMWs.total} total maintenance windows`
);
const updatedMWs = editedMWs.total;
const noOfMWs = totalMWs.total;
const hasMWsChanged = updatedMWs > 0 || noOfMWs !== lastTotalMWs;
return {
hasMWsChanged,
updatedMWs,
totalMWs: noOfMWs,
};
}
}

View file

@ -112,7 +112,9 @@
"@kbn/charts-plugin",
"@kbn/response-ops-rule-params",
"@kbn/response-ops-rule-form",
"@kbn/fields-metadata-plugin"
"@kbn/fields-metadata-plugin",
"@kbn/alerts-ui-shared",
"@kbn/core-lifecycle-server"
],
"exclude": ["target/**/*"]
}

View file

@ -229,6 +229,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
updated_at: decryptedCreatedMonitor.rawBody.updated_at,
created_at: decryptedCreatedMonitor.rawBody.created_at,
labels: {},
maintenance_windows: [],
});
}
});
@ -357,6 +358,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
ipv4: true,
max_attempts: 2,
labels: {},
maintenance_windows: [],
updated_at: decryptedCreatedMonitor.updated_at,
created_at: decryptedCreatedMonitor.created_at,
});
@ -473,6 +475,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
params: '',
max_attempts: 2,
labels: {},
maintenance_windows: [],
updated_at: decryptedCreatedMonitor.updated_at,
created_at: decryptedCreatedMonitor.created_at,
});
@ -578,6 +581,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
updated_at: decryptedCreatedMonitor.updated_at,
created_at: decryptedCreatedMonitor.created_at,
labels: {},
maintenance_windows: [],
});
}
} finally {

View file

@ -301,6 +301,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
updated_at: decryptedCreatedMonitor.rawBody.updated_at,
created_at: decryptedCreatedMonitor.rawBody.created_at,
labels: {},
maintenance_windows: [],
});
}
});
@ -482,6 +483,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
ipv4: true,
max_attempts: 2,
labels: {},
maintenance_windows: [],
updated_at: decryptedCreatedMonitor.updated_at,
created_at: decryptedCreatedMonitor.created_at,
});
@ -601,6 +603,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
params: '',
max_attempts: 2,
labels: {},
maintenance_windows: [],
updated_at: decryptedCreatedMonitor.updated_at,
created_at: decryptedCreatedMonitor.created_at,
});
@ -709,6 +712,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
updated_at: decryptedCreatedMonitor.updated_at,
created_at: decryptedCreatedMonitor.created_at,
labels: {},
maintenance_windows: [],
});
}
});

View file

@ -56,5 +56,6 @@
"ssl.verification_mode": "full",
"revision": 1,
"max_attempts": 2,
"labels": {}
"labels": {},
"maintenance_windows": []
}

View file

@ -79,5 +79,6 @@
"ipv4": true,
"ipv6": true,
"params": "",
"labels": {}
"labels": {},
"maintenance_windows": []
}

View file

@ -31,5 +31,6 @@
"ipv4": true,
"ipv6": true,
"params": "",
"max_attempts": 2
"max_attempts": 2,
"maintenance_windows": []
}

View file

@ -39,5 +39,6 @@
"ipv4": true,
"ipv6": true,
"params": "",
"max_attempts": 2
"max_attempts": 2,
"maintenance_windows": []
}

View file

@ -322,6 +322,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
journey_id: '',
max_attempts: 2,
labels: {},
maintenance_windows: [],
},
['config_id', 'id', 'form_monitor_type']
)

View file

@ -240,6 +240,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
},
},
],
maintenance_windows: null,
});
});
});

View file

@ -313,6 +313,7 @@ export const getHttpInput = ({
},
},
],
maintenance_windows: null,
};
return {

View file

@ -16,6 +16,7 @@ export const commonVars = {
},
maintenance_windows: {
type: 'yaml',
value: [],
},
};
@ -295,6 +296,7 @@ export const getTestProjectSyntheticsPolicyLightweight = (
},
},
],
maintenance_windows: null,
},
id: `synthetics/http-http-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`,
},
@ -601,6 +603,9 @@ export const getTestProjectSyntheticsPolicy = (
ipv4: { type: 'bool', value: true },
ipv6: { type: 'bool', value: true },
mode: { type: 'text' },
maintenance_windows: {
type: 'yaml',
},
},
id: `synthetics/http-http-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`,
},