[Synthetics] Edit private locations labels and tags in Synthetics (#221515)

This PR closes #221508.



https://github.com/user-attachments/assets/0b57487a-7188-4722-99dc-5cb44c15f129

- Added a new API endpoint to edit the label of private locations.
- When a label is updated, all monitors deployed in that location are
automatically updated to reflect the change.
- The UI now allows users to edit only the label of a private location.
- Added comprehensive API tests to cover the new functionality.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Francesco Fagnani 2025-06-20 09:57:13 +02:00 committed by GitHub
parent b759ebba3d
commit b1b8fb0a88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 994 additions and 111 deletions

View file

@ -56759,6 +56759,69 @@ paths:
summary: Get a private location summary: Get a private location
tags: tags:
- synthetics - 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: /api/task_manager/_health:
get: get:
description: | description: |

View file

@ -28,7 +28,7 @@ export const GETTING_STARTED_ROUTE = '/monitors/getting-started';
export const SETTINGS_ROUTE = '/settings'; 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'; export const SYNTHETICS_SETTINGS_ROUTE = '/settings/:tabId';

View file

@ -1024,6 +1024,71 @@ paths:
}, },
"namespace": "default" "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: components:
schemas: schemas:
commonMonitorFields: commonMonitorFields:

View file

@ -15,6 +15,7 @@ import { syntheticsAppPageProvider } from '../page_objects/synthetics_app';
journey(`PrivateLocationsSettings`, async ({ page, params }) => { journey(`PrivateLocationsSettings`, async ({ page, params }) => {
const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl, params }); const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl, params });
const services = new SyntheticsServices(params); const services = new SyntheticsServices(params);
const NEW_LOCATION_LABEL = 'Updated Test Location';
page.setDefaultTimeout(2 * 30000); 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 () => { step('Integration cannot be edited in Fleet', async () => {
await page.goto(`${params.kibanaUrl}/app/integrations/detail/synthetics/policies`); await page.goto(`${params.kibanaUrl}/app/integrations/detail/synthetics/policies`);
await page.waitForSelector('h1:has-text("Elastic Synthetics")'); 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('h1:has-text("Edit Elastic Synthetics integration")');
await page.waitForSelector('text="This package policy is managed by the Synthetics app."'); 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('h1:has-text("Settings")');
await page.click('text=Private Locations'); await page.click('text=Private Locations');
await page.waitForSelector('td:has-text("1")'); 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('.euiTableRowCell .euiToolTipAnchor');
await page.click('button:has-text("Tags")'); await page.click('button:has-text("Tags")');
await page.click('[aria-label="Tags"] >> text=Area51'); await page.click('[aria-label="Tags"] >> text=Area51');
await page.click( await page.click(
'main div:has-text("Private locations allow you to run monitors from your own premises. They require")' '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'); await page.click('.euiTableRowCell .euiToolTipAnchor');

View file

@ -21,8 +21,9 @@ describe('GettingStartedPage', () => {
loading: false, loading: false,
privateLocations: [], privateLocations: [],
deleteLoading: false, deleteLoading: false,
onSubmit: jest.fn(), onCreateLocationAPI: jest.fn(),
onDelete: jest.fn(), onDeleteLocationAPI: jest.fn(),
onEditLocationAPI: jest.fn(),
createLoading: false, createLoading: false,
}); });
jest.spyOn(permissionsHooks, 'useCanManagePrivateLocation').mockReturnValue(true); jest.spyOn(permissionsHooks, 'useCanManagePrivateLocation').mockReturnValue(true);
@ -82,7 +83,7 @@ describe('GettingStartedPage', () => {
loading: false, loading: false,
}, },
privateLocations: { privateLocations: {
isCreatePrivateLocationFlyoutVisible: true, isPrivateLocationFlyoutVisible: true,
}, },
agentPolicies: { agentPolicies: {
data: [], data: [],
@ -112,7 +113,7 @@ describe('GettingStartedPage', () => {
data: [{}], data: [{}],
}, },
privateLocations: { privateLocations: {
isCreatePrivateLocationFlyoutVisible: true, isPrivateLocationFlyoutVisible: true,
}, },
}, },
}); });
@ -151,7 +152,7 @@ describe('GettingStartedPage', () => {
data: [{}], data: [{}],
}, },
privateLocations: { privateLocations: {
isCreatePrivateLocationFlyoutVisible: true, isPrivateLocationFlyoutVisible: true,
}, },
}, },
} }

View file

@ -27,11 +27,14 @@ import { LoadingState } from '../monitors_page/overview/overview/monitor_detail_
import { getServiceLocations, cleanMonitorListState } from '../../state'; import { getServiceLocations, cleanMonitorListState } from '../../state';
import { MONITOR_ADD_ROUTE } from '../../../../../common/constants/ui'; import { MONITOR_ADD_ROUTE } from '../../../../../common/constants/ui';
import { SimpleMonitorForm } from './simple_monitor_form'; 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 type { ClientPluginsStart } from '../../../../plugin';
import { getAgentPoliciesAction, selectAgentPolicies } from '../../state/agent_policies'; import { getAgentPoliciesAction, selectAgentPolicies } from '../../state/agent_policies';
import { selectAddingNewPrivateLocation } from '../../state/settings/selectors'; import { setIsPrivateLocationFlyoutVisible } from '../../state/private_locations/actions';
import { setIsCreatePrivateLocationFlyoutVisible } from '../../state/private_locations/actions'; import { selectPrivateLocationFlyoutVisible } from '../../state/private_locations/selectors';
export const GettingStartedPage = () => { export const GettingStartedPage = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -127,23 +130,23 @@ export const GettingStartedOnPrem = () => {
useBreadcrumbs([{ text: MONITORING_OVERVIEW_LABEL }]); // No extra breadcrumbs on overview useBreadcrumbs([{ text: MONITORING_OVERVIEW_LABEL }]); // No extra breadcrumbs on overview
const isAddingNewLocation = useSelector(selectAddingNewPrivateLocation); const isPrivateLocationFlyoutVisible = useSelector(selectPrivateLocationFlyoutVisible);
const setIsAddingNewLocation = useCallback( const setIsFlyoutOpen = useCallback(
(val: boolean) => dispatch(setIsCreatePrivateLocationFlyoutVisible(val)), (val: boolean) => dispatch(setIsPrivateLocationFlyoutVisible(val)),
[dispatch] [dispatch]
); );
const { onSubmit, privateLocations } = usePrivateLocationsAPI(); const { onCreateLocationAPI, privateLocations } = usePrivateLocationsAPI();
const handleSubmit = (formData: NewLocation) => { const handleSubmit = (formData: NewLocation) => {
onSubmit(formData); onCreateLocationAPI(formData);
}; };
// make sure flyout is closed when first visiting the page // make sure flyout is closed when first visiting the page
useEffect(() => { useEffect(() => {
setIsAddingNewLocation(false); setIsFlyoutOpen(false);
}, [setIsAddingNewLocation]); }, [setIsFlyoutOpen]);
return ( return (
<> <>
@ -163,7 +166,7 @@ export const GettingStartedOnPrem = () => {
fill fill
iconType="plusInCircleFilled" iconType="plusInCircleFilled"
data-test-subj="gettingStartedAddLocationButton" data-test-subj="gettingStartedAddLocationButton"
onClick={() => setIsAddingNewLocation(true)} onClick={() => setIsFlyoutOpen(true)}
> >
{CREATE_LOCATION_LABEL} {CREATE_LOCATION_LABEL}
</EuiButton> </EuiButton>
@ -173,9 +176,9 @@ export const GettingStartedOnPrem = () => {
footer={<GettingStartedLink />} footer={<GettingStartedLink />}
/> />
{isAddingNewLocation ? ( {isPrivateLocationFlyoutVisible ? (
<AddLocationFlyout <AddOrEditLocationFlyout
setIsOpen={setIsAddingNewLocation} onCloseFlyout={() => setIsFlyoutOpen(false)}
onSubmit={handleSubmit} onSubmit={handleSubmit}
privateLocations={privateLocations} privateLocations={privateLocations}
/> />

View file

@ -16,9 +16,13 @@ import { ClientPluginsStart } from '../../../../../plugin';
interface SpaceSelectorProps { interface SpaceSelectorProps {
helpText: string; helpText: string;
isDisabled?: boolean;
} }
export const SpaceSelector = <T extends FieldValues>({ helpText }: SpaceSelectorProps) => { export const SpaceSelector = <T extends FieldValues>({
helpText,
isDisabled = false,
}: SpaceSelectorProps) => {
const NAMESPACES_NAME = 'spaces' as Path<T>; const NAMESPACES_NAME = 'spaces' as Path<T>;
const { services } = useKibana<ClientPluginsStart>(); const { services } = useKibana<ClientPluginsStart>();
const [spacesList, setSpacesList] = React.useState<Array<{ id: string; label: string }>>([]); const [spacesList, setSpacesList] = React.useState<Array<{ id: string; label: string }>>([]);
@ -61,6 +65,7 @@ export const SpaceSelector = <T extends FieldValues>({ helpText }: SpaceSelector
rules={{ required: true }} rules={{ required: true }}
render={({ field }) => ( render={({ field }) => (
<EuiComboBox <EuiComboBox
isDisabled={isDisabled}
fullWidth fullWidth
aria-label={SPACES_LABEL} aria-label={SPACES_LABEL}
placeholder={SPACES_LABEL} placeholder={SPACES_LABEL}

View file

@ -15,10 +15,12 @@ export function TagsField({
tagsList, tagsList,
control, control,
errors, errors,
isDisabled,
}: { }: {
tagsList: string[]; tagsList: string[];
errors: FieldErrors; errors: FieldErrors;
control: Control<PrivateLocation, any>; control: Control<PrivateLocation, any>;
isDisabled?: boolean;
}) { }) {
return ( return (
<EuiFormRow fullWidth label={TAGS_LABEL}> <EuiFormRow fullWidth label={TAGS_LABEL}>
@ -27,6 +29,7 @@ export function TagsField({
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<EuiComboBox <EuiComboBox
isDisabled={isDisabled}
fullWidth fullWidth
aria-label={TAGS_LABEL} aria-label={TAGS_LABEL}
placeholder={TAGS_LABEL} placeholder={TAGS_LABEL}

View file

@ -35,20 +35,22 @@ import { selectPrivateLocationsState } from '../../../state/private_locations/se
export type NewLocation = Omit<PrivateLocation, 'id'>; export type NewLocation = Omit<PrivateLocation, 'id'>;
const getEmptyFunctionComponent: React.FC<SpacesContextProps> = ({ children }) => <>{children}</>; const getEmptyFunctionComponent: React.FC<SpacesContextProps> = ({ children }) => <>{children}</>;
export const AddLocationFlyout = ({ export const AddOrEditLocationFlyout = ({
onSubmit, onSubmit,
setIsOpen, onCloseFlyout,
privateLocations, privateLocations,
privateLocationToEdit,
}: { }: {
onSubmit: (val: NewLocation) => void; onSubmit: (val: NewLocation) => void;
setIsOpen: (val: boolean) => void; onCloseFlyout: () => void;
privateLocations: PrivateLocation[]; privateLocations: PrivateLocation[];
privateLocationToEdit?: PrivateLocation;
}) => { }) => {
const form = useFormWrapped({ const form = useFormWrapped({
mode: 'onSubmit', mode: 'onSubmit',
reValidateMode: 'onChange', reValidateMode: 'onChange',
shouldFocusError: true, shouldFocusError: true,
defaultValues: { defaultValues: privateLocationToEdit || {
label: '', label: '',
agentPolicyId: '', agentPolicyId: '',
geo: { geo: {
@ -61,7 +63,7 @@ export const AddLocationFlyout = ({
const { canSave, canManagePrivateLocations } = useSyntheticsSettingsContext(); const { canSave, canManagePrivateLocations } = useSyntheticsSettingsContext();
const { createLoading } = useSelector(selectPrivateLocationsState); const { createLoading, editLoading } = useSelector(selectPrivateLocationsState);
const { spaces: spacesApi } = useKibana<ClientPluginsStart>().services; const { spaces: spacesApi } = useKibana<ClientPluginsStart>().services;
@ -72,33 +74,35 @@ export const AddLocationFlyout = ({
); );
const { handleSubmit } = form; const { handleSubmit } = form;
const closeFlyout = () => {
setIsOpen(false);
};
return ( return (
<ContextWrapper> <ContextWrapper>
<FormProvider {...form}> <FormProvider {...form}>
<EuiFlyout onClose={closeFlyout} style={{ width: 540 }}> <EuiFlyout onClose={onCloseFlyout} css={{ width: 540 }}>
<EuiFlyoutHeader hasBorder> <EuiFlyoutHeader hasBorder>
<EuiTitle size="m"> <EuiTitle size="m">
<h2>{ADD_PRIVATE_LOCATION}</h2> <h2>
{privateLocationToEdit !== undefined ? EDIT_PRIVATE_LOCATION : ADD_PRIVATE_LOCATION}
</h2>
</EuiTitle> </EuiTitle>
</EuiFlyoutHeader> </EuiFlyoutHeader>
<EuiFlyoutBody> <EuiFlyoutBody>
<ManageEmptyState privateLocations={privateLocations} showEmptyLocations={false}> <ManageEmptyState privateLocations={privateLocations} showEmptyLocations={false}>
<LocationForm privateLocations={privateLocations} /> <LocationForm
privateLocations={privateLocations}
privateLocationToEdit={privateLocationToEdit}
/>
</ManageEmptyState> </ManageEmptyState>
</EuiFlyoutBody> </EuiFlyoutBody>
<EuiFlyoutFooter> <EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween"> <EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiButtonEmpty <EuiButtonEmpty
data-test-subj="syntheticsAddLocationFlyoutButton" data-test-subj="syntheticsLocationFlyoutCancelButton"
iconType="cross" iconType="cross"
onClick={closeFlyout} onClick={onCloseFlyout}
flush="left" flush="left"
isLoading={createLoading} isLoading={createLoading || editLoading}
> >
{CANCEL_LABEL} {CANCEL_LABEL}
</EuiButtonEmpty> </EuiButtonEmpty>
@ -106,10 +110,10 @@ export const AddLocationFlyout = ({
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<NoPermissionsTooltip canEditSynthetics={canSave}> <NoPermissionsTooltip canEditSynthetics={canSave}>
<EuiButton <EuiButton
data-test-subj="syntheticsAddLocationFlyoutButton" data-test-subj="syntheticsLocationFlyoutSaveButton"
fill fill
onClick={handleSubmit(onSubmit)} onClick={handleSubmit(onSubmit)}
isLoading={createLoading} isLoading={createLoading || editLoading}
isDisabled={!canSave || !canManagePrivateLocations} isDisabled={!canSave || !canManagePrivateLocations}
> >
{SAVE_LABEL} {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', { const CANCEL_LABEL = i18n.translate('xpack.synthetics.monitorManagement.cancelLabel', {
defaultMessage: 'Cancel', defaultMessage: 'Cancel',
}); });

View file

@ -12,19 +12,19 @@ import { i18n } from '@kbn/i18n';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { NoPermissionsTooltip } from '../../common/components/permissions'; import { NoPermissionsTooltip } from '../../common/components/permissions';
import { useSyntheticsSettingsContext } from '../../../contexts'; import { useSyntheticsSettingsContext } from '../../../contexts';
import { PRIVATE_LOCATIOSN_ROUTE } from '../../../../../../common/constants'; import { PRIVATE_LOCATIONS_ROUTE } from '../../../../../../common/constants';
import { import {
setIsCreatePrivateLocationFlyoutVisible, setIsPrivateLocationFlyoutVisible,
setManageFlyoutOpen, setManageFlyoutOpen,
} from '../../../state/private_locations/actions'; } from '../../../state/private_locations/actions';
export const EmptyLocations = ({ export const EmptyLocations = ({
inFlyout = true, inFlyout = true,
setIsAddingNew, setIsFlyoutOpen,
redirectToSettings, redirectToSettings,
}: { }: {
inFlyout?: boolean; inFlyout?: boolean;
setIsAddingNew?: (val: boolean) => void; setIsFlyoutOpen?: (val: boolean) => void;
redirectToSettings?: boolean; redirectToSettings?: boolean;
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -52,7 +52,7 @@ export const EmptyLocations = ({
fill fill
isDisabled={!canSave} isDisabled={!canSave}
href={history.createHref({ href={history.createHref({
pathname: PRIVATE_LOCATIOSN_ROUTE, pathname: PRIVATE_LOCATIONS_ROUTE,
})} })}
> >
{ADD_LOCATION} {ADD_LOCATION}
@ -65,9 +65,9 @@ export const EmptyLocations = ({
color="primary" color="primary"
fill fill
onClick={() => { onClick={() => {
setIsAddingNew?.(true); setIsFlyoutOpen?.(true);
dispatch(setManageFlyoutOpen(true)); dispatch(setManageFlyoutOpen(true));
dispatch(setIsCreatePrivateLocationFlyoutVisible(true)); dispatch(setIsPrivateLocationFlyoutVisible(true));
}} }}
> >
{ADD_LOCATION} {ADD_LOCATION}

View file

@ -7,10 +7,12 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; 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 { import {
createPrivateLocationAction, createPrivateLocationAction,
deletePrivateLocationAction, deletePrivateLocationAction,
editPrivateLocationAction,
getPrivateLocationsAction, getPrivateLocationsAction,
} from '../../../../state/private_locations/actions'; } from '../../../../state/private_locations/actions';
import { selectPrivateLocationsState } from '../../../../state/private_locations/selectors'; import { selectPrivateLocationsState } from '../../../../state/private_locations/selectors';
@ -30,17 +32,22 @@ export const usePrivateLocationsAPI = () => {
} }
}, [data, dispatch]); }, [data, dispatch]);
const onSubmit = (newLoc: NewLocation) => { const onCreateLocationAPI = (newLoc: NewLocation) => {
dispatch(createPrivateLocationAction.get(newLoc)); 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)); dispatch(deletePrivateLocationAction.get(id));
}; };
return { return {
onSubmit, onCreateLocationAPI,
onDelete, onEditLocationAPI,
onDeleteLocationAPI,
deleteLoading, deleteLoading,
loading, loading,
createLoading, createLoading,

View file

@ -18,7 +18,13 @@ import { PrivateLocation } from '../../../../../../common/runtime_types';
import { AgentPolicyNeeded } from './agent_policy_needed'; import { AgentPolicyNeeded } from './agent_policy_needed';
import { PolicyHostsField } from './policy_hosts'; 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 { data } = useSelector(selectAgentPolicies);
const { control, register } = useFormContext<PrivateLocation>(); const { control, register } = useFormContext<PrivateLocation>();
const { errors } = useFormState(); const { errors } = useFormState();
@ -28,6 +34,8 @@ export const LocationForm = ({ privateLocations }: { privateLocations: PrivateLo
return [...acc, ...tags]; return [...acc, ...tags];
}, [] as string[]); }, [] as string[]);
const isEditingLocation = privateLocationToEdit !== undefined;
return ( return (
<> <>
{data?.length === 0 && <AgentPolicyNeeded />} {data?.length === 0 && <AgentPolicyNeeded />}
@ -48,7 +56,8 @@ export const LocationForm = ({ privateLocations }: { privateLocations: PrivateLo
message: NAME_REQUIRED, message: NAME_REQUIRED,
}, },
validate: (val: string) => { validate: (val: string) => {
return privateLocations.some((loc) => loc.label === val) return privateLocations.some((loc) => loc.label === val) &&
val !== privateLocationToEdit?.label
? NAME_ALREADY_EXISTS ? NAME_ALREADY_EXISTS
: undefined; : undefined;
}, },
@ -56,13 +65,13 @@ export const LocationForm = ({ privateLocations }: { privateLocations: PrivateLo
/> />
</EuiFormRow> </EuiFormRow>
<EuiSpacer /> <EuiSpacer />
<PolicyHostsField privateLocations={privateLocations} /> <PolicyHostsField privateLocations={privateLocations} isDisabled={isEditingLocation} />
<EuiSpacer /> <EuiSpacer />
<TagsField tagsList={tagsList} control={control} errors={errors} /> <TagsField tagsList={tagsList} control={control} errors={errors} />
<EuiSpacer /> <EuiSpacer />
<BrowserMonitorCallout /> <BrowserMonitorCallout />
<EuiSpacer /> <EuiSpacer />
<SpaceSelector helpText={LOCATION_HELP_TEXT} /> <SpaceSelector helpText={LOCATION_HELP_TEXT} isDisabled={isEditingLocation} />
</EuiForm> </EuiForm>
</> </>
); );

View file

@ -8,6 +8,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
EuiBadge, EuiBadge,
EuiBasicTableColumn,
EuiButton, EuiButton,
EuiFlexGroup, EuiFlexGroup,
EuiFlexItem, EuiFlexItem,
@ -31,7 +32,7 @@ import { DeleteLocation } from './delete_location';
import { useLocationMonitors } from './hooks/use_location_monitors'; import { useLocationMonitors } from './hooks/use_location_monitors';
import { PolicyName } from './policy_name'; import { PolicyName } from './policy_name';
import { LOCATION_NAME_LABEL } from './location_form'; 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'; import { ClientPluginsStart } from '../../../../../plugin';
interface ListItem extends PrivateLocation { interface ListItem extends PrivateLocation {
@ -41,10 +42,12 @@ interface ListItem extends PrivateLocation {
export const PrivateLocationsTable = ({ export const PrivateLocationsTable = ({
deleteLoading, deleteLoading,
onDelete, onDelete,
onEdit,
privateLocations, privateLocations,
}: { }: {
deleteLoading?: boolean; deleteLoading?: boolean;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onEdit: (privateLocation: PrivateLocation) => void;
privateLocations: PrivateLocation[]; privateLocations: PrivateLocation[];
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -65,7 +68,7 @@ export const PrivateLocationsTable = ({
return new Set([...acc, ...tags]); return new Set([...acc, ...tags]);
}, new Set<string>()); }, new Set<string>());
const columns = [ const columns: Array<EuiBasicTableColumn<ListItem>> = [
{ {
field: 'label', field: 'label',
name: LOCATION_NAME_LABEL, name: LOCATION_NAME_LABEL,
@ -114,6 +117,15 @@ export const PrivateLocationsTable = ({
{ {
name: ACTIONS_LABEL, name: ACTIONS_LABEL,
actions: [ actions: [
{
name: EDIT_LOCATION,
description: EDIT_LOCATION,
isPrimary: true,
'data-test-subj': 'action-edit',
onClick: onEdit,
icon: 'pencil',
type: 'icon',
},
{ {
name: DELETE_LOCATION, name: DELETE_LOCATION,
description: DELETE_LOCATION, description: DELETE_LOCATION,
@ -138,7 +150,7 @@ export const PrivateLocationsTable = ({
monitors: locationMonitors?.find((l) => l.id === location.id)?.count ?? 0, monitors: locationMonitors?.find((l) => l.id === location.id)?.count ?? 0,
})); }));
const setIsAddingNew = (val: boolean) => dispatch(setIsCreatePrivateLocationFlyoutVisible(val)); const openFlyout = () => dispatch(setIsPrivateLocationFlyoutVisible(true));
const renderToolRight = () => { const renderToolRight = () => {
return [ return [
@ -152,7 +164,7 @@ export const PrivateLocationsTable = ({
data-test-subj={'addPrivateLocationButton'} data-test-subj={'addPrivateLocationButton'}
isLoading={loading} isLoading={loading}
disabled={!canSave || !canManagePrivateLocations} disabled={!canSave || !canManagePrivateLocations}
onClick={() => setIsAddingNew(true)} onClick={openFlyout}
iconType="plusInCircle" iconType="plusInCircle"
> >
{ADD_LABEL} {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', { const ADD_LABEL = i18n.translate('xpack.synthetics.monitorManagement.createLocation', {
defaultMessage: 'Create location', defaultMessage: 'Create location',
}); });

View file

@ -15,14 +15,14 @@ import { selectAgentPolicies } from '../../../state/agent_policies';
export const ManageEmptyState: FC< export const ManageEmptyState: FC<
PropsWithChildren<{ PropsWithChildren<{
privateLocations: PrivateLocation[]; privateLocations: PrivateLocation[];
setIsAddingNew?: (val: boolean) => void; setIsFlyoutOpen?: (val: boolean) => void;
showNeedAgentPolicy?: boolean; showNeedAgentPolicy?: boolean;
showEmptyLocations?: boolean; showEmptyLocations?: boolean;
}> }>
> = ({ > = ({
children, children,
privateLocations, privateLocations,
setIsAddingNew, setIsFlyoutOpen,
showNeedAgentPolicy = true, showNeedAgentPolicy = true,
showEmptyLocations = true, showEmptyLocations = true,
}) => { }) => {
@ -33,7 +33,7 @@ export const ManageEmptyState: FC<
} }
if (privateLocations.length === 0 && showEmptyLocations) { if (privateLocations.length === 0 && showEmptyLocations) {
return <EmptyLocations setIsAddingNew={setIsAddingNew} />; return <EmptyLocations setIsFlyoutOpen={setIsFlyoutOpen} />;
} }
return <>{children}</>; return <>{children}</>;

View file

@ -28,9 +28,10 @@ describe('<ManagePrivateLocations />', () => {
}); });
jest.spyOn(locationHooks, 'usePrivateLocationsAPI').mockReturnValue({ jest.spyOn(locationHooks, 'usePrivateLocationsAPI').mockReturnValue({
loading: false, loading: false,
onSubmit: jest.fn(), onCreateLocationAPI: jest.fn(),
onEditLocationAPI: jest.fn(),
privateLocations: [], privateLocations: [],
onDelete: jest.fn(), onDeleteLocationAPI: jest.fn(),
deleteLoading: false, deleteLoading: false,
createLoading: false, createLoading: false,
}); });
@ -60,7 +61,7 @@ describe('<ManagePrivateLocations />', () => {
error: null, error: null,
}, },
privateLocations: { privateLocations: {
isCreatePrivateLocationFlyoutVisible: false, isPrivateLocationFlyoutVisible: false,
}, },
}, },
}); });
@ -95,7 +96,7 @@ describe('<ManagePrivateLocations />', () => {
error: null, error: null,
}, },
privateLocations: { privateLocations: {
isCreatePrivateLocationFlyoutVisible: false, isPrivateLocationFlyoutVisible: false,
}, },
}, },
}); });
@ -125,7 +126,8 @@ describe('<ManagePrivateLocations />', () => {
jest.spyOn(locationHooks, 'usePrivateLocationsAPI').mockReturnValue({ jest.spyOn(locationHooks, 'usePrivateLocationsAPI').mockReturnValue({
loading: false, loading: false,
onSubmit: jest.fn(), onCreateLocationAPI: jest.fn(),
onEditLocationAPI: jest.fn(),
privateLocations: [ privateLocations: [
{ {
label: privateLocationName, label: privateLocationName,
@ -134,7 +136,7 @@ describe('<ManagePrivateLocations />', () => {
isServiceManaged: false, isServiceManaged: false,
}, },
], ],
onDelete: jest.fn(), onDeleteLocationAPI: jest.fn(),
deleteLoading: false, deleteLoading: false,
createLoading: false, createLoading: false,
}); });
@ -146,7 +148,7 @@ describe('<ManagePrivateLocations />', () => {
error: null, error: null,
}, },
privateLocations: { privateLocations: {
isCreatePrivateLocationFlyoutVisible: false, isPrivateLocationFlyoutVisible: false,
}, },
}, },
}); });

View file

@ -8,15 +8,23 @@ import React, { useEffect, useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public';
import { SpacesContextProps } from '@kbn/spaces-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 { LoadingState } from '../../monitors_page/overview/overview/monitor_detail_flyout';
import { PrivateLocationsTable } from './locations_table'; import { PrivateLocationsTable } from './locations_table';
import { ManageEmptyState } from './manage_empty_state'; 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 { 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 { getServiceLocations } from '../../../state';
import { getAgentPoliciesAction } from '../../../state/agent_policies'; 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'; import { ClientPluginsStart } from '../../../../../plugin';
const getEmptyFunctionComponent: React.FC<SpacesContextProps> = ({ children }) => <>{children}</>; const getEmptyFunctionComponent: React.FC<SpacesContextProps> = ({ children }) => <>{children}</>;
@ -33,23 +41,53 @@ export const ManagePrivateLocations = () => {
[spacesApi] [spacesApi]
); );
const isAddingNew = useSelector(selectAddingNewPrivateLocation); const isPrivateLocationFlyoutVisible = useSelector(selectPrivateLocationFlyoutVisible);
const setIsAddingNew = useCallback( const privateLocationToEdit = useSelector(selectPrivateLocationToEdit);
(val: boolean) => dispatch(setIsCreatePrivateLocationFlyoutVisible(val)), const setIsFlyoutOpen = useCallback(
(val: boolean) => dispatch(setIsPrivateLocationFlyoutVisible(val)),
[dispatch] [dispatch]
); );
const { onSubmit, loading, privateLocations, onDelete, deleteLoading } = usePrivateLocationsAPI(); const {
onCreateLocationAPI,
onEditLocationAPI,
loading,
privateLocations,
onDeleteLocationAPI,
deleteLoading,
} = usePrivateLocationsAPI();
useEffect(() => { useEffect(() => {
dispatch(getAgentPoliciesAction.get()); dispatch(getAgentPoliciesAction.get());
dispatch(getServiceLocations()); dispatch(getServiceLocations());
// make sure flyout is closed when first visiting the page // make sure flyout is closed when first visiting the page
dispatch(setIsCreatePrivateLocationFlyoutVisible(false)); dispatch(setIsPrivateLocationFlyoutVisible(false));
}, [dispatch]); }, [dispatch]);
const handleSubmit = (formData: NewLocation) => { 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 ( return (
@ -57,20 +95,22 @@ export const ManagePrivateLocations = () => {
{loading ? ( {loading ? (
<LoadingState /> <LoadingState />
) : ( ) : (
<ManageEmptyState privateLocations={privateLocations} setIsAddingNew={setIsAddingNew}> <ManageEmptyState privateLocations={privateLocations} setIsFlyoutOpen={setIsFlyoutOpen}>
<PrivateLocationsTable <PrivateLocationsTable
privateLocations={privateLocations} privateLocations={privateLocations}
onDelete={onDelete} onDelete={onDeleteLocationAPI}
onEdit={onEditLocation}
deleteLoading={deleteLoading} deleteLoading={deleteLoading}
/> />
</ManageEmptyState> </ManageEmptyState>
)} )}
{isAddingNew ? ( {isPrivateLocationFlyoutVisible ? (
<AddLocationFlyout <AddOrEditLocationFlyout
setIsOpen={setIsAddingNew} onCloseFlyout={onCloseFlyout}
onSubmit={handleSubmit} onSubmit={handleSubmit}
privateLocations={privateLocations} privateLocations={privateLocations}
privateLocationToEdit={privateLocationToEdit}
/> />
) : null} ) : null}
</SpacesContextProvider> </SpacesContextProvider>

View file

@ -29,7 +29,13 @@ import { selectAgentPolicies } from '../../../state/agent_policies';
export const AGENT_POLICY_FIELD_NAME = 'agentPolicyId'; 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 { data } = useSelector(selectAgentPolicies);
const { basePath } = useSyntheticsSettingsContext(); const { basePath } = useSyntheticsSettingsContext();
@ -53,7 +59,7 @@ export const PolicyHostsField = ({ privateLocations }: { privateLocations: Priva
inputDisplay: ( inputDisplay: (
<EuiHealth <EuiHealth
color={item.status === 'active' ? 'success' : 'warning'} color={item.status === 'active' ? 'success' : 'warning'}
style={{ lineHeight: 'inherit' }} css={{ lineHeight: 'inherit' }}
> >
{item.name} {item.name}
</EuiHealth> </EuiHealth>
@ -74,7 +80,7 @@ export const PolicyHostsField = ({ privateLocations }: { privateLocations: Priva
<> <>
<EuiHealth <EuiHealth
color={item.status === 'active' ? 'success' : 'warning'} color={item.status === 'active' ? 'success' : 'warning'}
style={{ lineHeight: 'inherit' }} css={{ lineHeight: 'inherit' }}
> >
<strong>{item.name}</strong> <strong>{item.name}</strong>
</EuiHealth> </EuiHealth>
@ -124,6 +130,7 @@ export const PolicyHostsField = ({ privateLocations }: { privateLocations: Priva
rules={{ required: true }} rules={{ required: true }}
render={({ field }) => ( render={({ field }) => (
<SuperSelect <SuperSelect
disabled={isDisabled}
fullWidth fullWidth
aria-label={SELECT_POLICY_HOSTS} aria-label={SELECT_POLICY_HOSTS}
placeholder={SELECT_POLICY_HOSTS} placeholder={SELECT_POLICY_HOSTS}

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import { NewLocation } from '../../components/settings/private_locations/add_location_flyout'; import { NewLocation } from '../../components/settings/private_locations/add_or_edit_location_flyout';
import { AgentPolicyInfo } from '../../../../../common/types'; import { AgentPolicyInfo } from '../../../../../common/types';
import { INITIAL_REST_VERSION, SYNTHETICS_API_URLS } from '../../../../../common/constants'; import { INITIAL_REST_VERSION, SYNTHETICS_API_URLS } from '../../../../../common/constants';
import { SyntheticsPrivateLocations } from '../../../../../common/runtime_types'; import { SyntheticsPrivateLocations } from '../../../../../common/runtime_types';

View file

@ -6,9 +6,10 @@
*/ */
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import { NewLocation } from '../../components/settings/private_locations/add_location_flyout'; import { NewLocation } from '../../components/settings/private_locations/add_or_edit_location_flyout';
import { PrivateLocation, SyntheticsPrivateLocations } from '../../../../../common/runtime_types'; import { PrivateLocation, SyntheticsPrivateLocations } from '../../../../../common/runtime_types';
import { createAsyncAction } from '../utils/actions'; import { createAsyncAction } from '../utils/actions';
import type { EditPrivateLocationAttributes } from '../../../../../server/routes/settings/private_locations/edit_private_location';
export const getPrivateLocationsAction = createAsyncAction<void, SyntheticsPrivateLocations>( export const getPrivateLocationsAction = createAsyncAction<void, SyntheticsPrivateLocations>(
'[PRIVATE LOCATIONS] GET' '[PRIVATE LOCATIONS] GET'
@ -18,12 +19,24 @@ export const createPrivateLocationAction = createAsyncAction<NewLocation, Privat
'CREATE PRIVATE LOCATION' 'CREATE PRIVATE LOCATION'
); );
export const editPrivateLocationAction = createAsyncAction<
{
locationId: string;
newAttributes: EditPrivateLocationAttributes;
},
PrivateLocation
>('EDIT PRIVATE LOCATION');
export const deletePrivateLocationAction = createAsyncAction<string, SyntheticsPrivateLocations>( export const deletePrivateLocationAction = createAsyncAction<string, SyntheticsPrivateLocations>(
'DELETE PRIVATE LOCATION' 'DELETE PRIVATE LOCATION'
); );
export const setManageFlyoutOpen = createAction<boolean>('SET MANAGE FLYOUT OPEN'); export const setManageFlyoutOpen = createAction<boolean>('SET MANAGE FLYOUT OPEN');
export const setIsCreatePrivateLocationFlyoutVisible = createAction<boolean>( export const setIsPrivateLocationFlyoutVisible = createAction<boolean>(
'SET IS CREATE PRIVATE LOCATION FLYOUT VISIBLE' 'SET IS CREATE OR EDIT PRIVATE LOCATION FLYOUT VISIBLE'
);
export const setPrivateLocationToEdit = createAction<PrivateLocation | undefined>(
'SET PRIVATE LOCATION TO EDIT'
); );

View file

@ -5,7 +5,8 @@
* 2.0. * 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 { AgentPolicyInfo } from '../../../../../common/types';
import { INITIAL_REST_VERSION, SYNTHETICS_API_URLS } from '../../../../../common/constants'; import { INITIAL_REST_VERSION, SYNTHETICS_API_URLS } from '../../../../../common/constants';
import { PrivateLocation, SyntheticsPrivateLocations } from '../../../../../common/runtime_types'; 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<PrivateLocation> => {
return apiService.put(
`${SYNTHETICS_API_URLS.PRIVATE_LOCATIONS}/${locationId}`,
newAttributes,
undefined,
{
version: INITIAL_REST_VERSION,
}
);
};
export const getSyntheticsPrivateLocations = async (): Promise<SyntheticsPrivateLocations> => { export const getSyntheticsPrivateLocations = async (): Promise<SyntheticsPrivateLocations> => {
return await apiService.get(SYNTHETICS_API_URLS.PRIVATE_LOCATIONS, { return await apiService.get(SYNTHETICS_API_URLS.PRIVATE_LOCATIONS, {
version: INITIAL_REST_VERSION, version: INITIAL_REST_VERSION,

View file

@ -11,11 +11,13 @@ import { fetchEffectFactory } from '../utils/fetch_effect';
import { import {
createSyntheticsPrivateLocation, createSyntheticsPrivateLocation,
deleteSyntheticsPrivateLocation, deleteSyntheticsPrivateLocation,
editSyntheticsPrivateLocation,
getSyntheticsPrivateLocations, getSyntheticsPrivateLocations,
} from './api'; } from './api';
import { import {
createPrivateLocationAction, createPrivateLocationAction,
deletePrivateLocationAction, deletePrivateLocationAction,
editPrivateLocationAction,
getPrivateLocationsAction, getPrivateLocationsAction,
} from './actions'; } 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() { export function* deletePrivateLocationEffect() {
yield takeLeading( yield takeLeading(
deletePrivateLocationAction.get, deletePrivateLocationAction.get,
@ -61,5 +80,6 @@ export function* deletePrivateLocationEffect() {
export const privateLocationsEffects = [ export const privateLocationsEffects = [
fetchPrivateLocationsEffect, fetchPrivateLocationsEffect,
createPrivateLocationEffect, createPrivateLocationEffect,
editPrivateLocationEffect,
deletePrivateLocationEffect, deletePrivateLocationEffect,
]; ];

View file

@ -7,19 +7,26 @@
import { createReducer } from '@reduxjs/toolkit'; import { createReducer } from '@reduxjs/toolkit';
import { PrivateLocation, SyntheticsPrivateLocations } from '../../../../../common/runtime_types'; import { PrivateLocation, SyntheticsPrivateLocations } from '../../../../../common/runtime_types';
import { createPrivateLocationAction, deletePrivateLocationAction } from './actions'; import {
import { setIsCreatePrivateLocationFlyoutVisible, getPrivateLocationsAction } from './actions'; createPrivateLocationAction,
deletePrivateLocationAction,
editPrivateLocationAction,
setPrivateLocationToEdit,
} from './actions';
import { setIsPrivateLocationFlyoutVisible, getPrivateLocationsAction } from './actions';
import { IHttpSerializedFetchError } from '../utils/http_error'; import { IHttpSerializedFetchError } from '../utils/http_error';
export interface PrivateLocationsState { export interface PrivateLocationsState {
data?: SyntheticsPrivateLocations | null; data?: SyntheticsPrivateLocations | null;
loading: boolean; loading: boolean;
createLoading?: boolean; createLoading?: boolean;
editLoading?: boolean;
deleteLoading?: boolean; deleteLoading?: boolean;
error: IHttpSerializedFetchError | null; error: IHttpSerializedFetchError | null;
isManageFlyoutOpen?: boolean; isManageFlyoutOpen?: boolean;
isCreatePrivateLocationFlyoutVisible?: boolean; isPrivateLocationFlyoutVisible?: boolean;
newLocation?: PrivateLocation; newLocation?: PrivateLocation;
privateLocationToEdit?: PrivateLocation;
} }
const initialState: PrivateLocationsState = { const initialState: PrivateLocationsState = {
@ -27,8 +34,10 @@ const initialState: PrivateLocationsState = {
loading: false, loading: false,
error: null, error: null,
isManageFlyoutOpen: false, isManageFlyoutOpen: false,
isCreatePrivateLocationFlyoutVisible: false, isPrivateLocationFlyoutVisible: false,
createLoading: false, createLoading: false,
editLoading: false,
privateLocationToEdit: undefined,
}; };
export const privateLocationsStateReducer = createReducer(initialState, (builder) => { export const privateLocationsStateReducer = createReducer(initialState, (builder) => {
@ -51,12 +60,26 @@ export const privateLocationsStateReducer = createReducer(initialState, (builder
state.newLocation = action.payload; state.newLocation = action.payload;
state.createLoading = false; state.createLoading = false;
state.data = null; state.data = null;
state.isCreatePrivateLocationFlyoutVisible = false; state.isPrivateLocationFlyoutVisible = false;
}) })
.addCase(createPrivateLocationAction.fail, (state, action) => { .addCase(createPrivateLocationAction.fail, (state, action) => {
state.error = action.payload; state.error = action.payload;
state.createLoading = false; 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) => { .addCase(deletePrivateLocationAction.get, (state) => {
state.deleteLoading = true; state.deleteLoading = true;
}) })
@ -68,7 +91,10 @@ export const privateLocationsStateReducer = createReducer(initialState, (builder
state.error = action.payload; state.error = action.payload;
state.deleteLoading = false; state.deleteLoading = false;
}) })
.addCase(setIsCreatePrivateLocationFlyoutVisible, (state, action) => { .addCase(setIsPrivateLocationFlyoutVisible, (state, action) => {
state.isCreatePrivateLocationFlyoutVisible = action.payload; state.isPrivateLocationFlyoutVisible = action.payload;
})
.addCase(setPrivateLocationToEdit, (state, action) => {
state.privateLocationToEdit = action.payload;
}); });
}); });

View file

@ -11,8 +11,11 @@ import { AppState } from '..';
const getState = (appState: AppState) => appState.privateLocations; const getState = (appState: AppState) => appState.privateLocations;
export const selectAgentPolicies = createSelector(getState, (state) => state); export const selectAgentPolicies = createSelector(getState, (state) => state);
export const selectAddingNewPrivateLocation = (state: AppState) => export const selectPrivateLocationFlyoutVisible = (state: AppState) =>
state.privateLocations.isCreatePrivateLocationFlyoutVisible ?? false; state.privateLocations.isPrivateLocationFlyoutVisible ?? false;
export const selectPrivateLocationToEdit = (state: AppState) =>
state.privateLocations.privateLocationToEdit;
export const selectPrivateLocationsLoading = (state: AppState) => export const selectPrivateLocationsLoading = (state: AppState) =>
state.privateLocations.loading ?? false; state.privateLocations.loading ?? false;

View file

@ -14,7 +14,7 @@ const getState = (appState: AppState) => appState.agentPolicies;
export const selectAgentPolicies = createSelector(getState, (state) => state); export const selectAgentPolicies = createSelector(getState, (state) => state);
export const selectAddingNewPrivateLocation = (state: AppState) => export const selectAddingNewPrivateLocation = (state: AppState) =>
state.privateLocations.isCreatePrivateLocationFlyoutVisible ?? false; state.privateLocations.isPrivateLocationFlyoutVisible ?? false;
export const selectLocationMonitors = (state: AppState) => ({ export const selectLocationMonitors = (state: AppState) => ({
locationMonitors: state.dynamicSettings.locationMonitors, locationMonitors: state.dynamicSettings.locationMonitors,

View file

@ -116,7 +116,7 @@ export const mockState: SyntheticsAppState = {
data: null, data: null,
}, },
privateLocations: { privateLocations: {
isCreatePrivateLocationFlyoutVisible: false, isPrivateLocationFlyoutVisible: false,
loading: false, loading: false,
error: null, error: null,
data: [], data: [],

View file

@ -12,6 +12,7 @@ import { PrivateLocationAttributes } from '../runtime_types/private_locations';
import { PrivateLocationObject } from '../routes/settings/private_locations/add_private_location'; import { PrivateLocationObject } from '../routes/settings/private_locations/add_private_location';
import { RouteContext } from '../routes/types'; import { RouteContext } from '../routes/types';
import { privateLocationSavedObjectName } from '../../common/saved_objects/private_locations'; import { privateLocationSavedObjectName } from '../../common/saved_objects/private_locations';
import { EditPrivateLocationAttributes } from '../routes/settings/private_locations/edit_private_location';
export class PrivateLocationRepository { export class PrivateLocationRepository {
internalSOClient: ISavedObjectsRepository; internalSOClient: ISavedObjectsRepository;
@ -33,6 +34,26 @@ export class PrivateLocationRepository {
} }
); );
} }
async getPrivateLocation(locationId: string) {
const { savedObjectsClient } = this.routeContext;
return savedObjectsClient.get<PrivateLocationAttributes>(
privateLocationSavedObjectName,
locationId
);
}
async editPrivateLocation(locationId: string, newAttributes: EditPrivateLocationAttributes) {
const { savedObjectsClient } = this.routeContext;
return savedObjectsClient.update<PrivateLocationAttributes>(
privateLocationSavedObjectName,
locationId,
newAttributes
);
}
async validatePrivateLocation() { async validatePrivateLocation() {
const { response, request, server } = this.routeContext; const { response, request, server } = this.routeContext;

View file

@ -55,6 +55,7 @@ import { getDefaultAlertingRoute } from './default_alerts/get_default_alert';
import { createNetworkEventsRoute } from './network_events'; import { createNetworkEventsRoute } from './network_events';
import { addPrivateLocationRoute } from './settings/private_locations/add_private_location'; import { addPrivateLocationRoute } from './settings/private_locations/add_private_location';
import { deletePrivateLocationRoute } from './settings/private_locations/delete_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 { getPrivateLocationsRoute } from './settings/private_locations/get_private_locations';
import { getSyntheticsFilters } from './filters/filters'; import { getSyntheticsFilters } from './filters/filters';
import { getAllSyntheticsMonitorRoute } from './monitor_cruds/get_monitors_list'; import { getAllSyntheticsMonitorRoute } from './monitor_cruds/get_monitors_list';
@ -112,6 +113,7 @@ export const syntheticsAppPublicRestApiRoutes: SyntheticsRestApiRouteFactory[] =
deleteSyntheticsParamsRoute, deleteSyntheticsParamsRoute,
addPrivateLocationRoute, addPrivateLocationRoute,
deletePrivateLocationRoute, deletePrivateLocationRoute,
editPrivateLocationRoute,
getPrivateLocationsRoute, getPrivateLocationsRoute,
getAllSyntheticsMonitorRoute, getAllSyntheticsMonitorRoute,
getSyntheticsMonitorRoute, getSyntheticsMonitorRoute,

View file

@ -83,7 +83,10 @@ export const syncEditedMonitorBulk = async ({
} as unknown as MonitorFields, } as unknown as MonitorFields,
})); }));
const [editedMonitorSavedObjects, editSyncResponse] = await Promise.all([ 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 }), syncUpdatedMonitors({ monitorsToUpdate, routeContext, spaceId, privateLocations }),
]); ]);

View file

@ -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<typeof EditPrivateLocationSchema>
>;
const isPrivateLocationLabelChanged = (oldLabel: string, newLabel?: string): newLabel is string => {
return typeof newLabel === 'string' && oldLabel !== newLabel;
};
const isPrivateLocationChanged = ({
privateLocation,
newParams,
}: {
privateLocation: SavedObject<PrivateLocationAttributes>;
newParams: TypeOf<typeof EditPrivateLocationSchema>;
}) => {
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<typeof EditPrivateLocationQuery>,
any,
TypeOf<typeof EditPrivateLocationSchema>
> = () => ({
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<ReturnType<typeof repo.editPrivateLocation>> | 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;
}
},
});

View file

@ -5,7 +5,20 @@
* 2.0. * 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 = { const testLocations = {
locations: [ 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,
});
});
});

View file

@ -4,14 +4,24 @@
* 2.0; you may not use this file except in compliance with the Elastic License * 2.0; you may not use this file except in compliance with the Elastic License
* 2.0. * 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 { AgentPolicyInfo } from '../../../../common/types';
import type { SyntheticsPrivateLocations } from '../../../../common/runtime_types'; import type {
SyntheticsMonitor,
SyntheticsMonitorWithSecretsAttributes,
SyntheticsPrivateLocations,
} from '../../../../common/runtime_types';
import type { import type {
SyntheticsPrivateLocationsAttributes, SyntheticsPrivateLocationsAttributes,
PrivateLocationAttributes, PrivateLocationAttributes,
} from '../../../runtime_types/private_locations'; } from '../../../runtime_types/private_locations';
import { PrivateLocation } from '../../../../common/runtime_types'; import { PrivateLocation } from '../../../../common/runtime_types';
import {
MonitorConfigUpdate,
syncEditedMonitorBulk,
} from '../../monitor_cruds/bulk_cruds/edit_monitor_bulk';
import { RouteContext } from '../../types';
export const toClientContract = ( export const toClientContract = (
locationObject: SavedObject<PrivateLocationAttributes> locationObject: SavedObject<PrivateLocationAttributes>
@ -60,3 +70,54 @@ export const toSavedObjectContract = (location: PrivateLocation): PrivateLocatio
spaces: location.spaces, 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<SavedObjectsFindResult<SyntheticsMonitorWithSecretsAttributes>>;
}) => {
const updatedMonitorsPerSpace = monitorsInLocation.reduce<Record<string, MonitorConfigUpdate[]>>(
(acc, m) => {
const decryptedMonitorsWithNormalizedSecrets: SavedObject<SyntheticsMonitor> =
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());
};

View file

@ -82,17 +82,20 @@ export class MonitorConfigRepository {
async bulkUpdate({ async bulkUpdate({
monitors, monitors,
namespace,
}: { }: {
monitors: Array<{ monitors: Array<{
attributes: MonitorFields; attributes: MonitorFields;
id: string; id: string;
}>; }>;
namespace?: string;
}) { }) {
return await this.soClient.bulkUpdate<MonitorFields>( return this.soClient.bulkUpdate<MonitorFields>(
monitors.map(({ attributes, id }) => ({ monitors.map(({ attributes, id }) => ({
type: syntheticsMonitorType, type: syntheticsMonitorType,
id, id,
attributes, attributes,
namespace,
})) }))
); );
} }

View file

@ -42,7 +42,7 @@ export function normalizeMonitorSecretAttributes(
const normalizedMonitorAttributes = { const normalizedMonitorAttributes = {
...defaultFields, ...defaultFields,
...monitor, ...monitor,
...JSON.parse(monitor.secrets || ''), ...JSON.parse(monitor.secrets || '{}'),
}; };
delete normalizedMonitorAttributes.secrets; delete normalizedMonitorAttributes.secrets;
return normalizedMonitorAttributes; return normalizedMonitorAttributes;

View file

@ -27,7 +27,7 @@ export const GETTING_STARTED_ROUTE = '/monitors/getting-started';
export const SETTINGS_ROUTE = '/settings'; 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'; export const SYNTHETICS_SETTINGS_ROUTE = '/settings/:tabId';

View file

@ -39,7 +39,10 @@ export default function ({ getService }: FtrProviderContext) {
let resp; let resp;
const { statusCodes, SPACE_ID, username, password, writeAccess, readUser } = options; const { statusCodes, SPACE_ID, username, password, writeAccess, readUser } = options;
let tags = !writeAccess ? '[uptime-read]' : options.tags ?? '[uptime-read,uptime-write]'; 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 tags = readUser
? '[private-location-write,uptime-write]' ? '[private-location-write,uptime-write]'
: '[uptime-read,private-location-write,uptime-write]'; : '[uptime-read,private-location-write,uptime-write]';

View file

@ -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<PrivateLocation | ServiceLocation> = [
{
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].'
);
});
});
}

View file

@ -31,5 +31,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext)
loadTestFile(require.resolve('./sync_global_params')); loadTestFile(require.resolve('./sync_global_params'));
loadTestFile(require.resolve('./synthetics_enablement')); loadTestFile(require.resolve('./synthetics_enablement'));
loadTestFile(require.resolve('./test_now_monitor')); loadTestFile(require.resolve('./test_now_monitor'));
loadTestFile(require.resolve('./edit_private_location'));
}); });
} }