mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[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:
parent
b759ebba3d
commit
b1b8fb0a88
37 changed files with 994 additions and 111 deletions
|
@ -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: |
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
});
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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}</>;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -116,7 +116,7 @@ export const mockState: SyntheticsAppState = {
|
|||
data: null,
|
||||
},
|
||||
privateLocations: {
|
||||
isCreatePrivateLocationFlyoutVisible: false,
|
||||
isPrivateLocationFlyoutVisible: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: [],
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }),
|
||||
]);
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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());
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ export function normalizeMonitorSecretAttributes(
|
|||
const normalizedMonitorAttributes = {
|
||||
...defaultFields,
|
||||
...monitor,
|
||||
...JSON.parse(monitor.secrets || ''),
|
||||
...JSON.parse(monitor.secrets || '{}'),
|
||||
};
|
||||
delete normalizedMonitorAttributes.secrets;
|
||||
return normalizedMonitorAttributes;
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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]';
|
||||
|
|
|
@ -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].'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue