Revert [Synthetics] Edit private locations labels and tags and bulkUpdate (#225273)

…tics (#221515)"

This reverts commit b1b8fb0a88 and
97941682db as part of an emergency release
to address https://github.com/elastic/kibana/issues/225254 on serverless

## Summary

Summarize your PR. If it involves visual changes include a screenshot or
gif.


### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

### Identify risks

Does this PR introduce any risks? For example, consider risks like hard
to test bugs, performance regression, potential of data loss.

Describe the risk, its severity, and mitigation for each identified
risk. Invite stakeholders and evaluate how to proceed before merging.

- [ ] [See some risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx)
- [ ] ...
This commit is contained in:
Rudolf Meijering 2025-06-25 16:26:05 +02:00 committed by GitHub
parent e2a833785b
commit 0cb63c4ee0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 124 additions and 1079 deletions

View file

@ -56864,69 +56864,6 @@ 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

@ -53,7 +53,6 @@ import {
expectError,
createBadRequestErrorPayload,
expectUpdateResult,
MULTI_NAMESPACE_TYPE,
} from '../../test_helpers/repository.test.common';
import type { ISavedObjectsSecurityExtension } from '@kbn/core-saved-objects-server';
import { savedObjectsExtensionsMock } from '../../mocks/saved_objects_extensions.mock';
@ -621,74 +620,6 @@ describe('#bulkUpdate', () => {
2
);
});
it('migrates single namespace objects using the object namespace', async () => {
const modifiedObj2 = {
...obj2,
coreMigrationVersion: '8.0.0',
namespace: 'test',
};
const objects = [modifiedObj2];
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
await bulkUpdateSuccess(client, repository, registry, objects);
expect(migrator.migrateDocument).toHaveBeenCalledTimes(2);
expectMigrationArgs(
{
id: modifiedObj2.id,
namespace: 'test',
},
true,
2
);
});
it('migrates multiple namespace objects using the object namespaces', async () => {
const modifiedObj2 = {
...obj2,
type: MULTI_NAMESPACE_TYPE,
coreMigrationVersion: '8.0.0',
namespace: 'test',
};
const objects = [modifiedObj2];
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
await bulkUpdateSuccess(client, repository, registry, objects);
expect(migrator.migrateDocument).toHaveBeenCalledTimes(2);
expectMigrationArgs(
{
id: modifiedObj2.id,
namespaces: ['test'],
},
true,
2
);
});
it('migrates namespace agnsostic objects', async () => {
const modifiedObj2 = {
...obj2,
type: NAMESPACE_AGNOSTIC_TYPE,
coreMigrationVersion: '8.0.0',
namespace: 'test', // specify a namespace, but it should be ignored
};
const objects = [modifiedObj2];
migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true }));
await bulkUpdateSuccess(client, repository, registry, objects);
expect(migrator.migrateDocument).toHaveBeenCalledTimes(2);
expectMigrationArgs(
{
id: modifiedObj2.id,
namespaces: [],
},
true,
2
);
});
});
describe('returns', () => {

View file

@ -150,11 +150,17 @@ export const performBulkUpdate = async <T>(
};
}
// `objectNamespace` is a namespace string, while `namespace` is a namespace ID.
// The object namespace string, if defined, will supersede the operation's namespace ID.
const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace);
const getNamespaceId = (objectNamespace?: string) =>
objectNamespace !== undefined
? SavedObjectsUtils.namespaceStringToId(objectNamespace)
: namespace;
const getNamespaceString = (objectNamespace?: string) => objectNamespace ?? namespaceString;
const bulkGetDocs = validObjects.map(({ value: { type, id, objectNamespace } }) => ({
_id: serializer.generateRawId(getNamespaceId(objectNamespace), type, id),
_index: commonHelper.getIndexForType(type),
@ -229,6 +235,7 @@ export const performBulkUpdate = async <T>(
mergeAttributes,
} = expectedBulkGetResult.value;
let namespaces: string[] | undefined;
const versionProperties = getExpectedVersionProperties(version);
const indexFound = bulkGetResponse?.statusCode !== 404;
const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined;
@ -251,18 +258,15 @@ export const performBulkUpdate = async <T>(
});
}
let savedObjectNamespace: string | undefined;
let savedObjectNamespaces: string[] | undefined;
if (isMultiNS) {
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
savedObjectNamespaces = actualResult!._source.namespaces ?? [
namespaces = actualResult!._source.namespaces ?? [
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace),
];
} else if (registry.isSingleNamespace(type)) {
// if `objectNamespace` is undefined, fall back to `options.namespace`
savedObjectNamespace = objectNamespace ?? namespace;
namespaces = [getNamespaceString(objectNamespace)];
}
const document = getSavedObjectFromSource<T>(
@ -306,8 +310,8 @@ export const performBulkUpdate = async <T>(
...migrated!,
id,
type,
...(savedObjectNamespace && { namespace: savedObjectNamespace }),
...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }),
namespace,
namespaces,
attributes: updatedAttributes,
updated_at: time,
updated_by: updatedBy,
@ -317,9 +321,6 @@ export const performBulkUpdate = async <T>(
migratedUpdatedSavedObjectDoc as SavedObjectSanitizedDoc
);
const namespaces =
savedObjectNamespaces ?? (savedObjectNamespace ? [savedObjectNamespace] : []);
const expectedResult = {
type,
id,

View file

@ -546,13 +546,9 @@ describe('#update', () => {
namespace: 'default',
});
expect(client.index).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.stringMatching(`${type}:${id}`),
}),
expect.objectContaining({ id: expect.stringMatching(`${type}:${id}`) }),
expect.anything()
);
// Assert that 'namespace' does not exist at all
expect(client.index.mock.calls[0][0]).not.toHaveProperty('namespace');
});
it(`doesn't prepend namespace to the id when using agnostic-namespace type`, async () => {

View file

@ -716,7 +716,7 @@ export const expectUpdateResult = ({
attributes,
references,
version: mockVersion,
namespaces: [],
namespaces: ['default'],
...mockTimestampFields,
});

View file

@ -28,7 +28,7 @@ export const GETTING_STARTED_ROUTE = '/monitors/getting-started';
export const SETTINGS_ROUTE = '/settings';
export const PRIVATE_LOCATIONS_ROUTE = '/settings/private-locations';
export const PRIVATE_LOCATIOSN_ROUTE = '/settings/private-locations';
export const SYNTHETICS_SETTINGS_ROUTE = '/settings/:tabId';

View file

@ -1024,71 +1024,6 @@ 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,7 +15,6 @@ 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);
@ -88,33 +87,11 @@ 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-${NEW_LOCATION_LABEL}-default"`);
await page.click('text="test-monitor-Test private-default"');
await page.waitForSelector('h1:has-text("Edit Elastic Synthetics integration")');
await page.waitForSelector('text="This package policy is managed by the Synthetics app."');
});
@ -134,14 +111,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("${NEW_LOCATION_LABEL}")`);
await page.waitForSelector('td:has-text("Test private")');
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=${NEW_LOCATION_LABEL}`);
await page.click('text=Test private');
await page.click('.euiTableRowCell .euiToolTipAnchor');

View file

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

View file

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

View file

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

View file

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

View file

@ -35,22 +35,20 @@ import { selectPrivateLocationsState } from '../../../state/private_locations/se
export type NewLocation = Omit<PrivateLocation, 'id'>;
const getEmptyFunctionComponent: React.FC<SpacesContextProps> = ({ children }) => <>{children}</>;
export const AddOrEditLocationFlyout = ({
export const AddLocationFlyout = ({
onSubmit,
onCloseFlyout,
setIsOpen,
privateLocations,
privateLocationToEdit,
}: {
onSubmit: (val: NewLocation) => void;
onCloseFlyout: () => void;
setIsOpen: (val: boolean) => void;
privateLocations: PrivateLocation[];
privateLocationToEdit?: PrivateLocation;
}) => {
const form = useFormWrapped({
mode: 'onSubmit',
reValidateMode: 'onChange',
shouldFocusError: true,
defaultValues: privateLocationToEdit || {
defaultValues: {
label: '',
agentPolicyId: '',
geo: {
@ -63,7 +61,7 @@ export const AddOrEditLocationFlyout = ({
const { canSave, canManagePrivateLocations } = useSyntheticsSettingsContext();
const { createLoading, editLoading } = useSelector(selectPrivateLocationsState);
const { createLoading } = useSelector(selectPrivateLocationsState);
const { spaces: spacesApi } = useKibana<ClientPluginsStart>().services;
@ -74,35 +72,33 @@ export const AddOrEditLocationFlyout = ({
);
const { handleSubmit } = form;
const closeFlyout = () => {
setIsOpen(false);
};
return (
<ContextWrapper>
<FormProvider {...form}>
<EuiFlyout onClose={onCloseFlyout} css={{ width: 540 }}>
<EuiFlyout onClose={closeFlyout} style={{ width: 540 }}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
{privateLocationToEdit !== undefined ? EDIT_PRIVATE_LOCATION : ADD_PRIVATE_LOCATION}
</h2>
<h2>{ADD_PRIVATE_LOCATION}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<ManageEmptyState privateLocations={privateLocations} showEmptyLocations={false}>
<LocationForm
privateLocations={privateLocations}
privateLocationToEdit={privateLocationToEdit}
/>
<LocationForm privateLocations={privateLocations} />
</ManageEmptyState>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="syntheticsLocationFlyoutCancelButton"
data-test-subj="syntheticsAddLocationFlyoutButton"
iconType="cross"
onClick={onCloseFlyout}
onClick={closeFlyout}
flush="left"
isLoading={createLoading || editLoading}
isLoading={createLoading}
>
{CANCEL_LABEL}
</EuiButtonEmpty>
@ -110,10 +106,10 @@ export const AddOrEditLocationFlyout = ({
<EuiFlexItem grow={false}>
<NoPermissionsTooltip canEditSynthetics={canSave}>
<EuiButton
data-test-subj="syntheticsLocationFlyoutSaveButton"
data-test-subj="syntheticsAddLocationFlyoutButton"
fill
onClick={handleSubmit(onSubmit)}
isLoading={createLoading || editLoading}
isLoading={createLoading}
isDisabled={!canSave || !canManagePrivateLocations}
>
{SAVE_LABEL}
@ -135,13 +131,6 @@ 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_LOCATIONS_ROUTE } from '../../../../../../common/constants';
import { PRIVATE_LOCATIOSN_ROUTE } from '../../../../../../common/constants';
import {
setIsPrivateLocationFlyoutVisible,
setIsCreatePrivateLocationFlyoutVisible,
setManageFlyoutOpen,
} from '../../../state/private_locations/actions';
export const EmptyLocations = ({
inFlyout = true,
setIsFlyoutOpen,
setIsAddingNew,
redirectToSettings,
}: {
inFlyout?: boolean;
setIsFlyoutOpen?: (val: boolean) => void;
setIsAddingNew?: (val: boolean) => void;
redirectToSettings?: boolean;
}) => {
const dispatch = useDispatch();
@ -52,7 +52,7 @@ export const EmptyLocations = ({
fill
isDisabled={!canSave}
href={history.createHref({
pathname: PRIVATE_LOCATIONS_ROUTE,
pathname: PRIVATE_LOCATIOSN_ROUTE,
})}
>
{ADD_LOCATION}
@ -65,9 +65,9 @@ export const EmptyLocations = ({
color="primary"
fill
onClick={() => {
setIsFlyoutOpen?.(true);
setIsAddingNew?.(true);
dispatch(setManageFlyoutOpen(true));
dispatch(setIsPrivateLocationFlyoutVisible(true));
dispatch(setIsCreatePrivateLocationFlyoutVisible(true));
}}
>
{ADD_LOCATION}

View file

@ -7,12 +7,10 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import type { EditPrivateLocationAttributes } from '../../../../../../../server/routes/settings/private_locations/edit_private_location';
import { NewLocation } from '../add_or_edit_location_flyout';
import { NewLocation } from '../add_location_flyout';
import {
createPrivateLocationAction,
deletePrivateLocationAction,
editPrivateLocationAction,
getPrivateLocationsAction,
} from '../../../../state/private_locations/actions';
import { selectPrivateLocationsState } from '../../../../state/private_locations/selectors';
@ -32,22 +30,17 @@ export const usePrivateLocationsAPI = () => {
}
}, [data, dispatch]);
const onCreateLocationAPI = (newLoc: NewLocation) => {
const onSubmit = (newLoc: NewLocation) => {
dispatch(createPrivateLocationAction.get(newLoc));
};
const onEditLocationAPI = (locationId: string, newAttributes: EditPrivateLocationAttributes) => {
dispatch(editPrivateLocationAction.get({ locationId, newAttributes }));
};
const onDeleteLocationAPI = (id: string) => {
const onDelete = (id: string) => {
dispatch(deletePrivateLocationAction.get(id));
};
return {
onCreateLocationAPI,
onEditLocationAPI,
onDeleteLocationAPI,
onSubmit,
onDelete,
deleteLoading,
loading,
createLoading,

View file

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

View file

@ -8,7 +8,6 @@
import React, { useState } from 'react';
import {
EuiBadge,
EuiBasicTableColumn,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
@ -32,7 +31,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 { setIsPrivateLocationFlyoutVisible } from '../../../state/private_locations/actions';
import { setIsCreatePrivateLocationFlyoutVisible } from '../../../state/private_locations/actions';
import { ClientPluginsStart } from '../../../../../plugin';
interface ListItem extends PrivateLocation {
@ -42,12 +41,10 @@ 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();
@ -68,7 +65,7 @@ export const PrivateLocationsTable = ({
return new Set([...acc, ...tags]);
}, new Set<string>());
const columns: Array<EuiBasicTableColumn<ListItem>> = [
const columns = [
{
field: 'label',
name: LOCATION_NAME_LABEL,
@ -117,15 +114,6 @@ 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,
@ -150,7 +138,7 @@ export const PrivateLocationsTable = ({
monitors: locationMonitors?.find((l) => l.id === location.id)?.count ?? 0,
}));
const openFlyout = () => dispatch(setIsPrivateLocationFlyoutVisible(true));
const setIsAddingNew = (val: boolean) => dispatch(setIsCreatePrivateLocationFlyoutVisible(val));
const renderToolRight = () => {
return [
@ -164,7 +152,7 @@ export const PrivateLocationsTable = ({
data-test-subj={'addPrivateLocationButton'}
isLoading={loading}
disabled={!canSave || !canManagePrivateLocations}
onClick={openFlyout}
onClick={() => setIsAddingNew(true)}
iconType="plusInCircle"
>
{ADD_LABEL}
@ -248,10 +236,6 @@ 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[];
setIsFlyoutOpen?: (val: boolean) => void;
setIsAddingNew?: (val: boolean) => void;
showNeedAgentPolicy?: boolean;
showEmptyLocations?: boolean;
}>
> = ({
children,
privateLocations,
setIsFlyoutOpen,
setIsAddingNew,
showNeedAgentPolicy = true,
showEmptyLocations = true,
}) => {
@ -33,7 +33,7 @@ export const ManageEmptyState: FC<
}
if (privateLocations.length === 0 && showEmptyLocations) {
return <EmptyLocations setIsFlyoutOpen={setIsFlyoutOpen} />;
return <EmptyLocations setIsAddingNew={setIsAddingNew} />;
}
return <>{children}</>;

View file

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

View file

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

View file

@ -29,13 +29,7 @@ import { selectAgentPolicies } from '../../../state/agent_policies';
export const AGENT_POLICY_FIELD_NAME = 'agentPolicyId';
export const PolicyHostsField = ({
privateLocations,
isDisabled,
}: {
privateLocations: PrivateLocation[];
isDisabled?: boolean;
}) => {
export const PolicyHostsField = ({ privateLocations }: { privateLocations: PrivateLocation[] }) => {
const { data } = useSelector(selectAgentPolicies);
const { basePath } = useSyntheticsSettingsContext();
@ -59,7 +53,7 @@ export const PolicyHostsField = ({
inputDisplay: (
<EuiHealth
color={item.status === 'active' ? 'success' : 'warning'}
css={{ lineHeight: 'inherit' }}
style={{ lineHeight: 'inherit' }}
>
{item.name}
</EuiHealth>
@ -80,7 +74,7 @@ export const PolicyHostsField = ({
<>
<EuiHealth
color={item.status === 'active' ? 'success' : 'warning'}
css={{ lineHeight: 'inherit' }}
style={{ lineHeight: 'inherit' }}
>
<strong>{item.name}</strong>
</EuiHealth>
@ -130,7 +124,6 @@ export const PolicyHostsField = ({
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_or_edit_location_flyout';
import { NewLocation } from '../../components/settings/private_locations/add_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,10 +6,9 @@
*/
import { createAction } from '@reduxjs/toolkit';
import { NewLocation } from '../../components/settings/private_locations/add_or_edit_location_flyout';
import { NewLocation } from '../../components/settings/private_locations/add_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'
@ -19,24 +18,12 @@ 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 setIsPrivateLocationFlyoutVisible = createAction<boolean>(
'SET IS CREATE OR EDIT PRIVATE LOCATION FLYOUT VISIBLE'
);
export const setPrivateLocationToEdit = createAction<PrivateLocation | undefined>(
'SET PRIVATE LOCATION TO EDIT'
export const setIsCreatePrivateLocationFlyoutVisible = createAction<boolean>(
'SET IS CREATE PRIVATE LOCATION FLYOUT VISIBLE'
);

View file

@ -5,8 +5,7 @@
* 2.0.
*/
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 { NewLocation } from '../../components/settings/private_locations/add_location_flyout';
import { AgentPolicyInfo } from '../../../../../common/types';
import { INITIAL_REST_VERSION, SYNTHETICS_API_URLS } from '../../../../../common/constants';
import { PrivateLocation, SyntheticsPrivateLocations } from '../../../../../common/runtime_types';
@ -24,23 +23,6 @@ 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,13 +11,11 @@ import { fetchEffectFactory } from '../utils/fetch_effect';
import {
createSyntheticsPrivateLocation,
deleteSyntheticsPrivateLocation,
editSyntheticsPrivateLocation,
getSyntheticsPrivateLocations,
} from './api';
import {
createPrivateLocationAction,
deletePrivateLocationAction,
editPrivateLocationAction,
getPrivateLocationsAction,
} from './actions';
@ -49,23 +47,6 @@ 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,
@ -80,6 +61,5 @@ export function* deletePrivateLocationEffect() {
export const privateLocationsEffects = [
fetchPrivateLocationsEffect,
createPrivateLocationEffect,
editPrivateLocationEffect,
deletePrivateLocationEffect,
];

View file

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

View file

@ -11,11 +11,8 @@ import { AppState } from '..';
const getState = (appState: AppState) => appState.privateLocations;
export const selectAgentPolicies = createSelector(getState, (state) => state);
export const selectPrivateLocationFlyoutVisible = (state: AppState) =>
state.privateLocations.isPrivateLocationFlyoutVisible ?? false;
export const selectPrivateLocationToEdit = (state: AppState) =>
state.privateLocations.privateLocationToEdit;
export const selectAddingNewPrivateLocation = (state: AppState) =>
state.privateLocations.isCreatePrivateLocationFlyoutVisible ?? false;
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.isPrivateLocationFlyoutVisible ?? false;
state.privateLocations.isCreatePrivateLocationFlyoutVisible ?? false;
export const selectLocationMonitors = (state: AppState) => ({
locationMonitors: state.dynamicSettings.locationMonitors,

View file

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

View file

@ -12,7 +12,6 @@ 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;
@ -34,26 +33,6 @@ 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,7 +55,6 @@ 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';
@ -113,7 +112,6 @@ export const syntheticsAppPublicRestApiRoutes: SyntheticsRestApiRouteFactory[] =
deleteSyntheticsParamsRoute,
addPrivateLocationRoute,
deletePrivateLocationRoute,
editPrivateLocationRoute,
getPrivateLocationsRoute,
getAllSyntheticsMonitorRoute,
getSyntheticsMonitorRoute,

View file

@ -83,10 +83,7 @@ export const syncEditedMonitorBulk = async ({
} as unknown as MonitorFields,
}));
const [editedMonitorSavedObjects, editSyncResponse] = await Promise.all([
monitorConfigRepository.bulkUpdate({
monitors: data,
namespace: spaceId !== routeContext.spaceId ? spaceId : undefined,
}),
monitorConfigRepository.bulkUpdate({ monitors: data }),
syncUpdatedMonitors({ monitorsToUpdate, routeContext, spaceId, privateLocations }),
]);

View file

@ -1,163 +0,0 @@
/*
* 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,20 +5,7 @@
* 2.0.
*/
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';
import { allLocationsToClientContract } from './helpers';
const testLocations = {
locations: [
@ -127,107 +114,3 @@ 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,24 +4,14 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SavedObject, SavedObjectsFindResult } from '@kbn/core/server';
import { formatSecrets, normalizeSecrets } from '../../../synthetics_service/utils';
import { SavedObject } from '@kbn/core/server';
import { AgentPolicyInfo } from '../../../../common/types';
import type {
SyntheticsMonitor,
SyntheticsMonitorWithSecretsAttributes,
SyntheticsPrivateLocations,
} from '../../../../common/runtime_types';
import type { 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>
@ -70,54 +60,3 @@ 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,20 +82,17 @@ export class MonitorConfigRepository {
async bulkUpdate({
monitors,
namespace,
}: {
monitors: Array<{
attributes: MonitorFields;
id: string;
}>;
namespace?: string;
}) {
return this.soClient.bulkUpdate<MonitorFields>(
return await 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_LOCATIONS_ROUTE = '/settings/private-locations';
export const PRIVATE_LOCATIOSN_ROUTE = '/settings/private-locations';
export const SYNTHETICS_SETTINGS_ROUTE = '/settings/:tabId';

View file

@ -39,10 +39,7 @@ 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' || method === 'PUT') &&
path.includes('private_locations')
) {
if ((method === 'POST' || method === 'DELETE') && path.includes('private_locations')) {
tags = readUser
? '[private-location-write,uptime-write]'
: '[uptime-read,private-location-write,uptime-write]';

View file

@ -1,174 +0,0 @@
/*
* 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,6 +31,5 @@ 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'));
});
}