diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 4a1c4aee7947..93c4f5f98801 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -56759,6 +56759,69 @@ paths: summary: Get a private location tags: - synthetics + put: + description: | + Update an existing private location's label. + You must have `all` privileges for the Synthetics and Uptime feature in the Observability section of the Kibana feature privileges. + When a private location's label is updated, all monitors using this location will also be updated to maintain data consistency. + operationId: put-private-location + parameters: + - description: The unique identifier of the private location to be updated. + in: path + name: id + required: true + schema: + type: string + requestBody: + content: + application/json: + examples: + putPrivateLocationRequestExample1: + description: Update a private location's label. + value: |- + { + "label": "Updated Private Location Name" + } + schema: + type: object + properties: + label: + description: A new label for the private location. Must be at least 1 character long. + minLength: 1 + type: string + required: + - label + required: true + responses: + '200': + content: + application/json: + examples: + putPrivateLocationResponseExample1: + value: |- + { + "label": "Updated Private Location Name", + "id": "test-private-location-id", + "agentPolicyId": "test-private-location-id", + "isServiceManaged": false, + "isInvalid": false, + "tags": ["private", "testing", "updated"], + "geo": { + "lat": 37.7749, + "lon": -122.4194 + }, + "spaces": ["*"] + } + schema: + $ref: '#/components/schemas/Synthetics_getPrivateLocation' + description: A successful response. + '400': + description: If the `label` is shorter than 1 character the API will return a 400 Bad Request response with a corresponding error message. + '404': + description: If the private location with the specified ID does not exist, the API will return a 404 Not Found response. + summary: Update a private location + tags: + - synthetics /api/task_manager/_health: get: description: | diff --git a/x-pack/solutions/observability/plugins/synthetics/common/constants/ui.ts b/x-pack/solutions/observability/plugins/synthetics/common/constants/ui.ts index 6f3fd250dcfe..ed7c652923db 100644 --- a/x-pack/solutions/observability/plugins/synthetics/common/constants/ui.ts +++ b/x-pack/solutions/observability/plugins/synthetics/common/constants/ui.ts @@ -28,7 +28,7 @@ export const GETTING_STARTED_ROUTE = '/monitors/getting-started'; export const SETTINGS_ROUTE = '/settings'; -export const PRIVATE_LOCATIOSN_ROUTE = '/settings/private-locations'; +export const PRIVATE_LOCATIONS_ROUTE = '/settings/private-locations'; export const SYNTHETICS_SETTINGS_ROUTE = '/settings/:tabId'; diff --git a/x-pack/solutions/observability/plugins/synthetics/docs/openapi/synthetic_apis.yaml b/x-pack/solutions/observability/plugins/synthetics/docs/openapi/synthetic_apis.yaml index d639f4cbc5f1..04f7d9a162bc 100644 --- a/x-pack/solutions/observability/plugins/synthetics/docs/openapi/synthetic_apis.yaml +++ b/x-pack/solutions/observability/plugins/synthetics/docs/openapi/synthetic_apis.yaml @@ -1024,6 +1024,71 @@ paths: }, "namespace": "default" } + put: + summary: Update a private location + operationId: put-private-location + description: > + Update an existing private location's label. + + You must have `all` privileges for the Synthetics and Uptime feature in the Observability section of the Kibana feature privileges. + + When a private location's label is updated, all monitors using this location will also be updated to maintain data consistency. + tags: + - synthetics + parameters: + - in: path + name: id + description: The unique identifier of the private location to be updated. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - label + properties: + label: + type: string + minLength: 1 + description: A new label for the private location. Must be at least 1 character long. + examples: + putPrivateLocationRequestExample1: + description: Update a private location's label. + value: |- + { + "label": "Updated Private Location Name" + } + responses: + '200': + description: A successful response. + content: + application/json: + schema: + $ref: "#/components/schemas/getPrivateLocation" + examples: + putPrivateLocationResponseExample1: + value: |- + { + "label": "Updated Private Location Name", + "id": "test-private-location-id", + "agentPolicyId": "test-private-location-id", + "isServiceManaged": false, + "isInvalid": false, + "tags": ["private", "testing", "updated"], + "geo": { + "lat": 37.7749, + "lon": -122.4194 + }, + "spaces": ["*"] + } + '400': + description: If the `label` is shorter than 1 character the API will return a 400 Bad Request response with a corresponding error message. + '404': + description: If the private location with the specified ID does not exist, the API will return a 404 Not Found response. components: schemas: commonMonitorFields: diff --git a/x-pack/solutions/observability/plugins/synthetics/e2e/synthetics/journeys/private_locations.journey.ts b/x-pack/solutions/observability/plugins/synthetics/e2e/synthetics/journeys/private_locations.journey.ts index cdc596199157..d1ea98301f11 100644 --- a/x-pack/solutions/observability/plugins/synthetics/e2e/synthetics/journeys/private_locations.journey.ts +++ b/x-pack/solutions/observability/plugins/synthetics/e2e/synthetics/journeys/private_locations.journey.ts @@ -15,6 +15,7 @@ import { syntheticsAppPageProvider } from '../page_objects/synthetics_app'; journey(`PrivateLocationsSettings`, async ({ page, params }) => { const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl, params }); const services = new SyntheticsServices(params); + const NEW_LOCATION_LABEL = 'Updated Test Location'; page.setDefaultTimeout(2 * 30000); @@ -87,11 +88,33 @@ journey(`PrivateLocationsSettings`, async ({ page, params }) => { }); }); + step('Edit private location label and verify disabled fields', async () => { + // Click on the edit button for the location + await page.click('[data-test-subj="action-edit"]'); + + // Verify that agent policy selector is disabled + expect(await page.locator('[aria-label="Select agent policy"]').isDisabled()).toBe(true); + + // Verify that tags field is disabled + expect(await page.locator('[aria-label="Tags"]').isDisabled()).toBe(false); + + // Verify that spaces selector is disabled + expect(await page.locator('[aria-label="Spaces "]').isDisabled()).toBe(true); + + await page.fill('[aria-label="Location name"]', NEW_LOCATION_LABEL); + + // Save the changes + await page.click('[data-test-subj="syntheticsLocationFlyoutSaveButton"]'); + + // Wait for the save to complete and verify the updated label appears in the table + await page.waitForSelector(`td:has-text("${NEW_LOCATION_LABEL}")`); + }); + step('Integration cannot be edited in Fleet', async () => { await page.goto(`${params.kibanaUrl}/app/integrations/detail/synthetics/policies`); await page.waitForSelector('h1:has-text("Elastic Synthetics")'); - await page.click('text="test-monitor-Test private-default"'); + await page.click(`text="test-monitor-${NEW_LOCATION_LABEL}-default"`); await page.waitForSelector('h1:has-text("Edit Elastic Synthetics integration")'); await page.waitForSelector('text="This package policy is managed by the Synthetics app."'); }); @@ -111,14 +134,14 @@ journey(`PrivateLocationsSettings`, async ({ page, params }) => { await page.click('h1:has-text("Settings")'); await page.click('text=Private Locations'); await page.waitForSelector('td:has-text("1")'); - await page.waitForSelector('td:has-text("Test private")'); + await page.waitForSelector(`td:has-text("${NEW_LOCATION_LABEL}")`); await page.click('.euiTableRowCell .euiToolTipAnchor'); await page.click('button:has-text("Tags")'); await page.click('[aria-label="Tags"] >> text=Area51'); await page.click( 'main div:has-text("Private locations allow you to run monitors from your own premises. They require")' ); - await page.click('text=Test private'); + await page.click(`text=${NEW_LOCATION_LABEL}`); await page.click('.euiTableRowCell .euiToolTipAnchor'); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/getting_started/getting_started_page.test.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/getting_started/getting_started_page.test.tsx index 1a35504699cb..cc4e845726e4 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/getting_started/getting_started_page.test.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/getting_started/getting_started_page.test.tsx @@ -21,8 +21,9 @@ describe('GettingStartedPage', () => { loading: false, privateLocations: [], deleteLoading: false, - onSubmit: jest.fn(), - onDelete: jest.fn(), + onCreateLocationAPI: jest.fn(), + onDeleteLocationAPI: jest.fn(), + onEditLocationAPI: jest.fn(), createLoading: false, }); jest.spyOn(permissionsHooks, 'useCanManagePrivateLocation').mockReturnValue(true); @@ -82,7 +83,7 @@ describe('GettingStartedPage', () => { loading: false, }, privateLocations: { - isCreatePrivateLocationFlyoutVisible: true, + isPrivateLocationFlyoutVisible: true, }, agentPolicies: { data: [], @@ -112,7 +113,7 @@ describe('GettingStartedPage', () => { data: [{}], }, privateLocations: { - isCreatePrivateLocationFlyoutVisible: true, + isPrivateLocationFlyoutVisible: true, }, }, }); @@ -151,7 +152,7 @@ describe('GettingStartedPage', () => { data: [{}], }, privateLocations: { - isCreatePrivateLocationFlyoutVisible: true, + isPrivateLocationFlyoutVisible: true, }, }, } diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/getting_started/getting_started_page.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/getting_started/getting_started_page.tsx index c102536e0454..53563eb1fb30 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/getting_started/getting_started_page.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/getting_started/getting_started_page.tsx @@ -27,11 +27,14 @@ import { LoadingState } from '../monitors_page/overview/overview/monitor_detail_ import { getServiceLocations, cleanMonitorListState } from '../../state'; import { MONITOR_ADD_ROUTE } from '../../../../../common/constants/ui'; import { SimpleMonitorForm } from './simple_monitor_form'; -import { AddLocationFlyout, NewLocation } from '../settings/private_locations/add_location_flyout'; +import { + AddOrEditLocationFlyout, + NewLocation, +} from '../settings/private_locations/add_or_edit_location_flyout'; import type { ClientPluginsStart } from '../../../../plugin'; import { getAgentPoliciesAction, selectAgentPolicies } from '../../state/agent_policies'; -import { selectAddingNewPrivateLocation } from '../../state/settings/selectors'; -import { setIsCreatePrivateLocationFlyoutVisible } from '../../state/private_locations/actions'; +import { setIsPrivateLocationFlyoutVisible } from '../../state/private_locations/actions'; +import { selectPrivateLocationFlyoutVisible } from '../../state/private_locations/selectors'; export const GettingStartedPage = () => { const dispatch = useDispatch(); @@ -127,23 +130,23 @@ export const GettingStartedOnPrem = () => { useBreadcrumbs([{ text: MONITORING_OVERVIEW_LABEL }]); // No extra breadcrumbs on overview - const isAddingNewLocation = useSelector(selectAddingNewPrivateLocation); + const isPrivateLocationFlyoutVisible = useSelector(selectPrivateLocationFlyoutVisible); - const setIsAddingNewLocation = useCallback( - (val: boolean) => dispatch(setIsCreatePrivateLocationFlyoutVisible(val)), + const setIsFlyoutOpen = useCallback( + (val: boolean) => dispatch(setIsPrivateLocationFlyoutVisible(val)), [dispatch] ); - const { onSubmit, privateLocations } = usePrivateLocationsAPI(); + const { onCreateLocationAPI, privateLocations } = usePrivateLocationsAPI(); const handleSubmit = (formData: NewLocation) => { - onSubmit(formData); + onCreateLocationAPI(formData); }; // make sure flyout is closed when first visiting the page useEffect(() => { - setIsAddingNewLocation(false); - }, [setIsAddingNewLocation]); + setIsFlyoutOpen(false); + }, [setIsFlyoutOpen]); return ( <> @@ -163,7 +166,7 @@ export const GettingStartedOnPrem = () => { fill iconType="plusInCircleFilled" data-test-subj="gettingStartedAddLocationButton" - onClick={() => setIsAddingNewLocation(true)} + onClick={() => setIsFlyoutOpen(true)} > {CREATE_LOCATION_LABEL} @@ -173,9 +176,9 @@ export const GettingStartedOnPrem = () => { footer={} /> - {isAddingNewLocation ? ( - setIsFlyoutOpen(false)} onSubmit={handleSubmit} privateLocations={privateLocations} /> diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/components/spaces_select.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/components/spaces_select.tsx index 51fecfef7f94..54dbc743dab0 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/components/spaces_select.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/components/spaces_select.tsx @@ -16,9 +16,13 @@ import { ClientPluginsStart } from '../../../../../plugin'; interface SpaceSelectorProps { helpText: string; + isDisabled?: boolean; } -export const SpaceSelector = ({ helpText }: SpaceSelectorProps) => { +export const SpaceSelector = ({ + helpText, + isDisabled = false, +}: SpaceSelectorProps) => { const NAMESPACES_NAME = 'spaces' as Path; const { services } = useKibana(); const [spacesList, setSpacesList] = React.useState>([]); @@ -61,6 +65,7 @@ export const SpaceSelector = ({ helpText }: SpaceSelector rules={{ required: true }} render={({ field }) => ( ; + isDisabled?: boolean; }) { return ( @@ -27,6 +29,7 @@ export function TagsField({ control={control} render={({ field }) => ( ; const getEmptyFunctionComponent: React.FC = ({ children }) => <>{children}; -export const AddLocationFlyout = ({ +export const AddOrEditLocationFlyout = ({ onSubmit, - setIsOpen, + onCloseFlyout, privateLocations, + privateLocationToEdit, }: { onSubmit: (val: NewLocation) => void; - setIsOpen: (val: boolean) => void; + onCloseFlyout: () => void; privateLocations: PrivateLocation[]; + privateLocationToEdit?: PrivateLocation; }) => { const form = useFormWrapped({ mode: 'onSubmit', reValidateMode: 'onChange', shouldFocusError: true, - defaultValues: { + defaultValues: privateLocationToEdit || { label: '', agentPolicyId: '', geo: { @@ -61,7 +63,7 @@ export const AddLocationFlyout = ({ const { canSave, canManagePrivateLocations } = useSyntheticsSettingsContext(); - const { createLoading } = useSelector(selectPrivateLocationsState); + const { createLoading, editLoading } = useSelector(selectPrivateLocationsState); const { spaces: spacesApi } = useKibana().services; @@ -72,33 +74,35 @@ export const AddLocationFlyout = ({ ); const { handleSubmit } = form; - const closeFlyout = () => { - setIsOpen(false); - }; return ( - + -

{ADD_PRIVATE_LOCATION}

+

+ {privateLocationToEdit !== undefined ? EDIT_PRIVATE_LOCATION : ADD_PRIVATE_LOCATION} +

- + {CANCEL_LABEL} @@ -106,10 +110,10 @@ export const AddLocationFlyout = ({ {SAVE_LABEL} @@ -131,6 +135,13 @@ const ADD_PRIVATE_LOCATION = i18n.translate( } ); +const EDIT_PRIVATE_LOCATION = i18n.translate( + 'xpack.synthetics.monitorManagement.editPrivateLocations', + { + defaultMessage: 'Edit private location', + } +); + const CANCEL_LABEL = i18n.translate('xpack.synthetics.monitorManagement.cancelLabel', { defaultMessage: 'Cancel', }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/empty_locations.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/empty_locations.tsx index 9d871c7507ad..ab16bc925627 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/empty_locations.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/empty_locations.tsx @@ -12,19 +12,19 @@ import { i18n } from '@kbn/i18n'; import { useDispatch } from 'react-redux'; import { NoPermissionsTooltip } from '../../common/components/permissions'; import { useSyntheticsSettingsContext } from '../../../contexts'; -import { PRIVATE_LOCATIOSN_ROUTE } from '../../../../../../common/constants'; +import { PRIVATE_LOCATIONS_ROUTE } from '../../../../../../common/constants'; import { - setIsCreatePrivateLocationFlyoutVisible, + setIsPrivateLocationFlyoutVisible, setManageFlyoutOpen, } from '../../../state/private_locations/actions'; export const EmptyLocations = ({ inFlyout = true, - setIsAddingNew, + setIsFlyoutOpen, redirectToSettings, }: { inFlyout?: boolean; - setIsAddingNew?: (val: boolean) => void; + setIsFlyoutOpen?: (val: boolean) => void; redirectToSettings?: boolean; }) => { const dispatch = useDispatch(); @@ -52,7 +52,7 @@ export const EmptyLocations = ({ fill isDisabled={!canSave} href={history.createHref({ - pathname: PRIVATE_LOCATIOSN_ROUTE, + pathname: PRIVATE_LOCATIONS_ROUTE, })} > {ADD_LOCATION} @@ -65,9 +65,9 @@ export const EmptyLocations = ({ color="primary" fill onClick={() => { - setIsAddingNew?.(true); + setIsFlyoutOpen?.(true); dispatch(setManageFlyoutOpen(true)); - dispatch(setIsCreatePrivateLocationFlyoutVisible(true)); + dispatch(setIsPrivateLocationFlyoutVisible(true)); }} > {ADD_LOCATION} diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/hooks/use_locations_api.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/hooks/use_locations_api.ts index 162f8ff59f87..e2d688d87637 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/hooks/use_locations_api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/hooks/use_locations_api.ts @@ -7,10 +7,12 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { NewLocation } from '../add_location_flyout'; +import type { EditPrivateLocationAttributes } from '../../../../../../../server/routes/settings/private_locations/edit_private_location'; +import { NewLocation } from '../add_or_edit_location_flyout'; import { createPrivateLocationAction, deletePrivateLocationAction, + editPrivateLocationAction, getPrivateLocationsAction, } from '../../../../state/private_locations/actions'; import { selectPrivateLocationsState } from '../../../../state/private_locations/selectors'; @@ -30,17 +32,22 @@ export const usePrivateLocationsAPI = () => { } }, [data, dispatch]); - const onSubmit = (newLoc: NewLocation) => { + const onCreateLocationAPI = (newLoc: NewLocation) => { dispatch(createPrivateLocationAction.get(newLoc)); }; - const onDelete = (id: string) => { + const onEditLocationAPI = (locationId: string, newAttributes: EditPrivateLocationAttributes) => { + dispatch(editPrivateLocationAction.get({ locationId, newAttributes })); + }; + + const onDeleteLocationAPI = (id: string) => { dispatch(deletePrivateLocationAction.get(id)); }; return { - onSubmit, - onDelete, + onCreateLocationAPI, + onEditLocationAPI, + onDeleteLocationAPI, deleteLoading, loading, createLoading, diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/location_form.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/location_form.tsx index 48b4b1e6dbad..052b69017ac4 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/location_form.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/location_form.tsx @@ -18,7 +18,13 @@ import { PrivateLocation } from '../../../../../../common/runtime_types'; import { AgentPolicyNeeded } from './agent_policy_needed'; import { PolicyHostsField } from './policy_hosts'; -export const LocationForm = ({ privateLocations }: { privateLocations: PrivateLocation[] }) => { +export const LocationForm = ({ + privateLocations, + privateLocationToEdit, +}: { + privateLocations: PrivateLocation[]; + privateLocationToEdit?: PrivateLocation; +}) => { const { data } = useSelector(selectAgentPolicies); const { control, register } = useFormContext(); const { errors } = useFormState(); @@ -28,6 +34,8 @@ export const LocationForm = ({ privateLocations }: { privateLocations: PrivateLo return [...acc, ...tags]; }, [] as string[]); + const isEditingLocation = privateLocationToEdit !== undefined; + return ( <> {data?.length === 0 && } @@ -48,7 +56,8 @@ export const LocationForm = ({ privateLocations }: { privateLocations: PrivateLo message: NAME_REQUIRED, }, validate: (val: string) => { - return privateLocations.some((loc) => loc.label === val) + return privateLocations.some((loc) => loc.label === val) && + val !== privateLocationToEdit?.label ? NAME_ALREADY_EXISTS : undefined; }, @@ -56,13 +65,13 @@ export const LocationForm = ({ privateLocations }: { privateLocations: PrivateLo />
- + - + ); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/locations_table.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/locations_table.tsx index af51265feee2..684bbbcb8746 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/locations_table.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/locations_table.tsx @@ -8,6 +8,7 @@ import React, { useState } from 'react'; import { EuiBadge, + EuiBasicTableColumn, EuiButton, EuiFlexGroup, EuiFlexItem, @@ -31,7 +32,7 @@ import { DeleteLocation } from './delete_location'; import { useLocationMonitors } from './hooks/use_location_monitors'; import { PolicyName } from './policy_name'; import { LOCATION_NAME_LABEL } from './location_form'; -import { setIsCreatePrivateLocationFlyoutVisible } from '../../../state/private_locations/actions'; +import { setIsPrivateLocationFlyoutVisible } from '../../../state/private_locations/actions'; import { ClientPluginsStart } from '../../../../../plugin'; interface ListItem extends PrivateLocation { @@ -41,10 +42,12 @@ interface ListItem extends PrivateLocation { export const PrivateLocationsTable = ({ deleteLoading, onDelete, + onEdit, privateLocations, }: { deleteLoading?: boolean; onDelete: (id: string) => void; + onEdit: (privateLocation: PrivateLocation) => void; privateLocations: PrivateLocation[]; }) => { const dispatch = useDispatch(); @@ -65,7 +68,7 @@ export const PrivateLocationsTable = ({ return new Set([...acc, ...tags]); }, new Set()); - const columns = [ + const columns: Array> = [ { field: 'label', name: LOCATION_NAME_LABEL, @@ -114,6 +117,15 @@ export const PrivateLocationsTable = ({ { name: ACTIONS_LABEL, actions: [ + { + name: EDIT_LOCATION, + description: EDIT_LOCATION, + isPrimary: true, + 'data-test-subj': 'action-edit', + onClick: onEdit, + icon: 'pencil', + type: 'icon', + }, { name: DELETE_LOCATION, description: DELETE_LOCATION, @@ -138,7 +150,7 @@ export const PrivateLocationsTable = ({ monitors: locationMonitors?.find((l) => l.id === location.id)?.count ?? 0, })); - const setIsAddingNew = (val: boolean) => dispatch(setIsCreatePrivateLocationFlyoutVisible(val)); + const openFlyout = () => dispatch(setIsPrivateLocationFlyoutVisible(true)); const renderToolRight = () => { return [ @@ -152,7 +164,7 @@ export const PrivateLocationsTable = ({ data-test-subj={'addPrivateLocationButton'} isLoading={loading} disabled={!canSave || !canManagePrivateLocations} - onClick={() => setIsAddingNew(true)} + onClick={openFlyout} iconType="plusInCircle" > {ADD_LABEL} @@ -236,6 +248,10 @@ const DELETE_LOCATION = i18n.translate( } ); +const EDIT_LOCATION = i18n.translate('xpack.synthetics.settingsRoute.privateLocations.editLabel', { + defaultMessage: 'Edit private location', +}); + const ADD_LABEL = i18n.translate('xpack.synthetics.monitorManagement.createLocation', { defaultMessage: 'Create location', }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_empty_state.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_empty_state.tsx index dd0b5d8b9993..49abc93ce233 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_empty_state.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_empty_state.tsx @@ -15,14 +15,14 @@ import { selectAgentPolicies } from '../../../state/agent_policies'; export const ManageEmptyState: FC< PropsWithChildren<{ privateLocations: PrivateLocation[]; - setIsAddingNew?: (val: boolean) => void; + setIsFlyoutOpen?: (val: boolean) => void; showNeedAgentPolicy?: boolean; showEmptyLocations?: boolean; }> > = ({ children, privateLocations, - setIsAddingNew, + setIsFlyoutOpen, showNeedAgentPolicy = true, showEmptyLocations = true, }) => { @@ -33,7 +33,7 @@ export const ManageEmptyState: FC< } if (privateLocations.length === 0 && showEmptyLocations) { - return ; + return ; } return <>{children}; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_private_locations.test.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_private_locations.test.tsx index a6d6ccfb1af7..271af590dcf7 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_private_locations.test.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_private_locations.test.tsx @@ -28,9 +28,10 @@ describe('', () => { }); jest.spyOn(locationHooks, 'usePrivateLocationsAPI').mockReturnValue({ loading: false, - onSubmit: jest.fn(), + onCreateLocationAPI: jest.fn(), + onEditLocationAPI: jest.fn(), privateLocations: [], - onDelete: jest.fn(), + onDeleteLocationAPI: jest.fn(), deleteLoading: false, createLoading: false, }); @@ -60,7 +61,7 @@ describe('', () => { error: null, }, privateLocations: { - isCreatePrivateLocationFlyoutVisible: false, + isPrivateLocationFlyoutVisible: false, }, }, }); @@ -95,7 +96,7 @@ describe('', () => { error: null, }, privateLocations: { - isCreatePrivateLocationFlyoutVisible: false, + isPrivateLocationFlyoutVisible: false, }, }, }); @@ -125,7 +126,8 @@ describe('', () => { jest.spyOn(locationHooks, 'usePrivateLocationsAPI').mockReturnValue({ loading: false, - onSubmit: jest.fn(), + onCreateLocationAPI: jest.fn(), + onEditLocationAPI: jest.fn(), privateLocations: [ { label: privateLocationName, @@ -134,7 +136,7 @@ describe('', () => { isServiceManaged: false, }, ], - onDelete: jest.fn(), + onDeleteLocationAPI: jest.fn(), deleteLoading: false, createLoading: false, }); @@ -146,7 +148,7 @@ describe('', () => { error: null, }, privateLocations: { - isCreatePrivateLocationFlyoutVisible: false, + isPrivateLocationFlyoutVisible: false, }, }, }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_private_locations.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_private_locations.tsx index 4478c973f7ea..550b6ee52a5c 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_private_locations.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/manage_private_locations.tsx @@ -8,15 +8,23 @@ import React, { useEffect, useCallback, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { SpacesContextProps } from '@kbn/spaces-plugin/public'; +import { isEqual } from 'lodash'; +import { PrivateLocation } from '../../../../../../common/runtime_types'; import { LoadingState } from '../../monitors_page/overview/overview/monitor_detail_flyout'; import { PrivateLocationsTable } from './locations_table'; import { ManageEmptyState } from './manage_empty_state'; -import { AddLocationFlyout, NewLocation } from './add_location_flyout'; +import { AddOrEditLocationFlyout, NewLocation } from './add_or_edit_location_flyout'; import { usePrivateLocationsAPI } from './hooks/use_locations_api'; -import { selectAddingNewPrivateLocation } from '../../../state/private_locations/selectors'; +import { + selectPrivateLocationFlyoutVisible, + selectPrivateLocationToEdit, +} from '../../../state/private_locations/selectors'; import { getServiceLocations } from '../../../state'; import { getAgentPoliciesAction } from '../../../state/agent_policies'; -import { setIsCreatePrivateLocationFlyoutVisible } from '../../../state/private_locations/actions'; +import { + setIsPrivateLocationFlyoutVisible as setIsPrivateLocationFlyoutVisible, + setPrivateLocationToEdit, +} from '../../../state/private_locations/actions'; import { ClientPluginsStart } from '../../../../../plugin'; const getEmptyFunctionComponent: React.FC = ({ children }) => <>{children}; @@ -33,23 +41,53 @@ export const ManagePrivateLocations = () => { [spacesApi] ); - const isAddingNew = useSelector(selectAddingNewPrivateLocation); - const setIsAddingNew = useCallback( - (val: boolean) => dispatch(setIsCreatePrivateLocationFlyoutVisible(val)), + const isPrivateLocationFlyoutVisible = useSelector(selectPrivateLocationFlyoutVisible); + const privateLocationToEdit = useSelector(selectPrivateLocationToEdit); + const setIsFlyoutOpen = useCallback( + (val: boolean) => dispatch(setIsPrivateLocationFlyoutVisible(val)), [dispatch] ); - const { onSubmit, loading, privateLocations, onDelete, deleteLoading } = usePrivateLocationsAPI(); + const { + onCreateLocationAPI, + onEditLocationAPI, + loading, + privateLocations, + onDeleteLocationAPI, + deleteLoading, + } = usePrivateLocationsAPI(); useEffect(() => { dispatch(getAgentPoliciesAction.get()); dispatch(getServiceLocations()); // make sure flyout is closed when first visiting the page - dispatch(setIsCreatePrivateLocationFlyoutVisible(false)); + dispatch(setIsPrivateLocationFlyoutVisible(false)); }, [dispatch]); const handleSubmit = (formData: NewLocation) => { - onSubmit(formData); + if (privateLocationToEdit) { + const isLabelChanged = formData.label !== privateLocationToEdit.label; + const areTagsChanged = !isEqual(formData.tags, privateLocationToEdit.tags); + if (!isLabelChanged && !areTagsChanged) { + onCloseFlyout(); + } else { + onEditLocationAPI(privateLocationToEdit.id, { label: formData.label, tags: formData.tags }); + } + } else { + onCreateLocationAPI(formData); + } + }; + + const onEditLocation = (privateLocation: PrivateLocation) => { + dispatch(setPrivateLocationToEdit(privateLocation)); + setIsFlyoutOpen(true); + }; + + const onCloseFlyout = () => { + if (privateLocationToEdit) { + dispatch(setPrivateLocationToEdit(undefined)); + } + setIsFlyoutOpen(false); }; return ( @@ -57,20 +95,22 @@ export const ManagePrivateLocations = () => { {loading ? ( ) : ( - + )} - {isAddingNew ? ( - ) : null} diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/policy_hosts.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/policy_hosts.tsx index 42f6a463e228..bb520225611b 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/policy_hosts.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/policy_hosts.tsx @@ -29,7 +29,13 @@ import { selectAgentPolicies } from '../../../state/agent_policies'; export const AGENT_POLICY_FIELD_NAME = 'agentPolicyId'; -export const PolicyHostsField = ({ privateLocations }: { privateLocations: PrivateLocation[] }) => { +export const PolicyHostsField = ({ + privateLocations, + isDisabled, +}: { + privateLocations: PrivateLocation[]; + isDisabled?: boolean; +}) => { const { data } = useSelector(selectAgentPolicies); const { basePath } = useSyntheticsSettingsContext(); @@ -53,7 +59,7 @@ export const PolicyHostsField = ({ privateLocations }: { privateLocations: Priva inputDisplay: ( {item.name} @@ -74,7 +80,7 @@ export const PolicyHostsField = ({ privateLocations }: { privateLocations: Priva <> {item.name} @@ -124,6 +130,7 @@ export const PolicyHostsField = ({ privateLocations }: { privateLocations: Priva rules={{ required: true }} render={({ field }) => ( ( '[PRIVATE LOCATIONS] GET' @@ -18,12 +19,24 @@ export const createPrivateLocationAction = createAsyncAction('EDIT PRIVATE LOCATION'); + export const deletePrivateLocationAction = createAsyncAction( 'DELETE PRIVATE LOCATION' ); export const setManageFlyoutOpen = createAction('SET MANAGE FLYOUT OPEN'); -export const setIsCreatePrivateLocationFlyoutVisible = createAction( - 'SET IS CREATE PRIVATE LOCATION FLYOUT VISIBLE' +export const setIsPrivateLocationFlyoutVisible = createAction( + 'SET IS CREATE OR EDIT PRIVATE LOCATION FLYOUT VISIBLE' +); + +export const setPrivateLocationToEdit = createAction( + 'SET PRIVATE LOCATION TO EDIT' ); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/api.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/api.ts index afa722302c89..6acb4b2e07e5 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/api.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { NewLocation } from '../../components/settings/private_locations/add_location_flyout'; +import type { EditPrivateLocationAttributes } from '../../../../../server/routes/settings/private_locations/edit_private_location'; +import { NewLocation } from '../../components/settings/private_locations/add_or_edit_location_flyout'; import { AgentPolicyInfo } from '../../../../../common/types'; import { INITIAL_REST_VERSION, SYNTHETICS_API_URLS } from '../../../../../common/constants'; import { PrivateLocation, SyntheticsPrivateLocations } from '../../../../../common/runtime_types'; @@ -23,6 +24,23 @@ export const createSyntheticsPrivateLocation = async ( }); }; +export const editSyntheticsPrivateLocation = async ({ + locationId, + newAttributes, +}: { + locationId: string; + newAttributes: EditPrivateLocationAttributes; +}): Promise => { + return apiService.put( + `${SYNTHETICS_API_URLS.PRIVATE_LOCATIONS}/${locationId}`, + newAttributes, + undefined, + { + version: INITIAL_REST_VERSION, + } + ); +}; + export const getSyntheticsPrivateLocations = async (): Promise => { return await apiService.get(SYNTHETICS_API_URLS.PRIVATE_LOCATIONS, { version: INITIAL_REST_VERSION, diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/effects.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/effects.ts index 0642e1d697ca..2ba40ff664c0 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/effects.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/effects.ts @@ -11,11 +11,13 @@ import { fetchEffectFactory } from '../utils/fetch_effect'; import { createSyntheticsPrivateLocation, deleteSyntheticsPrivateLocation, + editSyntheticsPrivateLocation, getSyntheticsPrivateLocations, } from './api'; import { createPrivateLocationAction, deletePrivateLocationAction, + editPrivateLocationAction, getPrivateLocationsAction, } from './actions'; @@ -47,6 +49,23 @@ export function* createPrivateLocationEffect() { ); } +export function* editPrivateLocationEffect() { + yield takeLeading( + editPrivateLocationAction.get, + fetchEffectFactory( + editSyntheticsPrivateLocation, + editPrivateLocationAction.success, + editPrivateLocationAction.fail, + i18n.translate('xpack.synthetics.editPrivateLocationSuccess', { + defaultMessage: 'Successfully edited private location.', + }), + i18n.translate('xpack.synthetics.editPrivateLocationFailure', { + defaultMessage: 'Failed to edit private location.', + }) + ) + ); +} + export function* deletePrivateLocationEffect() { yield takeLeading( deletePrivateLocationAction.get, @@ -61,5 +80,6 @@ export function* deletePrivateLocationEffect() { export const privateLocationsEffects = [ fetchPrivateLocationsEffect, createPrivateLocationEffect, + editPrivateLocationEffect, deletePrivateLocationEffect, ]; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/index.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/index.ts index 45c9ba4ece57..ef1a3284b36e 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/index.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/index.ts @@ -7,19 +7,26 @@ import { createReducer } from '@reduxjs/toolkit'; import { PrivateLocation, SyntheticsPrivateLocations } from '../../../../../common/runtime_types'; -import { createPrivateLocationAction, deletePrivateLocationAction } from './actions'; -import { setIsCreatePrivateLocationFlyoutVisible, getPrivateLocationsAction } from './actions'; +import { + createPrivateLocationAction, + deletePrivateLocationAction, + editPrivateLocationAction, + setPrivateLocationToEdit, +} from './actions'; +import { setIsPrivateLocationFlyoutVisible, getPrivateLocationsAction } from './actions'; import { IHttpSerializedFetchError } from '../utils/http_error'; export interface PrivateLocationsState { data?: SyntheticsPrivateLocations | null; loading: boolean; createLoading?: boolean; + editLoading?: boolean; deleteLoading?: boolean; error: IHttpSerializedFetchError | null; isManageFlyoutOpen?: boolean; - isCreatePrivateLocationFlyoutVisible?: boolean; + isPrivateLocationFlyoutVisible?: boolean; newLocation?: PrivateLocation; + privateLocationToEdit?: PrivateLocation; } const initialState: PrivateLocationsState = { @@ -27,8 +34,10 @@ const initialState: PrivateLocationsState = { loading: false, error: null, isManageFlyoutOpen: false, - isCreatePrivateLocationFlyoutVisible: false, + isPrivateLocationFlyoutVisible: false, createLoading: false, + editLoading: false, + privateLocationToEdit: undefined, }; export const privateLocationsStateReducer = createReducer(initialState, (builder) => { @@ -51,12 +60,26 @@ export const privateLocationsStateReducer = createReducer(initialState, (builder state.newLocation = action.payload; state.createLoading = false; state.data = null; - state.isCreatePrivateLocationFlyoutVisible = false; + state.isPrivateLocationFlyoutVisible = false; }) .addCase(createPrivateLocationAction.fail, (state, action) => { state.error = action.payload; state.createLoading = false; }) + .addCase(editPrivateLocationAction.get, (state) => { + state.editLoading = true; + }) + .addCase(editPrivateLocationAction.success, (state, action) => { + state.editLoading = false; + state.privateLocationToEdit = undefined; + state.data = null; + state.isPrivateLocationFlyoutVisible = false; + }) + .addCase(editPrivateLocationAction.fail, (state, action) => { + state.editLoading = false; + state.privateLocationToEdit = undefined; + state.error = action.payload; + }) .addCase(deletePrivateLocationAction.get, (state) => { state.deleteLoading = true; }) @@ -68,7 +91,10 @@ export const privateLocationsStateReducer = createReducer(initialState, (builder state.error = action.payload; state.deleteLoading = false; }) - .addCase(setIsCreatePrivateLocationFlyoutVisible, (state, action) => { - state.isCreatePrivateLocationFlyoutVisible = action.payload; + .addCase(setIsPrivateLocationFlyoutVisible, (state, action) => { + state.isPrivateLocationFlyoutVisible = action.payload; + }) + .addCase(setPrivateLocationToEdit, (state, action) => { + state.privateLocationToEdit = action.payload; }); }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/selectors.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/selectors.ts index a9ce77217579..5b85c0e7dbdd 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/selectors.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/private_locations/selectors.ts @@ -11,8 +11,11 @@ import { AppState } from '..'; const getState = (appState: AppState) => appState.privateLocations; export const selectAgentPolicies = createSelector(getState, (state) => state); -export const selectAddingNewPrivateLocation = (state: AppState) => - state.privateLocations.isCreatePrivateLocationFlyoutVisible ?? false; +export const selectPrivateLocationFlyoutVisible = (state: AppState) => + state.privateLocations.isPrivateLocationFlyoutVisible ?? false; + +export const selectPrivateLocationToEdit = (state: AppState) => + state.privateLocations.privateLocationToEdit; export const selectPrivateLocationsLoading = (state: AppState) => state.privateLocations.loading ?? false; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/settings/selectors.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/settings/selectors.ts index 195c1380ebc9..298c7a5dbf10 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/settings/selectors.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/settings/selectors.ts @@ -14,7 +14,7 @@ const getState = (appState: AppState) => appState.agentPolicies; export const selectAgentPolicies = createSelector(getState, (state) => state); export const selectAddingNewPrivateLocation = (state: AppState) => - state.privateLocations.isCreatePrivateLocationFlyoutVisible ?? false; + state.privateLocations.isPrivateLocationFlyoutVisible ?? false; export const selectLocationMonitors = (state: AppState) => ({ locationMonitors: state.dynamicSettings.locationMonitors, diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts index 248e4f2060ac..6b1a75c067c8 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts @@ -116,7 +116,7 @@ export const mockState: SyntheticsAppState = { data: null, }, privateLocations: { - isCreatePrivateLocationFlyoutVisible: false, + isPrivateLocationFlyoutVisible: false, loading: false, error: null, data: [], diff --git a/x-pack/solutions/observability/plugins/synthetics/server/repositories/private_location_repository.ts b/x-pack/solutions/observability/plugins/synthetics/server/repositories/private_location_repository.ts index f42947b8252c..6f0f52545b95 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/repositories/private_location_repository.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/repositories/private_location_repository.ts @@ -12,6 +12,7 @@ import { PrivateLocationAttributes } from '../runtime_types/private_locations'; import { PrivateLocationObject } from '../routes/settings/private_locations/add_private_location'; import { RouteContext } from '../routes/types'; import { privateLocationSavedObjectName } from '../../common/saved_objects/private_locations'; +import { EditPrivateLocationAttributes } from '../routes/settings/private_locations/edit_private_location'; export class PrivateLocationRepository { internalSOClient: ISavedObjectsRepository; @@ -33,6 +34,26 @@ export class PrivateLocationRepository { } ); } + + async getPrivateLocation(locationId: string) { + const { savedObjectsClient } = this.routeContext; + + return savedObjectsClient.get( + privateLocationSavedObjectName, + locationId + ); + } + + async editPrivateLocation(locationId: string, newAttributes: EditPrivateLocationAttributes) { + const { savedObjectsClient } = this.routeContext; + + return savedObjectsClient.update( + privateLocationSavedObjectName, + locationId, + newAttributes + ); + } + async validatePrivateLocation() { const { response, request, server } = this.routeContext; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts index 19b45f7b662d..bae05cb89fbd 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts @@ -55,6 +55,7 @@ import { getDefaultAlertingRoute } from './default_alerts/get_default_alert'; import { createNetworkEventsRoute } from './network_events'; import { addPrivateLocationRoute } from './settings/private_locations/add_private_location'; import { deletePrivateLocationRoute } from './settings/private_locations/delete_private_location'; +import { editPrivateLocationRoute } from './settings/private_locations/edit_private_location'; import { getPrivateLocationsRoute } from './settings/private_locations/get_private_locations'; import { getSyntheticsFilters } from './filters/filters'; import { getAllSyntheticsMonitorRoute } from './monitor_cruds/get_monitors_list'; @@ -112,6 +113,7 @@ export const syntheticsAppPublicRestApiRoutes: SyntheticsRestApiRouteFactory[] = deleteSyntheticsParamsRoute, addPrivateLocationRoute, deletePrivateLocationRoute, + editPrivateLocationRoute, getPrivateLocationsRoute, getAllSyntheticsMonitorRoute, getSyntheticsMonitorRoute, diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/edit_monitor_bulk.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/edit_monitor_bulk.ts index d4725af1edfd..61f609b1966c 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/edit_monitor_bulk.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/edit_monitor_bulk.ts @@ -83,7 +83,10 @@ export const syncEditedMonitorBulk = async ({ } as unknown as MonitorFields, })); const [editedMonitorSavedObjects, editSyncResponse] = await Promise.all([ - monitorConfigRepository.bulkUpdate({ monitors: data }), + monitorConfigRepository.bulkUpdate({ + monitors: data, + namespace: spaceId !== routeContext.spaceId ? spaceId : undefined, + }), syncUpdatedMonitors({ monitorsToUpdate, routeContext, spaceId, privateLocations }), ]); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/edit_private_location.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/edit_private_location.ts new file mode 100644 index 000000000000..3ae484b33cbf --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/edit_private_location.ts @@ -0,0 +1,163 @@ +/* + * 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 { TypeOf, schema } from '@kbn/config-schema'; +import { SavedObject, SavedObjectsErrorHelpers } from '@kbn/core/server'; +import { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants'; +import { i18n } from '@kbn/i18n'; +import { isEqual } from 'lodash'; +import { getPrivateLocations } from '../../../synthetics_service/get_private_locations'; +import { PrivateLocationAttributes } from '../../../runtime_types/private_locations'; +import { PrivateLocationRepository } from '../../../repositories/private_location_repository'; +import { PRIVATE_LOCATION_WRITE_API } from '../../../feature'; +import { SyntheticsRestApiRouteFactory } from '../../types'; +import { SYNTHETICS_API_URLS } from '../../../../common/constants'; +import { toClientContract, updatePrivateLocationMonitors } from './helpers'; +import { PrivateLocation } from '../../../../common/runtime_types'; +import { parseArrayFilters } from '../../common'; + +const EditPrivateLocationSchema = schema.object({ + label: schema.maybe( + schema.string({ + minLength: 1, + }) + ), + tags: schema.maybe(schema.arrayOf(schema.string())), +}); + +const EditPrivateLocationQuery = schema.object({ + locationId: schema.string(), +}); + +export type EditPrivateLocationAttributes = Pick< + PrivateLocationAttributes, + keyof TypeOf +>; + +const isPrivateLocationLabelChanged = (oldLabel: string, newLabel?: string): newLabel is string => { + return typeof newLabel === 'string' && oldLabel !== newLabel; +}; + +const isPrivateLocationChanged = ({ + privateLocation, + newParams, +}: { + privateLocation: SavedObject; + newParams: TypeOf; +}) => { + const isLabelChanged = isPrivateLocationLabelChanged( + privateLocation.attributes.label, + newParams.label + ); + const areTagsChanged = + Array.isArray(newParams.tags) && + (!privateLocation.attributes.tags || + (privateLocation.attributes.tags && + !isEqual(privateLocation.attributes.tags, newParams.tags))); + + return isLabelChanged || areTagsChanged; +}; + +export const editPrivateLocationRoute: SyntheticsRestApiRouteFactory< + PrivateLocation, + TypeOf, + any, + TypeOf +> = () => ({ + method: 'PUT', + path: SYNTHETICS_API_URLS.PRIVATE_LOCATIONS + '/{locationId}', + validate: {}, + validation: { + request: { + body: EditPrivateLocationSchema, + params: EditPrivateLocationQuery, + }, + }, + requiredPrivileges: [PRIVATE_LOCATION_WRITE_API], + handler: async (routeContext) => { + const { response, request, savedObjectsClient, server } = routeContext; + const { locationId } = request.params; + const { label: newLocationLabel, tags: newTags } = request.body; + + const repo = new PrivateLocationRepository(routeContext); + + try { + const { filtersStr } = parseArrayFilters({ + locations: [locationId], + }); + const [existingLocation, monitorsInLocation] = await Promise.all([ + repo.getPrivateLocation(locationId), + routeContext.monitorConfigRepository.findDecryptedMonitors({ + spaceId: ALL_SPACES_ID, + filter: filtersStr, + }), + ]); + + let newLocation: Awaited> | undefined; + + if ( + isPrivateLocationChanged({ privateLocation: existingLocation, newParams: request.body }) + ) { + // This privileges check is done only when changing the label, because changing the label will update also the monitors in that location + if (isPrivateLocationLabelChanged(existingLocation.attributes.label, newLocationLabel)) { + const monitorsSpaces = monitorsInLocation.map(({ namespaces }) => namespaces![0]); + + const checkSavedObjectsPrivileges = + server.security.authz.checkSavedObjectsPrivilegesWithRequest(request); + + const { hasAllRequested } = await checkSavedObjectsPrivileges( + 'saved_object:synthetics-monitor/bulk_update', + monitorsSpaces + ); + + if (!hasAllRequested) { + return response.forbidden({ + body: { + message: i18n.translate('xpack.synthetics.editPrivateLocation.forbidden', { + defaultMessage: + 'You do not have sufficient permissions to update monitors in all required spaces. This private location is used by monitors in spaces where you lack update privileges.', + }), + }, + }); + } + } + + newLocation = await repo.editPrivateLocation(locationId, { + label: newLocationLabel || existingLocation.attributes.label, + tags: newTags || existingLocation.attributes.tags, + }); + + if (isPrivateLocationLabelChanged(existingLocation.attributes.label, newLocationLabel)) { + await updatePrivateLocationMonitors({ + locationId, + newLocationLabel, + allPrivateLocations: await getPrivateLocations(savedObjectsClient), + routeContext, + monitorsInLocation, + }); + } + } + + return toClientContract({ + ...existingLocation, + attributes: { + ...existingLocation.attributes, + ...(newLocation ? newLocation.attributes : {}), + }, + }); + } catch (error) { + if (SavedObjectsErrorHelpers.isNotFoundError(error)) { + return response.notFound({ + body: { + message: `Private location with id ${locationId} does not exist.`, + }, + }); + } + throw error; + } + }, +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/helpers.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/helpers.test.ts index 84c531cb9ce7..5a271747c8d3 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/helpers.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/helpers.test.ts @@ -5,7 +5,20 @@ * 2.0. */ -import { allLocationsToClientContract } from './helpers'; +import { allLocationsToClientContract, updatePrivateLocationMonitors } from './helpers'; +import { RouteContext } from '../../types'; + +// Mock the syncEditedMonitorBulk module +jest.mock('../../monitor_cruds/bulk_cruds/edit_monitor_bulk', () => ({ + syncEditedMonitorBulk: jest.fn().mockResolvedValue({ + failedConfigs: [], + errors: [], + editedMonitors: [], + }), +})); + +// Import the mocked function +import { syncEditedMonitorBulk } from '../../monitor_cruds/bulk_cruds/edit_monitor_bulk'; const testLocations = { locations: [ @@ -114,3 +127,107 @@ describe('toClientContract', () => { ]); }); }); + +describe('updatePrivateLocationMonitors', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const LOCATION_ID = 'test-location-id'; + const NEW_LABEL = 'New location label'; + const FIRST_SPACE_ID = 'firstSpaceId'; + const SECOND_SPACE_ID = 'secondSpaceId'; + const FIRST_MONITOR_ID = 'monitor-1'; + const SECOND_MONITOR_ID = 'monitor-2'; + const mockMonitors = [ + { + id: FIRST_MONITOR_ID, + attributes: { + name: 'Test Monitor 1', + locations: [{ id: LOCATION_ID, label: 'Old Label' }], + // Other required monitor fields + type: 'http', + enabled: true, + schedule: { number: '10', unit: 'm' }, + namespace: FIRST_SPACE_ID, + }, + }, + { + id: SECOND_MONITOR_ID, + attributes: { + name: 'Test Monitor 2', + locations: [ + { id: LOCATION_ID, label: 'Old Label' }, + { id: 'different-location', label: 'Different Location' }, + ], + // Other required monitor fields + type: 'http', + enabled: true, + schedule: { number: '5', unit: 'm' }, + namespace: SECOND_SPACE_ID, + }, + }, + ]; + + it('updates monitor locations with the new label', async () => { + const PRIVATE_LOCATIONS = [] as any[]; + const ROUTE_CONTEXT = {} as RouteContext; + // Call the function + await updatePrivateLocationMonitors({ + locationId: LOCATION_ID, + newLocationLabel: NEW_LABEL, + allPrivateLocations: PRIVATE_LOCATIONS, + routeContext: ROUTE_CONTEXT, + monitorsInLocation: mockMonitors as any, + }); + + // Verify that syncEditedMonitorBulk was called + expect(syncEditedMonitorBulk).toHaveBeenCalledTimes(2); + + // Check first call for first space + expect(syncEditedMonitorBulk).toHaveBeenCalledWith({ + monitorsToUpdate: expect.arrayContaining([ + expect.objectContaining({ + decryptedPreviousMonitor: mockMonitors[0], + normalizedMonitor: expect.any(Object), + monitorWithRevision: expect.objectContaining({ + locations: [ + expect.objectContaining({ + id: LOCATION_ID, + label: NEW_LABEL, + }), + ], + }), + }), + ]), + privateLocations: PRIVATE_LOCATIONS, + routeContext: ROUTE_CONTEXT, + spaceId: FIRST_SPACE_ID, + }); + + // Check second call for second space + expect(syncEditedMonitorBulk).toHaveBeenCalledWith({ + monitorsToUpdate: expect.arrayContaining([ + expect.objectContaining({ + decryptedPreviousMonitor: mockMonitors[1], + normalizedMonitor: expect.any(Object), + monitorWithRevision: expect.objectContaining({ + locations: [ + expect.objectContaining({ + id: LOCATION_ID, + label: NEW_LABEL, + }), + expect.objectContaining({ + id: 'different-location', + label: 'Different Location', + }), + ], + }), + }), + ]), + privateLocations: PRIVATE_LOCATIONS, + routeContext: ROUTE_CONTEXT, + spaceId: SECOND_SPACE_ID, + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/helpers.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/helpers.ts index 956fdec42335..85579112411d 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/helpers.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/helpers.ts @@ -4,14 +4,24 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { SavedObject } from '@kbn/core/server'; +import { SavedObject, SavedObjectsFindResult } from '@kbn/core/server'; +import { formatSecrets, normalizeSecrets } from '../../../synthetics_service/utils'; import { AgentPolicyInfo } from '../../../../common/types'; -import type { SyntheticsPrivateLocations } from '../../../../common/runtime_types'; +import type { + SyntheticsMonitor, + SyntheticsMonitorWithSecretsAttributes, + SyntheticsPrivateLocations, +} from '../../../../common/runtime_types'; import type { SyntheticsPrivateLocationsAttributes, PrivateLocationAttributes, } from '../../../runtime_types/private_locations'; import { PrivateLocation } from '../../../../common/runtime_types'; +import { + MonitorConfigUpdate, + syncEditedMonitorBulk, +} from '../../monitor_cruds/bulk_cruds/edit_monitor_bulk'; +import { RouteContext } from '../../types'; export const toClientContract = ( locationObject: SavedObject @@ -60,3 +70,54 @@ export const toSavedObjectContract = (location: PrivateLocation): PrivateLocatio spaces: location.spaces, }; }; + +// This should be called when changing the label of a private location because the label is also stored +// in the locations array of monitors attributes +export const updatePrivateLocationMonitors = async ({ + locationId, + newLocationLabel, + allPrivateLocations, + routeContext, + monitorsInLocation, +}: { + locationId: string; + newLocationLabel: string; + allPrivateLocations: SyntheticsPrivateLocations; + routeContext: RouteContext; + monitorsInLocation: Array>; +}) => { + const updatedMonitorsPerSpace = monitorsInLocation.reduce>( + (acc, m) => { + const decryptedMonitorsWithNormalizedSecrets: SavedObject = + normalizeSecrets(m); + const normalizedMonitor = decryptedMonitorsWithNormalizedSecrets.attributes; + const newLocations = m.attributes.locations.map((l) => + l.id !== locationId ? l : { ...l, label: newLocationLabel } + ); + const monitorWithRevision = formatSecrets({ ...normalizedMonitor, locations: newLocations }); + const monitorToUpdate: MonitorConfigUpdate = { + normalizedMonitor, + decryptedPreviousMonitor: m, + monitorWithRevision, + }; + + const namespace = m.attributes.namespace; + return { + ...acc, + [namespace]: [...(acc[namespace] || []), monitorToUpdate], + }; + }, + {} + ); + + const promises = Object.keys(updatedMonitorsPerSpace).map((namespace) => [ + syncEditedMonitorBulk({ + monitorsToUpdate: updatedMonitorsPerSpace[namespace], + privateLocations: allPrivateLocations, + routeContext, + spaceId: namespace, + }), + ]); + + return Promise.all(promises.flat()); +}; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.ts b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.ts index 5c8901d802e9..487d58e95ef0 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.ts @@ -82,17 +82,20 @@ export class MonitorConfigRepository { async bulkUpdate({ monitors, + namespace, }: { monitors: Array<{ attributes: MonitorFields; id: string; }>; + namespace?: string; }) { - return await this.soClient.bulkUpdate( + return this.soClient.bulkUpdate( monitors.map(({ attributes, id }) => ({ type: syntheticsMonitorType, id, attributes, + namespace, })) ); } diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/secrets.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/secrets.ts index 90a11812ff19..b60622670224 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/secrets.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/utils/secrets.ts @@ -42,7 +42,7 @@ export function normalizeMonitorSecretAttributes( const normalizedMonitorAttributes = { ...defaultFields, ...monitor, - ...JSON.parse(monitor.secrets || ''), + ...JSON.parse(monitor.secrets || '{}'), }; delete normalizedMonitorAttributes.secrets; return normalizedMonitorAttributes; diff --git a/x-pack/solutions/observability/plugins/uptime/common/constants/ui.ts b/x-pack/solutions/observability/plugins/uptime/common/constants/ui.ts index 19d980dfa153..145477becc42 100644 --- a/x-pack/solutions/observability/plugins/uptime/common/constants/ui.ts +++ b/x-pack/solutions/observability/plugins/uptime/common/constants/ui.ts @@ -27,7 +27,7 @@ export const GETTING_STARTED_ROUTE = '/monitors/getting-started'; export const SETTINGS_ROUTE = '/settings'; -export const PRIVATE_LOCATIOSN_ROUTE = '/settings/private-locations'; +export const PRIVATE_LOCATIONS_ROUTE = '/settings/private-locations'; export const SYNTHETICS_SETTINGS_ROUTE = '/settings/:tabId'; diff --git a/x-pack/solutions/observability/test/api_integration/apis/synthetics/synthetics_api_security.ts b/x-pack/solutions/observability/test/api_integration/apis/synthetics/synthetics_api_security.ts index 3969dcae8821..1ee6f203336c 100644 --- a/x-pack/solutions/observability/test/api_integration/apis/synthetics/synthetics_api_security.ts +++ b/x-pack/solutions/observability/test/api_integration/apis/synthetics/synthetics_api_security.ts @@ -39,7 +39,10 @@ export default function ({ getService }: FtrProviderContext) { let resp; const { statusCodes, SPACE_ID, username, password, writeAccess, readUser } = options; let tags = !writeAccess ? '[uptime-read]' : options.tags ?? '[uptime-read,uptime-write]'; - if ((method === 'POST' || method === 'DELETE') && path.includes('private_locations')) { + if ( + (method === 'POST' || method === 'DELETE' || method === 'PUT') && + path.includes('private_locations') + ) { tags = readUser ? '[private-location-write,uptime-write]' : '[uptime-read,private-location-write,uptime-write]'; diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/observability/synthetics/edit_private_location.ts b/x-pack/test/api_integration/deployment_agnostic/apis/observability/synthetics/edit_private_location.ts new file mode 100644 index 000000000000..efef66b610bf --- /dev/null +++ b/x-pack/test/api_integration/deployment_agnostic/apis/observability/synthetics/edit_private_location.ts @@ -0,0 +1,174 @@ +/* + * 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 { RoleCredentials } from '@kbn/ftr-common-functional-services'; +import { PrivateLocation, ServiceLocation } from '@kbn/synthetics-plugin/common/runtime_types'; +import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants'; +import expect from '@kbn/expect'; +import rawExpect from 'expect'; +import { PackagePolicy } from '@kbn/fleet-plugin/common'; +import { DeploymentAgnosticFtrProviderContext } from '../../../ftr_provider_context'; +import { getFixtureJson } from './helpers/get_fixture_json'; +import { PrivateLocationTestService } from '../../../services/synthetics_private_location'; +import { addMonitorAPIHelper, omitMonitorKeys } from './create_monitor'; +import { SupertestWithRoleScopeType } from '../../../services'; + +export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { + describe('EditPrivateLocation', function () { + const kibanaServer = getService('kibanaServer'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const samlAuth = getService('samlAuth'); + const roleScopedSupertest = getService('roleScopedSupertest'); + let supertestEditorWithApiKey: SupertestWithRoleScopeType; + + let testFleetPolicyID: string; + let editorUser: RoleCredentials; + let privateLocations: PrivateLocation[] = []; + const testPolicyName = 'Fleet test server policy' + Date.now(); + + let newMonitor: { id: string; name: string }; + const testPrivateLocations = new PrivateLocationTestService(getService); + const NEW_LOCATION_LABEL = 'Barcelona'; + const NEW_TAGS = ['myAwesomeTag']; + + before(async () => { + supertestEditorWithApiKey = await roleScopedSupertest.getSupertestWithRoleScope('editor', { + withInternalHeaders: true, + }); + + await kibanaServer.savedObjects.cleanStandardList(); + await testPrivateLocations.installSyntheticsPackage(); + editorUser = await samlAuth.createM2mApiKeyWithRoleScope('editor'); + }); + + after(async () => { + await supertestEditorWithApiKey.destroy(); + await samlAuth.invalidateM2mApiKeyWithRoleScope(editorUser); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + it('adds a test fleet policy', async () => { + const apiResponse = await testPrivateLocations.addFleetPolicy(testPolicyName); + testFleetPolicyID = apiResponse.body.item.id; + }); + + it('add a test private location', async () => { + privateLocations = await testPrivateLocations.setTestLocations([testFleetPolicyID]); + + const apiResponse = await supertestEditorWithApiKey + .get(SYNTHETICS_API_URLS.SERVICE_LOCATIONS) + .expect(200); + + const testResponse: Array = [ + { + id: testFleetPolicyID, + isServiceManaged: false, + isInvalid: false, + label: privateLocations[0].label, + geo: { + lat: 0, + lon: 0, + }, + agentPolicyId: testFleetPolicyID, + spaces: ['default'], + }, + ]; + + rawExpect(apiResponse.body.locations).toEqual(rawExpect.arrayContaining(testResponse)); + }); + + it('adds a monitor in private location', async () => { + newMonitor = { + ...getFixtureJson('http_monitor'), + namespace: 'default', + locations: [privateLocations[0]], + }; + + const { body, rawBody } = await addMonitorAPIHelper( + supertestWithoutAuth, + newMonitor, + 200, + editorUser, + samlAuth + ); + expect(body).eql(omitMonitorKeys(newMonitor)); + newMonitor.id = rawBody.id; + }); + + it('successfully edits a private location label', async () => { + const privateLocation = privateLocations[0]; + + // Edit the private location + const editResponse = await supertestEditorWithApiKey + .put(`${SYNTHETICS_API_URLS.PRIVATE_LOCATIONS}/${privateLocation.id}`) + .send({ label: NEW_LOCATION_LABEL, tags: NEW_TAGS }) + .expect(200); + + // Verify the response contains the updated label + expect(editResponse.body.label).to.be(NEW_LOCATION_LABEL); + expect(editResponse.body.tags).to.eql(NEW_TAGS); + expect(editResponse.body.id).to.be(privateLocation.id); + expect(editResponse.body.agentPolicyId).to.be(privateLocation.agentPolicyId); + + // Verify the location was actually updated by getting it + const getResponse = await supertestEditorWithApiKey + .get(`${SYNTHETICS_API_URLS.PRIVATE_LOCATIONS}/${privateLocation.id}`) + .expect(200); + + expect(getResponse.body.label).to.be(NEW_LOCATION_LABEL); + }); + + it('verifies that monitor location label is updated when the private location label changes', async () => { + // Get the monitor with the updated location label + const getMonitorResponse = await supertestEditorWithApiKey + .get(`${SYNTHETICS_API_URLS.SYNTHETICS_MONITORS}/${newMonitor.id}`) + .expect(200); + + // Verify the monitor's location has the updated label + const monitor = getMonitorResponse.body; + expect(monitor.locations).to.have.length(1); + expect(monitor.locations[0].id).to.be(privateLocations[0].id); + expect(monitor.locations[0].label).to.be(NEW_LOCATION_LABEL); + }); + + it('verifies that package policies are updated when the private location label changes', async () => { + const apiResponse = await supertestEditorWithApiKey.get( + '/api/fleet/package_policies?page=1&perPage=2000&kuery=ingest-package-policies.package.name%3A%20synthetics' + ); + + const packagePolicy: PackagePolicy = apiResponse.body.items.find( + (pkgPolicy: PackagePolicy) => + pkgPolicy.id === newMonitor.id + '-' + testFleetPolicyID + '-default' + ); + expect(packagePolicy.name).to.contain(NEW_LOCATION_LABEL); + }); + + it('returns 404 when trying to edit a non-existent private location', async () => { + const nonExistentId = 'non-existent-id'; + + const response = await supertestEditorWithApiKey + .put(`${SYNTHETICS_API_URLS.PRIVATE_LOCATIONS}/${nonExistentId}`) + .send({ label: NEW_LOCATION_LABEL }) + .expect(404); + + expect(response.body.message).to.contain( + `Private location with id ${nonExistentId} does not exist.` + ); + }); + + it('returns 400 when trying to edit with an empty label', async () => { + const response = await supertestEditorWithApiKey + .put(`${SYNTHETICS_API_URLS.PRIVATE_LOCATIONS}/${privateLocations[0].id}`) + .send({ label: '' }) + .expect(400); + + expect(response.body.message).to.contain( + '[request body.label]: value has length [0] but it must have a minimum length of [1].' + ); + }); + }); +} diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/observability/synthetics/index.ts b/x-pack/test/api_integration/deployment_agnostic/apis/observability/synthetics/index.ts index 0e79a70376f0..2dd2e0b50812 100644 --- a/x-pack/test/api_integration/deployment_agnostic/apis/observability/synthetics/index.ts +++ b/x-pack/test/api_integration/deployment_agnostic/apis/observability/synthetics/index.ts @@ -31,5 +31,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) loadTestFile(require.resolve('./sync_global_params')); loadTestFile(require.resolve('./synthetics_enablement')); loadTestFile(require.resolve('./test_now_monitor')); + loadTestFile(require.resolve('./edit_private_location')); }); }