[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
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: |

View file

@ -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';

View file

@ -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:

View file

@ -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');

View file

@ -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,
},
},
}

View file

@ -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}
</EuiButton>
@ -173,9 +176,9 @@ export const GettingStartedOnPrem = () => {
footer={<GettingStartedLink />}
/>
{isAddingNewLocation ? (
<AddLocationFlyout
setIsOpen={setIsAddingNewLocation}
{isPrivateLocationFlyoutVisible ? (
<AddOrEditLocationFlyout
onCloseFlyout={() => setIsFlyoutOpen(false)}
onSubmit={handleSubmit}
privateLocations={privateLocations}
/>

View file

@ -16,9 +16,13 @@ import { ClientPluginsStart } from '../../../../../plugin';
interface SpaceSelectorProps {
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 { services } = useKibana<ClientPluginsStart>();
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 }}
render={({ field }) => (
<EuiComboBox
isDisabled={isDisabled}
fullWidth
aria-label={SPACES_LABEL}
placeholder={SPACES_LABEL}

View file

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

View file

@ -35,20 +35,22 @@ import { selectPrivateLocationsState } from '../../../state/private_locations/se
export type NewLocation = Omit<PrivateLocation, 'id'>;
const getEmptyFunctionComponent: React.FC<SpacesContextProps> = ({ 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<ClientPluginsStart>().services;
@ -72,33 +74,35 @@ export const AddLocationFlyout = ({
);
const { handleSubmit } = form;
const closeFlyout = () => {
setIsOpen(false);
};
return (
<ContextWrapper>
<FormProvider {...form}>
<EuiFlyout onClose={closeFlyout} style={{ width: 540 }}>
<EuiFlyout onClose={onCloseFlyout} css={{ width: 540 }}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{ADD_PRIVATE_LOCATION}</h2>
<h2>
{privateLocationToEdit !== undefined ? EDIT_PRIVATE_LOCATION : ADD_PRIVATE_LOCATION}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<ManageEmptyState privateLocations={privateLocations} showEmptyLocations={false}>
<LocationForm privateLocations={privateLocations} />
<LocationForm
privateLocations={privateLocations}
privateLocationToEdit={privateLocationToEdit}
/>
</ManageEmptyState>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="syntheticsAddLocationFlyoutButton"
data-test-subj="syntheticsLocationFlyoutCancelButton"
iconType="cross"
onClick={closeFlyout}
onClick={onCloseFlyout}
flush="left"
isLoading={createLoading}
isLoading={createLoading || editLoading}
>
{CANCEL_LABEL}
</EuiButtonEmpty>
@ -106,10 +110,10 @@ export const AddLocationFlyout = ({
<EuiFlexItem grow={false}>
<NoPermissionsTooltip canEditSynthetics={canSave}>
<EuiButton
data-test-subj="syntheticsAddLocationFlyoutButton"
data-test-subj="syntheticsLocationFlyoutSaveButton"
fill
onClick={handleSubmit(onSubmit)}
isLoading={createLoading}
isLoading={createLoading || editLoading}
isDisabled={!canSave || !canManagePrivateLocations}
>
{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',
});

View file

@ -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}

View file

@ -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,

View file

@ -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<PrivateLocation>();
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 && <AgentPolicyNeeded />}
@ -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
/>
</EuiFormRow>
<EuiSpacer />
<PolicyHostsField privateLocations={privateLocations} />
<PolicyHostsField privateLocations={privateLocations} isDisabled={isEditingLocation} />
<EuiSpacer />
<TagsField tagsList={tagsList} control={control} errors={errors} />
<EuiSpacer />
<BrowserMonitorCallout />
<EuiSpacer />
<SpaceSelector helpText={LOCATION_HELP_TEXT} />
<SpaceSelector helpText={LOCATION_HELP_TEXT} isDisabled={isEditingLocation} />
</EuiForm>
</>
);

View file

@ -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<string>());
const columns = [
const columns: Array<EuiBasicTableColumn<ListItem>> = [
{
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',
});

View file

@ -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 <EmptyLocations setIsAddingNew={setIsAddingNew} />;
return <EmptyLocations setIsFlyoutOpen={setIsFlyoutOpen} />;
}
return <>{children}</>;

View file

@ -28,9 +28,10 @@ describe('<ManagePrivateLocations />', () => {
});
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('<ManagePrivateLocations />', () => {
error: null,
},
privateLocations: {
isCreatePrivateLocationFlyoutVisible: false,
isPrivateLocationFlyoutVisible: false,
},
},
});
@ -95,7 +96,7 @@ describe('<ManagePrivateLocations />', () => {
error: null,
},
privateLocations: {
isCreatePrivateLocationFlyoutVisible: false,
isPrivateLocationFlyoutVisible: false,
},
},
});
@ -125,7 +126,8 @@ describe('<ManagePrivateLocations />', () => {
jest.spyOn(locationHooks, 'usePrivateLocationsAPI').mockReturnValue({
loading: false,
onSubmit: jest.fn(),
onCreateLocationAPI: jest.fn(),
onEditLocationAPI: jest.fn(),
privateLocations: [
{
label: privateLocationName,
@ -134,7 +136,7 @@ describe('<ManagePrivateLocations />', () => {
isServiceManaged: false,
},
],
onDelete: jest.fn(),
onDeleteLocationAPI: jest.fn(),
deleteLoading: false,
createLoading: false,
});
@ -146,7 +148,7 @@ describe('<ManagePrivateLocations />', () => {
error: null,
},
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 { 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<SpacesContextProps> = ({ 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 ? (
<LoadingState />
) : (
<ManageEmptyState privateLocations={privateLocations} setIsAddingNew={setIsAddingNew}>
<ManageEmptyState privateLocations={privateLocations} setIsFlyoutOpen={setIsFlyoutOpen}>
<PrivateLocationsTable
privateLocations={privateLocations}
onDelete={onDelete}
onDelete={onDeleteLocationAPI}
onEdit={onEditLocation}
deleteLoading={deleteLoading}
/>
</ManageEmptyState>
)}
{isAddingNew ? (
<AddLocationFlyout
setIsOpen={setIsAddingNew}
{isPrivateLocationFlyoutVisible ? (
<AddOrEditLocationFlyout
onCloseFlyout={onCloseFlyout}
onSubmit={handleSubmit}
privateLocations={privateLocations}
privateLocationToEdit={privateLocationToEdit}
/>
) : null}
</SpacesContextProvider>

View file

@ -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: (
<EuiHealth
color={item.status === 'active' ? 'success' : 'warning'}
style={{ lineHeight: 'inherit' }}
css={{ lineHeight: 'inherit' }}
>
{item.name}
</EuiHealth>
@ -74,7 +80,7 @@ export const PolicyHostsField = ({ privateLocations }: { privateLocations: Priva
<>
<EuiHealth
color={item.status === 'active' ? 'success' : 'warning'}
style={{ lineHeight: 'inherit' }}
css={{ lineHeight: 'inherit' }}
>
<strong>{item.name}</strong>
</EuiHealth>
@ -124,6 +130,7 @@ export const PolicyHostsField = ({ privateLocations }: { privateLocations: Priva
rules={{ required: true }}
render={({ field }) => (
<SuperSelect
disabled={isDisabled}
fullWidth
aria-label={SELECT_POLICY_HOSTS}
placeholder={SELECT_POLICY_HOSTS}

View file

@ -5,7 +5,7 @@
* 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 { INITIAL_REST_VERSION, SYNTHETICS_API_URLS } from '../../../../../common/constants';
import { SyntheticsPrivateLocations } from '../../../../../common/runtime_types';

View file

@ -6,9 +6,10 @@
*/
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 { createAsyncAction } from '../utils/actions';
import type { EditPrivateLocationAttributes } from '../../../../../server/routes/settings/private_locations/edit_private_location';
export const getPrivateLocationsAction = createAsyncAction<void, SyntheticsPrivateLocations>(
'[PRIVATE LOCATIONS] GET'
@ -18,12 +19,24 @@ export const createPrivateLocationAction = createAsyncAction<NewLocation, Privat
'CREATE PRIVATE LOCATION'
);
export const editPrivateLocationAction = createAsyncAction<
{
locationId: string;
newAttributes: EditPrivateLocationAttributes;
},
PrivateLocation
>('EDIT PRIVATE LOCATION');
export const deletePrivateLocationAction = createAsyncAction<string, SyntheticsPrivateLocations>(
'DELETE PRIVATE LOCATION'
);
export const setManageFlyoutOpen = createAction<boolean>('SET MANAGE FLYOUT OPEN');
export const setIsCreatePrivateLocationFlyoutVisible = createAction<boolean>(
'SET IS CREATE PRIVATE LOCATION FLYOUT VISIBLE'
export const setIsPrivateLocationFlyoutVisible = createAction<boolean>(
'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.
*/
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<PrivateLocation> => {
return apiService.put(
`${SYNTHETICS_API_URLS.PRIVATE_LOCATIONS}/${locationId}`,
newAttributes,
undefined,
{
version: INITIAL_REST_VERSION,
}
);
};
export const getSyntheticsPrivateLocations = async (): Promise<SyntheticsPrivateLocations> => {
return await apiService.get(SYNTHETICS_API_URLS.PRIVATE_LOCATIONS, {
version: INITIAL_REST_VERSION,

View file

@ -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,
];

View file

@ -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;
});
});

View file

@ -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;

View file

@ -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,

View file

@ -116,7 +116,7 @@ export const mockState: SyntheticsAppState = {
data: null,
},
privateLocations: {
isCreatePrivateLocationFlyoutVisible: false,
isPrivateLocationFlyoutVisible: false,
loading: false,
error: null,
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 { 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<PrivateLocationAttributes>(
privateLocationSavedObjectName,
locationId
);
}
async editPrivateLocation(locationId: string, newAttributes: EditPrivateLocationAttributes) {
const { savedObjectsClient } = this.routeContext;
return savedObjectsClient.update<PrivateLocationAttributes>(
privateLocationSavedObjectName,
locationId,
newAttributes
);
}
async validatePrivateLocation() {
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 { 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,

View file

@ -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 }),
]);

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.
*/
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,
});
});
});

View file

@ -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<PrivateLocationAttributes>
@ -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<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({
monitors,
namespace,
}: {
monitors: Array<{
attributes: MonitorFields;
id: string;
}>;
namespace?: string;
}) {
return await this.soClient.bulkUpdate<MonitorFields>(
return this.soClient.bulkUpdate<MonitorFields>(
monitors.map(({ attributes, id }) => ({
type: syntheticsMonitorType,
id,
attributes,
namespace,
}))
);
}

View file

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

View file

@ -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';

View file

@ -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]';

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('./synthetics_enablement'));
loadTestFile(require.resolve('./test_now_monitor'));
loadTestFile(require.resolve('./edit_private_location'));
});
}