[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",
"locations.id", "locations.id",
"locations.label", "locations.label",
"maintenance_windows",
"name", "name",
"origin", "origin",
"project_id", "project_id",

View file

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

View file

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

View file

@ -10,6 +10,7 @@
export { AlertLifecycleStatusBadge } from './src/alert_lifecycle_status_badge'; export { AlertLifecycleStatusBadge } from './src/alert_lifecycle_status_badge';
export type { AlertLifecycleStatusBadgeProps } from './src/alert_lifecycle_status_badge'; export type { AlertLifecycleStatusBadgeProps } from './src/alert_lifecycle_status_badge';
export { MaintenanceWindowCallout } from './src/maintenance_window_callout'; 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 { AddMessageVariables } from './src/add_message_variables';
export * from './src/common/hooks'; export * from './src/common/hooks';

View file

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

View file

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

View file

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

View file

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

View file

@ -64,6 +64,7 @@ export const ProjectMonitorCodec = t.intersection([
retestOnFailure: t.boolean, retestOnFailure: t.boolean,
fields: t.record(t.string, t.string), fields: t.record(t.string, t.string),
'service.name': 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, timestamp: t.string,
spaceId: t.string, spaceId: t.string,
urls: 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, component: Component,
helpText, helpText,
label, label,
labelAppend,
ariaLabel, ariaLabel,
props, props,
fieldKey, fieldKey,
@ -57,6 +58,7 @@ export const Field = memo<Props>(
'aria-label': ariaLabel, 'aria-label': ariaLabel,
helpText, helpText,
fullWidth: true, fullWidth: true,
labelAppend,
}; };
return controlled ? ( return controlled ? (

View file

@ -30,6 +30,8 @@ import {
EuiBadge, EuiBadge,
EuiToolTip, EuiToolTip,
} from '@elastic/eui'; } 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 { kibanaService } from '../../../../../utils/kibana_service';
import { import {
PROFILE_OPTIONS, PROFILE_OPTIONS,
@ -60,6 +62,7 @@ import {
KeyValuePairsField, KeyValuePairsField,
TextArea, TextArea,
ThrottlingWrapper, ThrottlingWrapper,
MaintenanceWindowsFieldWrapper,
} from './field_wrappers'; } from './field_wrappers';
import { useMonitorName } from '../../../hooks/use_monitor_name'; import { useMonitorName } from '../../../hooks/use_monitor_name';
import { import {
@ -1676,4 +1679,26 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({
'data-test-subj': 'syntheticsEnableAttemptSwitch', '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, EuiTextArea,
EuiTextAreaProps, EuiTextAreaProps,
} from '@elastic/eui'; } from '@elastic/eui';
import {
MaintenanceWindowsField,
MaintenanceWindowsFieldProps,
} from '../fields/maintenance_windows/maintenance_windows';
import { import {
ThrottlingConfigField, ThrottlingConfigField,
ThrottlingConfigFieldProps, ThrottlingConfigFieldProps,
@ -154,3 +158,8 @@ export const ResponseBodyIndexField = React.forwardRef<unknown, DefaultResponseB
export const ThrottlingWrapper = React.forwardRef<unknown, ThrottlingConfigFieldProps>( export const ThrottlingWrapper = React.forwardRef<unknown, ThrottlingConfigFieldProps>(
(props, _ref) => <ThrottlingConfigField {...props} /> (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) => ({ const HTTP_ADVANCED = (readOnly: boolean) => ({
requestConfig: { requestConfig: {
title: i18n.translate('xpack.synthetics.monitorConfig.section.requestConfiguration.title', { title: i18n.translate('xpack.synthetics.monitorConfig.section.requestConfiguration.title', {
@ -206,6 +220,7 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({
], ],
advanced: [ advanced: [
DEFAULT_DATA_OPTIONS(readOnly), DEFAULT_DATA_OPTIONS(readOnly),
MAINTENANCE_WINDOWS_OPTIONS(readOnly),
HTTP_ADVANCED(readOnly).requestConfig, HTTP_ADVANCED(readOnly).requestConfig,
HTTP_ADVANCED(readOnly).responseConfig, HTTP_ADVANCED(readOnly).responseConfig,
HTTP_ADVANCED(readOnly).responseChecks, HTTP_ADVANCED(readOnly).responseChecks,
@ -227,6 +242,7 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({
], ],
advanced: [ advanced: [
DEFAULT_DATA_OPTIONS(readOnly), DEFAULT_DATA_OPTIONS(readOnly),
MAINTENANCE_WINDOWS_OPTIONS(readOnly),
TCP_ADVANCED(readOnly).requestConfig, TCP_ADVANCED(readOnly).requestConfig,
TCP_ADVANCED(readOnly).responseChecks, TCP_ADVANCED(readOnly).responseChecks,
TLS_OPTIONS(readOnly), TLS_OPTIONS(readOnly),
@ -255,6 +271,7 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({
FIELD(readOnly)[ConfigKey.NAMESPACE], FIELD(readOnly)[ConfigKey.NAMESPACE],
], ],
}, },
MAINTENANCE_WINDOWS_OPTIONS(readOnly),
...BROWSER_ADVANCED(readOnly), ...BROWSER_ADVANCED(readOnly),
], ],
}, },
@ -281,6 +298,7 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({
FIELD(readOnly)[ConfigKey.NAMESPACE], FIELD(readOnly)[ConfigKey.NAMESPACE],
], ],
}, },
MAINTENANCE_WINDOWS_OPTIONS(readOnly),
...BROWSER_ADVANCED(readOnly), ...BROWSER_ADVANCED(readOnly),
], ],
}, },
@ -297,6 +315,10 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({
FIELD(readOnly)[ConfigKey.MAX_ATTEMPTS], FIELD(readOnly)[ConfigKey.MAX_ATTEMPTS],
FIELD(readOnly)[AlertConfigKey.STATUS_ENABLED], 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; fieldKey: keyof FormConfig;
component: React.ComponentType<any>; component: React.ComponentType<any>;
label?: string | React.ReactNode; label?: string | React.ReactNode;
labelAppend?: React.ReactNode;
ariaLabel?: string; ariaLabel?: string;
helpText?: string | React.ReactNode; helpText?: string | React.ReactNode;
hidden?: (depenencies: unknown[]) => boolean; hidden?: (dependencies: unknown[]) => boolean;
props?: (params: { props?: (params: {
field?: ControllerRenderProps<FormConfig, TFieldKey>; field?: ControllerRenderProps<FormConfig, TFieldKey>;
formState: FormState<FormConfig>; formState: FormState<FormConfig>;
@ -166,4 +167,5 @@ export interface FieldMap {
[ConfigKey.IPV4]: FieldMeta<ConfigKey.IPV4>; [ConfigKey.IPV4]: FieldMeta<ConfigKey.IPV4>;
[ConfigKey.MAX_ATTEMPTS]: FieldMeta<ConfigKey.MAX_ATTEMPTS>; [ConfigKey.MAX_ATTEMPTS]: FieldMeta<ConfigKey.MAX_ATTEMPTS>;
[ConfigKey.LABELS]: FieldMeta<ConfigKey.LABELS>; [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 { EuiTitle, EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { LoadWhenInView } from '@kbn/observability-shared-plugin/public'; import { LoadWhenInView } from '@kbn/observability-shared-plugin/public';
import { MonitorMWsCallout } from '../../common/mws_callout/monitor_mws_callout';
import { SummaryPanel } from './summary_panel'; import { SummaryPanel } from './summary_panel';
import { useMonitorDetailsPage } from '../use_monitor_details_page'; import { useMonitorDetailsPage } from '../use_monitor_details_page';
@ -34,6 +35,7 @@ export const MonitorSummary = () => {
return ( return (
<MonitorPendingWrapper> <MonitorPendingWrapper>
<MonitorMWsCallout />
<SummaryPanel dateLabel={dateLabel} from={from} to={to} /> <SummaryPanel dateLabel={dateLabel} from={from} to={to} />
<EuiSpacer size="m" /> <EuiSpacer size="m" />
<EuiFlexGroup gutterSize="m" wrap={true} responsive={false}> <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 { Redirect } from 'react-router-dom';
import { useTrackPageview } from '@kbn/observability-shared-plugin/public'; import { useTrackPageview } from '@kbn/observability-shared-plugin/public';
import { MonitorsMWsCallout } from '../common/mws_callout/monitors_mws_callout';
import { DisabledCallout } from './management/disabled_callout'; import { DisabledCallout } from './management/disabled_callout';
import { useOverviewStatus } from './hooks/use_overview_status'; import { useOverviewStatus } from './hooks/use_overview_status';
import { GETTING_STARTED_ROUTE } from '../../../../../common/constants'; import { GETTING_STARTED_ROUTE } from '../../../../../common/constants';
@ -54,6 +55,7 @@ export const MonitorManagementPage: React.FC = () => {
errorBody={labels.ERROR_HEADING_BODY} errorBody={labels.ERROR_HEADING_BODY}
> >
<DisabledCallout total={absoluteTotal} /> <DisabledCallout total={absoluteTotal} />
<MonitorsMWsCallout />
<MonitorListContainer isEnabled={isEnabled} monitorListProps={monitorListProps} /> <MonitorListContainer isEnabled={isEnabled} monitorListProps={monitorListProps} />
</Loader> </Loader>
{showEmptyState && <EnablementEmptyState />} {showEmptyState && <EnablementEmptyState />}

View file

@ -20,11 +20,13 @@ import {
EuiLink, EuiLink,
EuiSpacer, EuiSpacer,
EuiSkeletonText, EuiSkeletonText,
EuiIcon,
} from '@elastic/eui'; } from '@elastic/eui';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { useMonitorMWs } from '../../../hooks/use_monitor_mws';
import { MetricErrorIcon } from './metric_error_icon'; import { MetricErrorIcon } from './metric_error_icon';
import { OverviewStatusMetaData } from '../../../../../../../../common/runtime_types'; import { OverviewStatusMetaData } from '../../../../../../../../common/runtime_types';
import { isTestRunning, manualTestRunSelector } from '../../../../../state/manual_test_runs'; import { isTestRunning, manualTestRunSelector } from '../../../../../state/manual_test_runs';
@ -61,6 +63,7 @@ export const MetricItemIcon = ({
}); });
const dispatch = useDispatch(); const dispatch = useDispatch();
const { activeMWs } = useMonitorMWs(monitor);
const inProgress = isTestRunning(testNowRun); 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 = () => { const closePopover = () => {
dispatch(toggleErrorPopoverOpen(null)); 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 { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { Store } from 'redux'; import { Store } from 'redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SyntheticsRefreshContextProvider } from './synthetics_refresh_context'; import { SyntheticsRefreshContextProvider } from './synthetics_refresh_context';
import { SyntheticsDataViewContextProvider } from './synthetics_data_view_context'; import { SyntheticsDataViewContextProvider } from './synthetics_data_view_context';
import { SyntheticsAppProps } from './synthetics_settings_context'; import { SyntheticsAppProps } from './synthetics_settings_context';
@ -20,6 +21,8 @@ import { storage, store } from '../state';
export const SyntheticsSharedContext: React.FC< export const SyntheticsSharedContext: React.FC<
React.PropsWithChildren<SyntheticsAppProps & { reload$?: Subject<boolean>; reduxStore?: Store }> React.PropsWithChildren<SyntheticsAppProps & { reload$?: Subject<boolean>; reduxStore?: Store }>
> = ({ reduxStore, coreStart, setupPlugins, startPlugins, children, darkMode, reload$ }) => { > = ({ reduxStore, coreStart, setupPlugins, startPlugins, children, darkMode, reload$ }) => {
const queryClient = new QueryClient();
return ( return (
<KibanaContextProvider <KibanaContextProvider
services={{ services={{
@ -46,20 +49,22 @@ export const SyntheticsSharedContext: React.FC<
> >
<EuiThemeProvider darkMode={darkMode}> <EuiThemeProvider darkMode={darkMode}>
<ReduxProvider store={reduxStore ?? store}> <ReduxProvider store={reduxStore ?? store}>
<SyntheticsRefreshContextProvider reload$={reload$}> <QueryClientProvider client={queryClient}>
<SyntheticsDataViewContextProvider dataViews={startPlugins.dataViews}> <SyntheticsRefreshContextProvider reload$={reload$}>
<RedirectAppLinks <SyntheticsDataViewContextProvider dataViews={startPlugins.dataViews}>
coreStart={{ <RedirectAppLinks
application: coreStart.application, coreStart={{
}} application: coreStart.application,
style={{ }}
height: '100%', style={{
}} height: '100%',
> }}
{children} >
</RedirectAppLinks> {children}
</SyntheticsDataViewContextProvider> </RedirectAppLinks>
</SyntheticsRefreshContextProvider> </SyntheticsDataViewContextProvider>
</SyntheticsRefreshContextProvider>
</QueryClientProvider>
</ReduxProvider> </ReduxProvider>
</EuiThemeProvider> </EuiThemeProvider>
</KibanaContextProvider> </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 { all, fork } from 'redux-saga/effects';
import { getMaintenanceWindowsEffect } from './maintenance_windows';
import { getCertsListEffect } from './certs'; import { getCertsListEffect } from './certs';
import { import {
addGlobalParamEffect, addGlobalParamEffect,
@ -82,6 +83,7 @@ export const rootEffect = function* root(): Generator {
fork(refreshOverviewTrendStats), fork(refreshOverviewTrendStats),
fork(inspectStatusRuleEffect), fork(inspectStatusRuleEffect),
fork(inspectTLSRuleEffect), fork(inspectTLSRuleEffect),
fork(getMaintenanceWindowsEffect),
...privateLocationsEffects.map((effect) => fork(effect)), ...privateLocationsEffects.map((effect) => fork(effect)),
]); ]);
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -350,6 +350,7 @@ export class OverviewStatusService {
ConfigKey.PROJECT_ID, ConfigKey.PROJECT_ID,
ConfigKey.ALERT_CONFIG, ConfigKey.ALERT_CONFIG,
ConfigKey.URLS, ConfigKey.URLS,
ConfigKey.MAINTENANCE_WINDOWS,
], ],
}); });
} }
@ -371,6 +372,7 @@ export class OverviewStatusService {
updated_at: monitor.updated_at, updated_at: monitor.updated_at,
spaceId: monitor.namespaces?.[0], spaceId: monitor.namespaces?.[0],
urls: monitor.attributes[ConfigKey.URLS], 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, 'url.port': null,
urls: 'https://elastic.co', urls: 'https://elastic.co',
labels: {}, labels: {},
maintenance_windows: [],
}, },
coreMigrationVersion: '8.8.0', coreMigrationVersion: '8.8.0',
created_at: '2023-03-31T20:31:24.177Z', created_at: '2023-03-31T20:31:24.177Z',

View file

@ -246,6 +246,9 @@ export const getSyntheticsMonitorSavedObjectType = (
}, },
}, },
}, },
maintenance_windows: {
type: 'keyword',
},
}, },
}, },
management: { 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 { Logger } from '@kbn/logging';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { MaintenanceWindow } from '@kbn/alerting-plugin/server/application/maintenance_window/types';
import { ConfigKey, MonitorFields } from '../../../common/runtime_types'; import { ConfigKey, MonitorFields } from '../../../common/runtime_types';
import { ParsedVars, replaceVarsWithParams } from './lightweight_param_formatter'; import { ParsedVars, replaceVarsWithParams } from './lightweight_param_formatter';
import variableParser from './variable_parser'; import variableParser from './variable_parser';
@ -83,3 +84,41 @@ export const secondsToCronFormatter: FormatterFn = (fields, key) => {
export const maxAttemptsFormatter: FormatterFn = (fields, key) => { export const maxAttemptsFormatter: FormatterFn = (fields, key) => {
return (fields[key] as number) ?? 2; 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.MONITOR_QUERY_ID]: stringToJsonFormatter,
[ConfigKey.PARAMS]: null, [ConfigKey.PARAMS]: null,
[ConfigKey.MAX_ATTEMPTS]: null, [ConfigKey.MAX_ATTEMPTS]: null,
[ConfigKey.MAINTENANCE_WINDOWS]: null,
retest_on_failure: null, retest_on_failure: null,
[ConfigKey.SCHEDULE]: (fields) => [ConfigKey.SCHEDULE]: (fields) =>
JSON.stringify( JSON.stringify(

View file

@ -7,15 +7,38 @@
import { ConfigKey, MonitorTypeEnum } from '../../../../common/runtime_types'; import { ConfigKey, MonitorTypeEnum } from '../../../../common/runtime_types';
import { formatSyntheticsPolicy } from './format_synthetics_policy'; import { formatSyntheticsPolicy } from './format_synthetics_policy';
import { PROFILE_VALUES_ENUM, PROFILES_MAP } from '../../../../common/constants/monitor_defaults'; 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 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', () => { describe('formatSyntheticsPolicy', () => {
it('formats browser policy', () => { it('formats browser policy', () => {
const { formattedPolicy } = formatSyntheticsPolicy( const { formattedPolicy } = formatSyntheticsPolicy(
testNewPolicy, testNewPolicy,
MonitorTypeEnum.BROWSER, MonitorTypeEnum.BROWSER,
browserConfig, browserConfig,
gParams gParams,
testMW
); );
expect(formattedPolicy).toEqual({ expect(formattedPolicy).toEqual({
@ -142,6 +165,9 @@ describe('formatSyntheticsPolicy', () => {
username: { username: {
type: 'text', type: 'text',
}, },
maintenance_windows: {
type: 'yaml',
},
}, },
}, },
], ],
@ -242,6 +268,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text', type: 'text',
value: 'tcp', value: 'tcp',
}, },
maintenance_windows: { type: 'yaml' },
}, },
}, },
], ],
@ -315,6 +342,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text', type: 'text',
value: '1s', value: '1s',
}, },
maintenance_windows: { type: 'yaml' },
}, },
}, },
], ],
@ -432,6 +460,11 @@ describe('formatSyntheticsPolicy', () => {
type: 'text', type: 'text',
value: 'browser', 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, ...httpPolicy,
[ConfigKey.METADATA]: { is_tls_enabled: isTLSEnabled }, [ConfigKey.METADATA]: { is_tls_enabled: isTLSEnabled },
}, },
gParams gParams,
[]
); );
expect(formattedPolicy).toEqual({ expect(formattedPolicy).toEqual({
@ -628,6 +662,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text', type: 'text',
value: '"admin"', value: '"admin"',
}, },
maintenance_windows: { type: 'yaml' },
}, },
}, },
], ],
@ -728,6 +763,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text', type: 'text',
value: 'tcp', value: 'tcp',
}, },
maintenance_windows: { type: 'yaml' },
}, },
}, },
], ],
@ -801,6 +837,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text', type: 'text',
value: '1s', value: '1s',
}, },
maintenance_windows: { type: 'yaml' },
}, },
}, },
], ],
@ -897,6 +934,7 @@ describe('formatSyntheticsPolicy', () => {
type: 'text', type: 'text',
value: 'browser', value: 'browser',
}, },
maintenance_windows: { type: 'yaml' },
}, },
}, },
{ {
@ -987,6 +1025,7 @@ const testNewPolicy = {
origin: { type: 'text' }, origin: { type: 'text' },
'monitor.project.id': { type: 'text' }, 'monitor.project.id': { type: 'text' },
'monitor.project.name': { type: 'text' }, 'monitor.project.name': { type: 'text' },
maintenance_windows: { type: 'yaml' },
}, },
}, },
], ],
@ -1026,6 +1065,7 @@ const testNewPolicy = {
origin: { type: 'text' }, origin: { type: 'text' },
'monitor.project.id': { type: 'text' }, 'monitor.project.id': { type: 'text' },
'monitor.project.name': { type: 'text' }, 'monitor.project.name': { type: 'text' },
maintenance_windows: { type: 'yaml' },
}, },
}, },
], ],
@ -1056,6 +1096,7 @@ const testNewPolicy = {
origin: { type: 'text' }, origin: { type: 'text' },
'monitor.project.id': { type: 'text' }, 'monitor.project.id': { type: 'text' },
'monitor.project.name': { type: 'text' }, 'monitor.project.name': { type: 'text' },
maintenance_windows: { type: 'yaml' },
}, },
}, },
], ],
@ -1094,6 +1135,7 @@ const testNewPolicy = {
origin: { type: 'text' }, origin: { type: 'text' },
'monitor.project.id': { type: 'text' }, 'monitor.project.id': { type: 'text' },
'monitor.project.name': { type: 'text' }, 'monitor.project.name': { type: 'text' },
maintenance_windows: { type: 'yaml' },
}, },
}, },
{ enabled: true, data_stream: { type: 'synthetics', dataset: 'browser.network' } }, { 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: { config_id: '00bb3ceb-a242-4c7a-8405-8da963661374' },
fields_under_root: true, fields_under_root: true,
location_name: 'Test private location 0', location_name: 'Test private location 0',
maintenance_windows: ['190bd51b-985a-4553-9fba-57222ddde6b7'],
}; };
const httpPolicy: any = { const httpPolicy: any = {

View file

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

View file

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

View file

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

View file

@ -7,7 +7,8 @@
import { isEmpty, isNil, omitBy } from 'lodash'; import { isEmpty, isNil, omitBy } from 'lodash';
import { Logger } from '@kbn/logging'; 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 { PARAMS_KEYS_TO_SKIP } from '../common';
import { import {
BrowserFields, BrowserFields,
@ -37,7 +38,8 @@ export const formatMonitorConfigFields = (
configKeys: ConfigKey[], configKeys: ConfigKey[],
config: Partial<MonitorFields>, config: Partial<MonitorFields>,
logger: Logger, logger: Logger,
params: Record<string, string> params: Record<string, string>,
mws: MaintenanceWindow[]
) => { ) => {
const formattedMonitor = {} as Record<ConfigKey, any>; const formattedMonitor = {} as Record<ConfigKey, any>;
@ -76,6 +78,19 @@ export const formatMonitorConfigFields = (
sslKeys.forEach((key) => (formattedMonitor[key] = null)); 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>; return omitBy(formattedMonitor, isNil) as Partial<MonitorFields>;
}; };

View file

@ -138,7 +138,8 @@ describe('SyntheticsPrivateLocation', () => {
await syntheticsPrivateLocation.editMonitors( await syntheticsPrivateLocation.editMonitors(
[{ config: testConfig, globalParams: {} }], [{ config: testConfig, globalParams: {} }],
[mockPrivateLocation], [mockPrivateLocation],
'test-space' 'test-space',
[]
); );
} catch (e) { } catch (e) {
expect(e).toEqual(new Error(error)); expect(e).toEqual(new Error(error));
@ -183,7 +184,8 @@ describe('SyntheticsPrivateLocation', () => {
testMonitorPolicy, testMonitorPolicy,
MonitorTypeEnum.BROWSER, MonitorTypeEnum.BROWSER,
dummyBrowserConfig, dummyBrowserConfig,
{} {},
[]
); );
expect(test.formattedPolicy.inputs[3].streams[1]).toStrictEqual({ 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 { NewPackagePolicyWithId } from '@kbn/fleet-plugin/server/services/package_policy';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { SavedObjectError } from '@kbn/core-saved-objects-common'; 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 { DEFAULT_NAMESPACE_STRING } from '../../../common/constants/monitor_defaults';
import { import {
BROWSER_TEST_NOW_RUN, BROWSER_TEST_NOW_RUN,
@ -75,6 +76,7 @@ export class SyntheticsPrivateLocation {
newPolicyTemplate: NewPackagePolicy, newPolicyTemplate: NewPackagePolicy,
spaceId: string, spaceId: string,
globalParams: Record<string, string>, globalParams: Record<string, string>,
maintenanceWindows: MaintenanceWindow[],
testRunId?: string, testRunId?: string,
runOnce?: boolean runOnce?: boolean
): Promise<NewPackagePolicy | null> { ): Promise<NewPackagePolicy | null> {
@ -122,7 +124,8 @@ export class SyntheticsPrivateLocation {
: {}), : {}),
...(runOnce ? { run_once: runOnce } : {}), ...(runOnce ? { run_once: runOnce } : {}),
}, },
globalParams globalParams,
maintenanceWindows
); );
return formattedPolicy; return formattedPolicy;
@ -165,6 +168,7 @@ export class SyntheticsPrivateLocation {
newPolicyTemplate, newPolicyTemplate,
spaceId, spaceId,
globalParams, globalParams,
[],
testRunId, testRunId,
runOnce runOnce
); );
@ -214,10 +218,12 @@ export class SyntheticsPrivateLocation {
privateConfig, privateConfig,
spaceId, spaceId,
allPrivateLocations, allPrivateLocations,
maintenanceWindows,
}: { }: {
privateConfig?: PrivateConfig; privateConfig?: PrivateConfig;
allPrivateLocations: PrivateLocationAttributes[]; allPrivateLocations: PrivateLocationAttributes[];
spaceId: string; spaceId: string;
maintenanceWindows: MaintenanceWindow[];
}) { }) {
if (!privateConfig) { if (!privateConfig) {
return null; return null;
@ -238,7 +244,8 @@ export class SyntheticsPrivateLocation {
location, location,
newPolicyTemplate, newPolicyTemplate,
spaceId, spaceId,
globalParams globalParams,
maintenanceWindows
); );
const pkgPolicy = { const pkgPolicy = {
@ -256,7 +263,8 @@ export class SyntheticsPrivateLocation {
async editMonitors( async editMonitors(
configs: Array<{ config: HeartbeatConfig; globalParams: Record<string, string> }>, configs: Array<{ config: HeartbeatConfig; globalParams: Record<string, string> }>,
allPrivateLocations: SyntheticsPrivateLocations, allPrivateLocations: SyntheticsPrivateLocations,
spaceId: string spaceId: string,
maintenanceWindows: MaintenanceWindow[]
) { ) {
if (configs.length === 0) { if (configs.length === 0) {
return {}; return {};
@ -291,7 +299,8 @@ export class SyntheticsPrivateLocation {
privateLocation, privateLocation,
newPolicyTemplate, newPolicyTemplate,
spaceId, spaceId,
globalParams globalParams,
maintenanceWindows
); );
if (!newPolicy) { if (!newPolicy) {

View file

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

View file

@ -101,6 +101,8 @@ export const getNormalizeCommonFields = ({
// picking out keys specifically, so users can't add arbitrary fields // picking out keys specifically, so users can't add arbitrary fields
[ConfigKey.ALERT_CONFIG]: getAlertConfig(monitor), [ConfigKey.ALERT_CONFIG]: getAlertConfig(monitor),
[ConfigKey.LABELS]: monitor.fields || defaultFields[ConfigKey.LABELS], [ConfigKey.LABELS]: monitor.fields || defaultFields[ConfigKey.LABELS],
[ConfigKey.MAINTENANCE_WINDOWS]:
monitor.maintenanceWindows || defaultFields[ConfigKey.MAINTENANCE_WINDOWS],
...(monitor[ConfigKey.APM_SERVICE_NAME] && { ...(monitor[ConfigKey.APM_SERVICE_NAME] && {
[ConfigKey.APM_SERVICE_NAME]: 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.addConfigs = jest.fn();
syntheticsService.editConfig = jest.fn(); syntheticsService.editConfig = jest.fn();
syntheticsService.deleteConfigs = jest.fn(); syntheticsService.deleteConfigs = jest.fn();
syntheticsService.getMaintenanceWindows = jest.fn();
const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createStart().getClient(); const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createStart().getClient();

View file

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

View file

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

View file

@ -145,6 +145,8 @@ describe('SyntheticsService', () => {
jest.spyOn(service, 'getOutput').mockResolvedValue({ hosts: ['es'], api_key: 'i:k' }); jest.spyOn(service, 'getOutput').mockResolvedValue({ hosts: ['es'], api_key: 'i:k' });
jest.spyOn(service, 'getSyntheticsParams').mockResolvedValue({}); jest.spyOn(service, 'getSyntheticsParams').mockResolvedValue({});
service.getMaintenanceWindows = jest.fn();
return { service, locations }; return { service, locations };
}; };
@ -223,7 +225,7 @@ describe('SyntheticsService', () => {
(axios as jest.MockedFunction<typeof axios>).mockResolvedValue({} as AxiosResponse); (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).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith( expect(axios).toHaveBeenCalledWith(
@ -289,7 +291,7 @@ describe('SyntheticsService', () => {
const payload = getFakePayload([locations[0]]); 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).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith( expect(axios).toHaveBeenCalledWith(
@ -306,7 +308,7 @@ describe('SyntheticsService', () => {
const payload = getFakePayload([locations[0]]); 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).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith( expect(axios).toHaveBeenCalledWith(
@ -323,7 +325,7 @@ describe('SyntheticsService', () => {
const payload = getFakePayload([locations[0]]); 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).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith( expect(axios).toHaveBeenCalledWith(
@ -533,6 +535,8 @@ describe('SyntheticsService', () => {
jest.spyOn(service, 'getOutput').mockResolvedValue({ hosts: ['es'], api_key: 'i:k' }); jest.spyOn(service, 'getOutput').mockResolvedValue({ hosts: ['es'], api_key: 'i:k' });
jest.spyOn(service, 'getSyntheticsParams').mockResolvedValue({}); jest.spyOn(service, 'getSyntheticsParams').mockResolvedValue({});
service.getMaintenanceWindows = jest.fn();
it('paginates the results', async () => { it('paginates the results', async () => {
serverMock.config = mockConfig; 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 { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants';
import pMap from 'p-map'; import pMap from 'p-map';
import moment from 'moment'; 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 { registerCleanUpTask } from './private_location/clean_up_task';
import { SyntheticsServerSetup } from '../types'; import { SyntheticsServerSetup } from '../types';
import { syntheticsMonitorType, syntheticsParamType } from '../../common/types/saved_objects'; import { syntheticsMonitorType, syntheticsParamType } from '../../common/types/saved_objects';
@ -326,11 +330,11 @@ export class SyntheticsService {
}; };
} }
async inspectConfig(config?: ConfigData) { async inspectConfig(config: ConfigData | null, mws: MaintenanceWindow[]) {
if (!config) { if (!config || isEmpty(config)) {
return null; return null;
} }
const monitors = this.formatConfigs(config); const monitors = this.formatConfigs(config, mws);
const license = await this.getLicense(); const license = await this.getLicense();
const output = await this.getOutput({ inspect: true }); const output = await this.getOutput({ inspect: true });
@ -344,13 +348,13 @@ export class SyntheticsService {
return null; return null;
} }
async addConfigs(configs: ConfigData[]) { async addConfigs(configs: ConfigData[], mws: MaintenanceWindow[]) {
try { try {
if (configs.length === 0 || !this.isAllowed) { if (configs.length === 0 || !this.isAllowed) {
return; return;
} }
const monitors = this.formatConfigs(configs); const monitors = this.formatConfigs(configs, mws);
const license = await this.getLicense(); const license = await this.getLicense();
const output = await this.getOutput(); 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 { try {
if (monitorConfig.length === 0 || !this.isAllowed) { if (monitorConfig.length === 0 || !this.isAllowed) {
return; return;
} }
const license = await this.getLicense(); const license = await this.getLicense();
const monitors = this.formatConfigs(monitorConfig); const monitors = this.formatConfigs(monitorConfig, mws);
const output = await this.getOutput(); const output = await this.getOutput();
if (output) { if (output) {
@ -411,6 +415,7 @@ export class SyntheticsService {
let output: ServiceData['output'] | null = null; let output: ServiceData['output'] | null = null;
const paramsBySpace = await this.getSyntheticsParams(); const paramsBySpace = await this.getSyntheticsParams();
const maintenanceWindows = await this.getMaintenanceWindows();
const finder = await this.getSOClientFinder({ pageSize: PER_PAGE }); const finder = await this.getSOClientFinder({ pageSize: PER_PAGE });
const bucketsByLocation: Record<string, MonitorFields[]> = {}; const bucketsByLocation: Record<string, MonitorFields[]> = {};
@ -462,7 +467,11 @@ export class SyntheticsService {
} }
const monitors = result.saved_objects.filter(({ error }) => !error); const monitors = result.saved_objects.filter(({ error }) => !error);
const formattedConfigs = this.normalizeConfigs(monitors, paramsBySpace); const formattedConfigs = this.normalizeConfigs(
monitors,
paramsBySpace,
maintenanceWindows
);
this.logger.debug( this.logger.debug(
`${formattedConfigs.length} monitors will be pushed to synthetics service.` `${formattedConfigs.length} monitors will be pushed to synthetics service.`
@ -504,7 +513,7 @@ export class SyntheticsService {
if (!configs) { if (!configs) {
return; return;
} }
const monitors = this.formatConfigs(configs); const monitors = this.formatConfigs(configs, []);
if (monitors.length === 0) { if (monitors.length === 0) {
return; return;
} }
@ -545,7 +554,7 @@ export class SyntheticsService {
const data = { const data = {
output, output,
monitors: this.formatConfigs(configs), monitors: this.formatConfigs(configs, []),
license, license,
}; };
return await this.apiClient.delete(data); return await this.apiClient.delete(data);
@ -557,7 +566,6 @@ export class SyntheticsService {
async deleteAllConfigs() { async deleteAllConfigs() {
const license = await this.getLicense(); const license = await this.getLicense();
const paramsBySpace = await this.getSyntheticsParams();
const finder = await this.getSOClientFinder({ pageSize: 100 }); const finder = await this.getSOClientFinder({ pageSize: 100 });
const output = await this.getOutput(); const output = await this.getOutput();
if (!output) { if (!output) {
@ -565,7 +573,7 @@ export class SyntheticsService {
} }
for await (const result of finder.find()) { 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) => const hasPublicLocations = monitors.some((config) =>
config.locations.some(({ isServiceManaged }) => isServiceManaged) config.locations.some(({ isServiceManaged }) => isServiceManaged)
); );
@ -636,7 +644,24 @@ export class SyntheticsService {
return paramsBySpace; 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]; const configDataList = Array.isArray(configData) ? configData : [configData];
return configDataList.map((config) => { return configDataList.map((config) => {
@ -651,20 +676,22 @@ export class SyntheticsService {
Object.keys(asHeartbeatConfig) as ConfigKey[], Object.keys(asHeartbeatConfig) as ConfigKey[],
asHeartbeatConfig as Partial<MonitorFields>, asHeartbeatConfig as Partial<MonitorFields>,
this.logger, this.logger,
params ?? {} params ?? {},
mws
); );
}); });
} }
normalizeConfigs( normalizeConfigs(
monitors: Array<SavedObject<SyntheticsMonitorWithSecretsAttributes>>, monitors: Array<SavedObject<SyntheticsMonitorWithSecretsAttributes>>,
paramsBySpace: Record<string, Record<string, string>> paramsBySpace: Record<string, Record<string, string>>,
mws: MaintenanceWindow[]
) { ) {
const configDataList = (monitors ?? []).map((monitor) => { const configDataList = (monitors ?? []).map((monitor) => {
const attributes = monitor.attributes as unknown as MonitorFields; const attributes = monitor.attributes as unknown as MonitorFields;
const monitorSpace = monitor.namespaces?.[0] ?? DEFAULT_SPACE_ID; const monitorSpace = monitor.namespaces?.[0] ?? DEFAULT_SPACE_ID;
const params = paramsBySpace[monitorSpace]; const params = paramsBySpace[monitorSpace] ?? {};
return { return {
params: { ...params, ...(paramsBySpace?.[ALL_SPACES_ID] ?? {}) }, 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>) { checkMissingSchedule(state: Record<string, string>) {
try { try {
@ -685,7 +712,7 @@ export class SyntheticsService {
if (lastRunAt) { if (lastRunAt) {
// log if it has missed last schedule // log if it has missed last schedule
const diff = moment(current).diff(lastRunAt, 'minutes'); 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) { if (diff > syncInterval) {
const message = `Synthetics monitor sync task has missed its schedule, it last ran ${diff} minutes ago.`; const message = `Synthetics monitor sync task has missed its schedule, it last ran ${diff} minutes ago.`;
this.logger.warn(message); 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 { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants';
import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server';
import moment from 'moment'; import moment from 'moment';
import { MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/common';
import { syntheticsParamType } from '../../common/types/saved_objects'; import { syntheticsParamType } from '../../common/types/saved_objects';
import { normalizeSecrets } from '../synthetics_service/utils'; import { normalizeSecrets } from '../synthetics_service/utils';
import type { PrivateLocationAttributes } from '../runtime_types/private_locations'; import type { PrivateLocationAttributes } from '../runtime_types/private_locations';
@ -33,13 +34,17 @@ import {
const TASK_TYPE = 'Synthetics:Sync-Private-Location-Monitors'; const TASK_TYPE = 'Synthetics:Sync-Private-Location-Monitors';
const TASK_ID = `${TASK_TYPE}-single-instance`; const TASK_ID = `${TASK_TYPE}-single-instance`;
const TASK_SCHEDULE = '5m';
interface TaskState extends Record<string, unknown> { interface TaskState extends Record<string, unknown> {
lastStartedAt: string; lastStartedAt: string;
lastTotalParams: number; lastTotalParams: number;
lastTotalMWs: number;
} }
type CustomTaskInstance = Omit<ConcreteTaskInstance, 'state'> & { state: Partial<TaskState> }; export type CustomTaskInstance = Omit<ConcreteTaskInstance, 'state'> & {
state: Partial<TaskState>;
};
export class SyncPrivateLocationMonitorsTask { export class SyncPrivateLocationMonitorsTask {
constructor( constructor(
@ -79,18 +84,23 @@ export class SyncPrivateLocationMonitorsTask {
taskInstance.state.lastStartedAt || moment().subtract(10, 'minute').toISOString(); taskInstance.state.lastStartedAt || moment().subtract(10, 'minute').toISOString();
const startedAt = taskInstance.startedAt || new Date(); const startedAt = taskInstance.startedAt || new Date();
let lastTotalParams = taskInstance.state.lastTotalParams || 0; let lastTotalParams = taskInstance.state.lastTotalParams || 0;
let lastTotalMWs = taskInstance.state.lastTotalMWs || 0;
try { try {
logger.debug( logger.debug(
`Syncing private location monitors, last total params ${lastTotalParams}, last run ${lastStartedAt}` `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 allPrivateLocations = await getPrivateLocations(soClient);
const { updatedParams, totalParams } = await this.hasAnyParamChanged(soClient, lastStartedAt); const { totalMWs, totalParams, hasDataChanged } = await this.hasAnyDataChanged({
if (updatedParams > 0 || totalParams !== lastTotalParams) { soClient,
lastTotalParams = totalParams; taskInstance,
logger.debug( });
`Syncing private location monitors because params changed, updated params ${updatedParams}, total params ${totalParams}` lastTotalParams = totalParams;
); lastTotalMWs = totalMWs;
if (hasDataChanged) {
logger.debug(`Syncing private location monitors because data has changed`);
if (allPrivateLocations.length > 0) { if (allPrivateLocations.length > 0) {
await this.syncGlobalParams({ await this.syncGlobalParams({
@ -101,9 +111,8 @@ export class SyncPrivateLocationMonitorsTask {
} }
logger.debug(`Sync of private location monitors succeeded`); logger.debug(`Sync of private location monitors succeeded`);
} else { } else {
lastTotalParams = totalParams;
logger.debug( 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) { } catch (error) {
@ -113,6 +122,7 @@ export class SyncPrivateLocationMonitorsTask {
state: { state: {
lastStartedAt: startedAt.toISOString(), lastStartedAt: startedAt.toISOString(),
lastTotalParams, lastTotalParams,
lastTotalMWs,
}, },
}; };
} }
@ -120,6 +130,7 @@ export class SyncPrivateLocationMonitorsTask {
state: { state: {
lastStartedAt: startedAt.toISOString(), lastStartedAt: startedAt.toISOString(),
lastTotalParams, lastTotalParams,
lastTotalMWs,
}, },
}; };
} }
@ -134,7 +145,7 @@ export class SyncPrivateLocationMonitorsTask {
id: TASK_ID, id: TASK_ID,
state: {}, state: {},
schedule: { schedule: {
interval: '10m', interval: TASK_SCHEDULE,
}, },
taskType: TASK_TYPE, taskType: TASK_TYPE,
params: {}, params: {},
@ -142,6 +153,32 @@ export class SyncPrivateLocationMonitorsTask {
logger.debug(`Sync private location monitors task scheduled successfully`); 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({ async syncGlobalParams({
allPrivateLocations, allPrivateLocations,
encryptedSavedObjects, encryptedSavedObjects,
@ -155,10 +192,11 @@ export class SyncPrivateLocationMonitorsTask {
const privateConfigs: Array<{ config: HeartbeatConfig; globalParams: Record<string, string> }> = const privateConfigs: Array<{ config: HeartbeatConfig; globalParams: Record<string, string> }> =
[]; [];
const { configsBySpaces, paramsBySpace, spaceIds } = await this.getAllMonitorConfigs({ const { configsBySpaces, paramsBySpace, spaceIds, maintenanceWindows } =
encryptedSavedObjects, await this.getAllMonitorConfigs({
soClient, encryptedSavedObjects,
}); soClient,
});
for (const spaceId of spaceIds) { for (const spaceId of spaceIds) {
const monitors = configsBySpaces[spaceId]; const monitors = configsBySpaces[spaceId];
@ -173,7 +211,12 @@ export class SyncPrivateLocationMonitorsTask {
} }
} }
if (privateConfigs.length > 0) { 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 { syntheticsService } = this.syntheticsMonitorClient;
const paramsBySpacePromise = syntheticsService.getSyntheticsParams({ spaceId: ALL_SPACES_ID }); const paramsBySpacePromise = syntheticsService.getSyntheticsParams({ spaceId: ALL_SPACES_ID });
const maintenanceWindowsPromise = syntheticsService.getMaintenanceWindows();
const monitorConfigRepository = new MonitorConfigRepository( const monitorConfigRepository = new MonitorConfigRepository(
soClient, soClient,
encryptedSavedObjects.getClient() encryptedSavedObjects.getClient()
@ -196,11 +240,16 @@ export class SyncPrivateLocationMonitorsTask {
spaceId: ALL_SPACES_ID, spaceId: ALL_SPACES_ID,
}); });
const [paramsBySpace, monitors] = await Promise.all([paramsBySpacePromise, monitorsPromise]); const [paramsBySpace, monitors, maintenanceWindows] = await Promise.all([
paramsBySpacePromise,
monitorsPromise,
maintenanceWindowsPromise,
]);
return { return {
...this.mixParamsWithMonitors(monitors, paramsBySpace), ...this.mixParamsWithMonitors(monitors, paramsBySpace),
paramsBySpace, paramsBySpace,
maintenanceWindows,
}; };
} }
@ -251,7 +300,15 @@ export class SyncPrivateLocationMonitorsTask {
return { configsBySpaces, spaceIds }; 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 { logger } = this.serverSetup;
const [editedParams, totalParams] = await Promise.all([ const [editedParams, totalParams] = await Promise.all([
soClient.find({ soClient.find({
@ -268,10 +325,59 @@ export class SyncPrivateLocationMonitorsTask {
fields: [], 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 { return {
hasParamsChanges,
updatedParams: editedParams.total, 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/charts-plugin",
"@kbn/response-ops-rule-params", "@kbn/response-ops-rule-params",
"@kbn/response-ops-rule-form", "@kbn/response-ops-rule-form",
"@kbn/fields-metadata-plugin" "@kbn/fields-metadata-plugin",
"@kbn/alerts-ui-shared",
"@kbn/core-lifecycle-server"
], ],
"exclude": ["target/**/*"] "exclude": ["target/**/*"]
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,6 +16,7 @@ export const commonVars = {
}, },
maintenance_windows: { maintenance_windows: {
type: 'yaml', 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`, 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 }, ipv4: { type: 'bool', value: true },
ipv6: { type: 'bool', value: true }, ipv6: { type: 'bool', value: true },
mode: { type: 'text' }, mode: { type: 'text' },
maintenance_windows: {
type: 'yaml',
},
}, },
id: `synthetics/http-http-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`, id: `synthetics/http-http-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`,
}, },