[8.7] [Synthetics] handle onboarding for onprem deployments (#152048) (#153163)

# Backport

This will backport the following commits from `main` to `8.7`:
- [[Synthetics] handle onboarding for onprem deployments
(#152048)](https://github.com/elastic/kibana/pull/152048)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Dominique
Clarke","email":"dominique.clarke@elastic.co"},"sourceCommit":{"committedDate":"2023-03-11T02:29:14Z","message":"[Synthetics]
handle onboarding for onprem deployments (#152048)\n\n##
Summary\r\n\r\nDirects on-prem users to create a private location,
before allowing\r\non-prem users to create monitors.\r\n\r\nAdmin
user\r\n[Monitor-Management-Synthetics---Kibana\r\n(6).webm](https://user-images.githubusercontent.com/11356435/222554184-3f399764-0c3d-41e4-9652-7ec5616a320c.webm)\r\n\r\nUser
without Fleet
privileges\r\n[Synthetics-Getting-Started-Synthetics---Kibana\r\n(3).webm](https://user-images.githubusercontent.com/11356435/222554216-893a9a79-a152-459d-b6e6-d5bdfc5014dc.webm)\r\n\r\n\r\n###
Testing\r\n\r\n1. Start ES with yarn es snapshot\r\n2. Remove all
`xpack.uptime.service` configs in your Kibana.dev.yml\r\n3. Start Kibana
connected to local ES\r\n4. Navigate to Synthetics and enable
monitor\r\n5. Confirm that Add monitor flow appears first before
creating a monitor\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana
Machine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
shahzad31 <shahzad31comp@gmail.com>\r\nCo-authored-by: florent-leborgne
<florent.leborgne@elastic.co>","sha":"01ba0270d9e9f62aadbe8cfc38b20810581619d7","branchLabelMapping":{"^v8.8.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["bug","Team:uptime","release_note:skip","v8.7.0","v8.8.0"],"number":152048,"url":"https://github.com/elastic/kibana/pull/152048","mergeCommit":{"message":"[Synthetics]
handle onboarding for onprem deployments (#152048)\n\n##
Summary\r\n\r\nDirects on-prem users to create a private location,
before allowing\r\non-prem users to create monitors.\r\n\r\nAdmin
user\r\n[Monitor-Management-Synthetics---Kibana\r\n(6).webm](https://user-images.githubusercontent.com/11356435/222554184-3f399764-0c3d-41e4-9652-7ec5616a320c.webm)\r\n\r\nUser
without Fleet
privileges\r\n[Synthetics-Getting-Started-Synthetics---Kibana\r\n(3).webm](https://user-images.githubusercontent.com/11356435/222554216-893a9a79-a152-459d-b6e6-d5bdfc5014dc.webm)\r\n\r\n\r\n###
Testing\r\n\r\n1. Start ES with yarn es snapshot\r\n2. Remove all
`xpack.uptime.service` configs in your Kibana.dev.yml\r\n3. Start Kibana
connected to local ES\r\n4. Navigate to Synthetics and enable
monitor\r\n5. Confirm that Add monitor flow appears first before
creating a monitor\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana
Machine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
shahzad31 <shahzad31comp@gmail.com>\r\nCo-authored-by: florent-leborgne
<florent.leborgne@elastic.co>","sha":"01ba0270d9e9f62aadbe8cfc38b20810581619d7"}},"sourceBranch":"main","suggestedTargetBranches":["8.7"],"targetPullRequestStates":[{"branch":"8.7","label":"v8.7.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.8.0","labelRegex":"^v8.8.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/152048","number":152048,"mergeCommit":{"message":"[Synthetics]
handle onboarding for onprem deployments (#152048)\n\n##
Summary\r\n\r\nDirects on-prem users to create a private location,
before allowing\r\non-prem users to create monitors.\r\n\r\nAdmin
user\r\n[Monitor-Management-Synthetics---Kibana\r\n(6).webm](https://user-images.githubusercontent.com/11356435/222554184-3f399764-0c3d-41e4-9652-7ec5616a320c.webm)\r\n\r\nUser
without Fleet
privileges\r\n[Synthetics-Getting-Started-Synthetics---Kibana\r\n(3).webm](https://user-images.githubusercontent.com/11356435/222554216-893a9a79-a152-459d-b6e6-d5bdfc5014dc.webm)\r\n\r\n\r\n###
Testing\r\n\r\n1. Start ES with yarn es snapshot\r\n2. Remove all
`xpack.uptime.service` configs in your Kibana.dev.yml\r\n3. Start Kibana
connected to local ES\r\n4. Navigate to Synthetics and enable
monitor\r\n5. Confirm that Add monitor flow appears first before
creating a monitor\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana
Machine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
shahzad31 <shahzad31comp@gmail.com>\r\nCo-authored-by: florent-leborgne
<florent.leborgne@elastic.co>","sha":"01ba0270d9e9f62aadbe8cfc38b20810581619d7"}}]}]
BACKPORT-->

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dominique Clarke 2023-03-13 10:50:41 -04:00 committed by GitHub
parent bfab6b1d49
commit 21c8d91a24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 581 additions and 174 deletions

View file

@ -27,6 +27,8 @@ export const GETTING_STARTED_ROUTE = '/monitors/getting-started';
export const SETTINGS_ROUTE = '/settings';
export const PRIVATE_LOCATIOSN_ROUTE = '/settings/private-locations';
export const SYNTHETICS_SETTINGS_ROUTE = '/settings/:tabId';
export const CERTIFICATES_ROUTE = '/certificates';

View file

@ -66,7 +66,7 @@ export const ServiceLocationsField = ({
const SELECT_ONE_OR_MORE_LOCATIONS = i18n.translate(
'xpack.synthetics.monitorManagement.selectOneOrMoreLocations',
{
defaultMessage: 'Select one or more locations',
defaultMessage: 'Select one or more locations.',
}
);

View file

@ -0,0 +1,175 @@
/*
* 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 React from 'react';
import * as permissionsHooks from '../../hooks/use_fleet_permissions';
import { render } from '../../utils/testing/rtl_helpers';
import { GettingStartedPage } from './getting_started_page';
import * as privateLocationsHooks from '../settings/private_locations/hooks/use_locations_api';
describe('GettingStartedPage', () => {
beforeEach(() => {
jest.spyOn(privateLocationsHooks, 'usePrivateLocationsAPI').mockReturnValue({
loading: false,
privateLocations: [],
deleteLoading: false,
onSubmit: jest.fn(),
onDelete: jest.fn(),
formData: undefined,
});
jest.spyOn(permissionsHooks, 'useCanManagePrivateLocation').mockReturnValue(true);
});
it('works with cloud locations', () => {
const { getByText } = render(<GettingStartedPage />, {
state: {
serviceLocations: {
locations: [
{
id: 'us_central',
label: 'Us Central',
},
{
id: 'us_east',
label: 'US East',
},
],
locationsLoaded: true,
loading: false,
},
agentPolicies: {
loading: false,
},
},
});
// page is loaded
expect(getByText('Create a single page browser monitor')).toBeInTheDocument();
});
it('serves on prem getting started experience when locations are not available', () => {
const { getByText } = render(<GettingStartedPage />, {
state: {
serviceLocations: {
locations: [],
locationsLoaded: true,
loading: false,
},
},
});
// page is loaded
expect(getByText('Get started with synthetic monitoring')).toBeInTheDocument();
});
it('shows need agent flyout when isAddingNewPrivateLocation is true and agentPolicies.length === 0', async () => {
const { getByText, getByRole, queryByLabelText } = render(<GettingStartedPage />, {
state: {
serviceLocations: {
locations: [],
locationsLoaded: true,
loading: false,
},
agentPolicies: {
data: {
total: 0,
},
isAddingNewPrivateLocation: true,
},
},
});
// page is loaded
expect(getByText('Get started with synthetic monitoring')).toBeInTheDocument();
expect(getByRole('heading', { name: 'Create private location', level: 2 }));
expect(getByText('No agent policies found')).toBeInTheDocument();
expect(getByRole('link', { name: 'Create agent policy' })).toBeEnabled();
expect(queryByLabelText('Location name')).not.toBeInTheDocument();
expect(queryByLabelText('Agent policy')).not.toBeInTheDocument();
});
it('shows add location flyout when isAddingNewPrivateLocation is true and agentPolicies.length > 0', async () => {
const { getByText, getByRole, getByLabelText, queryByText } = render(<GettingStartedPage />, {
state: {
serviceLocations: {
locations: [],
locationsLoaded: true,
loading: false,
},
agentPolicies: {
data: {
total: 1,
items: [{}],
},
isAddingNewPrivateLocation: true,
},
},
});
// page is loaded
expect(getByText('Get started with synthetic monitoring')).toBeInTheDocument();
expect(getByRole('heading', { name: 'Create private location', level: 2 }));
expect(queryByText('No agent policies found')).not.toBeInTheDocument();
expect(getByLabelText('Location name')).toBeInTheDocument();
expect(getByLabelText('Agent policy')).toBeInTheDocument();
});
it('shows permissions callout and hides form when agent policies are available but the user does not have permissions', async () => {
jest.spyOn(permissionsHooks, 'useCanManagePrivateLocation').mockReturnValue(false);
const { getByText, getByRole, queryByLabelText, queryByRole } = render(<GettingStartedPage />, {
state: {
serviceLocations: {
locations: [],
locationsLoaded: true,
loading: false,
},
agentPolicies: {
data: {
total: 1,
items: [{}],
},
isAddingNewPrivateLocation: true,
},
},
});
// page is loaded
expect(getByText('Get started with synthetic monitoring')).toBeInTheDocument();
expect(getByRole('heading', { name: 'Create private location', level: 2 }));
expect(queryByLabelText('Location name')).not.toBeInTheDocument();
expect(queryByLabelText('Agent policy')).not.toBeInTheDocument();
expect(queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
expect(getByText("You're missing some Kibana privileges to manage private locations"));
});
it('shows permissions callout when agent policy is needed but the user does not have permissions', async () => {
jest.spyOn(permissionsHooks, 'useCanManagePrivateLocation').mockReturnValue(false);
const { getByText, getByRole, queryByLabelText } = render(<GettingStartedPage />, {
state: {
serviceLocations: {
locations: [],
locationsLoaded: true,
loading: false,
},
agentPolicies: {
data: undefined, // data will be undefined when user does not have permissions
isAddingNewPrivateLocation: true,
},
},
});
// page is loaded
expect(getByText('Get started with synthetic monitoring')).toBeInTheDocument();
expect(getByRole('heading', { name: 'Create private location', level: 2 }));
expect(queryByLabelText('Location name')).not.toBeInTheDocument();
expect(queryByLabelText('Agent policy')).not.toBeInTheDocument();
expect(getByText("You're missing some Kibana privileges to manage private locations"));
});
});

View file

@ -5,54 +5,152 @@
* 2.0.
*/
import React, { useEffect } from 'react';
import { EuiEmptyPrompt, EuiLink, EuiSpacer, EuiText } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import React, { useEffect, useCallback } from 'react';
import {
EuiEmptyPrompt,
EuiLink,
EuiSpacer,
EuiText,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import styled from 'styled-components';
import { useBreadcrumbs } from '../../hooks';
import { getServiceLocations } from '../../state';
import { useBreadcrumbs, useLocations, useFleetPermissions } from '../../hooks';
import { usePrivateLocationsAPI } from '../settings/private_locations/hooks/use_locations_api';
import { LoadingState } from '../monitors_page/overview/overview/monitor_detail_flyout';
import {
getServiceLocations,
selectAddingNewPrivateLocation,
setAddingNewPrivateLocation,
getAgentPoliciesAction,
selectAgentPolicies,
} from '../../state';
import { MONITOR_ADD_ROUTE } from '../../../../../common/constants/ui';
import { PrivateLocation } from '../../../../../common/runtime_types';
import { SimpleMonitorForm } from './simple_monitor_form';
import { AddLocationFlyout } from '../settings/private_locations/add_location_flyout';
export const GettingStartedPage = () => {
const dispatch = useDispatch();
const history = useHistory();
const { canReadAgentPolicies } = useFleetPermissions();
useEffect(() => {
dispatch(getServiceLocations());
}, [dispatch]);
if (canReadAgentPolicies) {
dispatch(getAgentPoliciesAction.get());
}
}, [canReadAgentPolicies, dispatch]);
useBreadcrumbs([{ text: MONITORING_OVERVIEW_LABEL }]); // No extra breadcrumbs on overview
return (
const { locations, loading: allLocationsLoading } = useLocations();
const { loading: agentPoliciesLoading } = useSelector(selectAgentPolicies);
const loading = allLocationsLoading || agentPoliciesLoading;
const hasNoLocations = !allLocationsLoading && locations.length === 0;
return !loading ? (
<Wrapper>
{hasNoLocations ? (
<GettingStartedOnPrem />
) : (
<EuiEmptyPrompt
title={<h2>{CREATE_SINGLE_PAGE_LABEL}</h2>}
layout="horizontal"
color="plain"
body={
<>
<EuiText size="s">
{OR_LABEL}{' '}
<EuiLink
href={history.createHref({
pathname: MONITOR_ADD_ROUTE,
})}
>
{SELECT_DIFFERENT_MONITOR}
</EuiLink>
{i18n.translate('xpack.synthetics.gettingStarted.createSingle.description', {
defaultMessage: ' to get started with Elastic Synthetics Monitoring.',
})}
</EuiText>
<EuiSpacer />
<SimpleMonitorForm />
</>
}
/>
)}
</Wrapper>
) : (
<LoadingState />
);
};
export const GettingStartedOnPrem = () => {
const dispatch = useDispatch();
useBreadcrumbs([{ text: MONITORING_OVERVIEW_LABEL }]); // No extra breadcrumbs on overview
const isAddingNewLocation = useSelector(selectAddingNewPrivateLocation);
const setIsAddingNewLocation = useCallback(
(val: boolean) => dispatch(setAddingNewPrivateLocation(val)),
[dispatch]
);
const { onSubmit, privateLocations, loading } = usePrivateLocationsAPI();
const handleSubmit = (formData: PrivateLocation) => {
onSubmit(formData);
};
// make sure flyout is closed when first visiting the page
useEffect(() => {
setIsAddingNewLocation(false);
}, [setIsAddingNewLocation]);
return (
<>
<EuiEmptyPrompt
title={<h2>{CREATE_SINGLE_PAGE_LABEL}</h2>}
title={<h2>{GET_STARTED_LABEL}</h2>}
layout="horizontal"
color="plain"
body={
<>
<EuiText size="s">
{OR_LABEL}{' '}
<EuiLink
href={history.createHref({
pathname: MONITOR_ADD_ROUTE,
})}
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiText>{CREATE_LOCATION_DESCRIPTION}</EuiText>
<EuiSpacer />
<EuiText>{PUBLIC_LOCATION_DESCRIPTION}</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
fill
iconType="plusInCircleFilled"
data-test-subj="gettingStartedAddLocationButton"
onClick={() => setIsAddingNewLocation(true)}
>
{SELECT_DIFFERENT_MONITOR}
</EuiLink>
{i18n.translate('xpack.synthetics.gettingStarted.createSingle.description', {
defaultMessage: ' to get started with Elastic Synthetics Monitoring',
})}
</EuiText>
<EuiSpacer />
<SimpleMonitorForm />
</>
{CREATE_LOCATION_LABEL}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
}
/>
</Wrapper>
{isAddingNewLocation ? (
<AddLocationFlyout
setIsOpen={setIsAddingNewLocation}
onSubmit={handleSubmit}
privateLocations={privateLocations}
isLoading={loading}
/>
) : null}
</>
);
};
@ -72,6 +170,69 @@ const CREATE_SINGLE_PAGE_LABEL = i18n.translate(
}
);
const GET_STARTED_LABEL = i18n.translate('xpack.synthetics.gettingStarted.createLocationHeading', {
defaultMessage: 'Get started with synthetic monitoring',
});
const PRIVATE_LOCATION_LABEL = i18n.translate(
'xpack.synthetics.gettingStarted.privateLocationLabel',
{
defaultMessage: 'private location',
}
);
const CREATE_LOCATION_LABEL = i18n.translate(
'xpack.synthetics.gettingStarted.createLocationLabel',
{
defaultMessage: 'Create location',
}
);
const CREATE_LOCATION_DESCRIPTION = (
<FormattedMessage
id="xpack.synthetics.gettingStarted.createLocationDescription"
defaultMessage="To start creating monitors, you first need to create a {link}. Private locations allow you to run monitors from your own premises. They require an Elastic agent and Agent policy which you can control and maintain via Fleet."
values={{
link: (
<EuiLink
href="https://www.elastic.co/guide/en/observability/current/synthetics-private-location.html"
target="_blank"
>
{PRIVATE_LOCATION_LABEL}
</EuiLink>
),
}}
/>
);
const PUBLIC_LOCATION_DESCRIPTION = (
<FormattedMessage
id="xpack.synthetics.gettingStarted.publicLocationDescription"
defaultMessage="In {link} you can also use {elasticManagedLink}. With it, you can create and run monitors in multiple locations without having to manage your own infrastructure. Elastic takes care of software updates and capacity planning for you."
values={{
elasticManagedLink: (
<strong>
{i18n.translate(
'xpack.synthetics.gettingStarted.gettingStartedLabel.elasticManagedLink',
{
defaultMessage: 'Elastics global managed testing infrastructure',
}
)}
</strong>
),
link: (
<EuiLink href="https://www.elastic.co/cloud/" target="_blank">
{i18n.translate(
'xpack.synthetics.gettingStarted.gettingStartedLabel.elasticCloudDeployments',
{
defaultMessage: 'Elastic Cloud',
}
)}
</EuiLink>
),
}}
/>
);
const SELECT_DIFFERENT_MONITOR = i18n.translate(
'xpack.synthetics.gettingStarted.gettingStartedLabel.selectDifferentMonitor',
{

View file

@ -126,7 +126,7 @@ export const WEBSITE_URL_PLACEHOLDER = i18n.translate(
export const WEBSITE_URL_HELP_TEXT = i18n.translate(
'xpack.synthetics.monitorManagement.websiteUrlHelpText',
{
defaultMessage: `For example, your company's homepage or https://elastic.co`,
defaultMessage: `For example, your company's homepage or https://elastic.co.`,
}
);

View file

@ -24,6 +24,7 @@ import { useFormWrapped } from '../../../../../hooks/use_form_wrapped';
import { PrivateLocation } from '../../../../../../common/runtime_types';
import { FleetPermissionsCallout } from '../../common/components/permissions';
import { LocationForm } from './location_form';
import { ManageEmptyState } from './manage_empty_state';
export const AddLocationFlyout = ({
onSubmit,
@ -69,32 +70,39 @@ export const AddLocationFlyout = ({
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{!canManagePrivateLocation && <FleetPermissionsCallout />}
<LocationForm
<ManageEmptyState
privateLocations={privateLocations}
hasPermissions={canManagePrivateLocation}
/>
hasFleetPermissions={canManagePrivateLocation}
showEmptyLocations={false}
>
{!canManagePrivateLocation && <FleetPermissionsCallout />}
<LocationForm
privateLocations={privateLocations}
hasPermissions={canManagePrivateLocation}
/>
</ManageEmptyState>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={closeFlyout}
flush="left"
isLoading={isLoading}
>
{CANCEL_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill onClick={handleSubmit(onSubmit)} isLoading={isLoading}>
{SAVE_LABEL}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
{canManagePrivateLocation && (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={closeFlyout}
flush="left"
isLoading={isLoading}
>
{CANCEL_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill onClick={handleSubmit(onSubmit)} isLoading={isLoading}>
{SAVE_LABEL}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
)}
</EuiFlyout>
</FormProvider>
);

View file

@ -6,21 +6,26 @@
*/
import React from 'react';
import { useHistory } from 'react-router-dom';
import { EuiEmptyPrompt, EuiButton, EuiLink, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDispatch } from 'react-redux';
import { PRIVATE_LOCATIOSN_ROUTE } from '../../../../../../common/constants';
import { setAddingNewPrivateLocation, setManageFlyoutOpen } from '../../../state/private_locations';
export const EmptyLocations = ({
inFlyout = true,
setIsAddingNew,
disabled,
redirectToSettings,
}: {
inFlyout?: boolean;
disabled?: boolean;
setIsAddingNew?: (val: boolean) => void;
redirectToSettings?: boolean;
}) => {
const dispatch = useDispatch();
const history = useHistory();
return (
<EuiEmptyPrompt
@ -33,19 +38,33 @@ export const EmptyLocations = ({
</EuiText>
}
actions={
<EuiButton
iconType="plusInCircle"
disabled={disabled}
color="primary"
fill
onClick={() => {
setIsAddingNew?.(true);
dispatch(setManageFlyoutOpen(true));
dispatch(setAddingNewPrivateLocation(true));
}}
>
{ADD_LOCATION}
</EuiButton>
redirectToSettings ? (
<EuiButton
iconType="plusInCircle"
color="primary"
fill
isDisabled={disabled}
href={history.createHref({
pathname: PRIVATE_LOCATIOSN_ROUTE,
})}
>
{ADD_LOCATION}
</EuiButton>
) : (
<EuiButton
iconType="plusInCircle"
disabled={disabled}
color="primary"
fill
onClick={() => {
setIsAddingNew?.(true);
dispatch(setManageFlyoutOpen(true));
dispatch(setAddingNewPrivateLocation(true));
}}
>
{ADD_LOCATION}
</EuiButton>
)
}
footer={
<EuiText size="s">

View file

@ -9,11 +9,11 @@ import { renderHook, act } from '@testing-library/react-hooks';
import { WrappedHelper } from '../../../../utils/testing';
import { getServiceLocations } from '../../../../state/service_locations';
import { setAddingNewPrivateLocation } from '../../../../state/private_locations';
import { useLocationsAPI } from './use_locations_api';
import { usePrivateLocationsAPI } from './use_locations_api';
import * as locationAPI from '../../../../state/private_locations/api';
import * as reduxHooks from 'react-redux';
describe('useLocationsAPI', () => {
describe('usePrivateLocationsAPI', () => {
const dispatch = jest.fn();
const addAPI = jest.spyOn(locationAPI, 'addSyntheticsPrivateLocations').mockResolvedValue({
locations: [],
@ -25,7 +25,7 @@ describe('useLocationsAPI', () => {
jest.spyOn(reduxHooks, 'useDispatch').mockReturnValue(dispatch);
it('returns expected results', () => {
const { result } = renderHook(() => useLocationsAPI(), {
const { result } = renderHook(() => usePrivateLocationsAPI(), {
wrapper: WrappedHelper,
});
@ -46,7 +46,7 @@ describe('useLocationsAPI', () => {
],
});
it('returns expected results after data', async () => {
const { result, waitForNextUpdate } = renderHook(() => useLocationsAPI(), {
const { result, waitForNextUpdate } = renderHook(() => usePrivateLocationsAPI(), {
wrapper: WrappedHelper,
});
@ -73,7 +73,7 @@ describe('useLocationsAPI', () => {
});
it('adds location on submit', async () => {
const { result, waitForNextUpdate } = renderHook(() => useLocationsAPI(), {
const { result, waitForNextUpdate } = renderHook(() => usePrivateLocationsAPI(), {
wrapper: WrappedHelper,
});
@ -109,7 +109,7 @@ describe('useLocationsAPI', () => {
});
it('deletes location on delete', async () => {
const { result, waitForNextUpdate } = renderHook(() => useLocationsAPI(), {
const { result, waitForNextUpdate } = renderHook(() => usePrivateLocationsAPI(), {
wrapper: WrappedHelper,
});

View file

@ -17,7 +17,7 @@ import {
} from '../../../../state/private_locations/api';
import { PrivateLocation } from '../../../../../../../common/runtime_types';
export const useLocationsAPI = () => {
export const usePrivateLocationsAPI = () => {
const [formData, setFormData] = useState<PrivateLocation>();
const [deleteId, setDeleteId] = useState<string>();
const [privateLocations, setPrivateLocations] = useState<PrivateLocation[]>([]);

View file

@ -46,78 +46,47 @@ export const LocationForm = ({
return (
<>
{data?.items.length === 0 && <AgentPolicyNeeded disabled={!hasPermissions} />}
<EuiForm component="form" noValidate>
<EuiFormRow
fullWidth
label={LOCATION_NAME_LABEL}
isInvalid={Boolean(errors?.label)}
error={errors?.label?.message}
>
<EuiFieldText
{hasPermissions ? (
<EuiForm component="form" noValidate>
<EuiFormRow
fullWidth
aria-label={LOCATION_NAME_LABEL}
{...register('label', {
required: {
value: true,
message: NAME_REQUIRED,
},
validate: (val: string) => {
return privateLocations.some((loc) => loc.label === val)
? NAME_ALREADY_EXISTS
: undefined;
},
})}
/>
</EuiFormRow>
<EuiSpacer />
<PolicyHostsField errors={errors} control={control} privateLocations={privateLocations} />
<EuiSpacer />
<TagsField tagsList={tagsList} control={control} errors={errors} />
<EuiSpacer />
<EuiCallOut title={AGENT_CALLOUT_TITLE} size="s" style={{ textAlign: 'left' }}>
<p>
{
<FormattedMessage
id="xpack.synthetics.monitorManagement.agentCallout.content"
defaultMessage='If you intend to run "Browser" monitors on this private location, please ensure you are using the {code} Docker container, which contains the dependencies to run these monitors. For more information, {link}.'
values={{
code: <EuiCode>elastic-agent-complete</EuiCode>,
link: (
<EuiLink
target="_blank"
href="https://www.elastic.co/guide/en/observability/current/uptime-set-up-choose-agent.html#private-locations"
external
>
<FormattedMessage
id="xpack.synthetics.monitorManagement.agentCallout.link"
defaultMessage="read the docs"
/>
</EuiLink>
),
}}
/>
}
</p>
</EuiCallOut>
<EuiSpacer />
{selectedPolicy?.agents === 0 && (
<EuiCallOut
title={AGENT_MISSING_CALLOUT_TITLE}
size="s"
style={{ textAlign: 'left' }}
color="warning"
label={LOCATION_NAME_LABEL}
isInvalid={Boolean(errors?.label)}
error={errors?.label?.message}
>
<EuiFieldText
fullWidth
aria-label={LOCATION_NAME_LABEL}
{...register('label', {
required: {
value: true,
message: NAME_REQUIRED,
},
validate: (val: string) => {
return privateLocations.some((loc) => loc.label === val)
? NAME_ALREADY_EXISTS
: undefined;
},
})}
/>
</EuiFormRow>
<EuiSpacer />
<PolicyHostsField errors={errors} control={control} privateLocations={privateLocations} />
<EuiSpacer />
<TagsField tagsList={tagsList} control={control} errors={errors} />
<EuiSpacer />
<EuiCallOut title={AGENT_CALLOUT_TITLE} size="s" style={{ textAlign: 'left' }}>
<p>
{
<FormattedMessage
id="xpack.synthetics.monitorManagement.agentMissingCallout.content"
defaultMessage="You have selected an agent policy that has no agents attached. Please ensure you have at least one agent enrolled in this policy. You can add agent before or after creating a location. For more information, {link}."
id="xpack.synthetics.monitorManagement.agentCallout.content"
defaultMessage='To run "Browser" monitors on this private location, make sure that you&apos;re using the {code} Docker container, which contains the dependencies necessary to run these monitors. For more information, {link}.'
values={{
code: <EuiCode>elastic-agent-complete</EuiCode>,
link: (
<EuiLink
target="_blank"
href="https://www.elastic.co/guide/en/observability/current/synthetics-private-location.html#synthetics-private-location-fleet-agent"
href="https://www.elastic.co/guide/en/observability/current/uptime-set-up-choose-agent.html#private-locations"
external
>
<FormattedMessage
@ -131,8 +100,41 @@ export const LocationForm = ({
}
</p>
</EuiCallOut>
)}
</EuiForm>
<EuiSpacer />
{selectedPolicy?.agents === 0 && (
<EuiCallOut
title={AGENT_MISSING_CALLOUT_TITLE}
size="s"
style={{ textAlign: 'left' }}
color="warning"
>
<p>
{
<FormattedMessage
id="xpack.synthetics.monitorManagement.agentMissingCallout.content"
defaultMessage="You have selected an agent policy that has no agent attached. Make sure that you have at least one agent enrolled in this policy. You can add agent before or after creating a location. For more information, {link}."
values={{
link: (
<EuiLink
target="_blank"
href="https://www.elastic.co/guide/en/observability/current/synthetics-private-location.html#synthetics-private-location-fleet-agent"
external
>
<FormattedMessage
id="xpack.synthetics.monitorManagement.agentCallout.link"
defaultMessage="read the docs"
/>
</EuiLink>
),
}}
/>
}
</p>
</EuiCallOut>
)}
</EuiForm>
) : null}
</>
);
};

View file

@ -15,15 +15,24 @@ import { selectAgentPolicies } from '../../../state/private_locations';
export const ManageEmptyState: FC<{
privateLocations: PrivateLocation[];
hasFleetPermissions: boolean;
setIsAddingNew: (val: boolean) => void;
}> = ({ children, privateLocations, setIsAddingNew, hasFleetPermissions }) => {
setIsAddingNew?: (val: boolean) => void;
showNeedAgentPolicy?: boolean;
showEmptyLocations?: boolean;
}> = ({
children,
privateLocations,
setIsAddingNew,
hasFleetPermissions,
showNeedAgentPolicy = true,
showEmptyLocations = true,
}) => {
const { data: agentPolicies } = useSelector(selectAgentPolicies);
if (agentPolicies?.total === 0) {
if (agentPolicies?.total === 0 && showNeedAgentPolicy) {
return <AgentPolicyNeeded disabled={!hasFleetPermissions} />;
}
if (privateLocations.length === 0) {
if (privateLocations.length === 0 && showEmptyLocations) {
return <EmptyLocations setIsAddingNew={setIsAddingNew} disabled={!hasFleetPermissions} />;
}

View file

@ -21,7 +21,11 @@ jest.mock('../../../contexts/synthetics_settings_context');
describe('<ManagePrivateLocations />', () => {
beforeEach(() => {
jest.spyOn(permissionsHooks, 'useCanManagePrivateLocation').mockReturnValue(true);
jest.spyOn(locationHooks, 'useLocationsAPI').mockReturnValue({
jest.spyOn(permissionsHooks, 'useFleetPermissions').mockReturnValue({
canReadAgentPolicies: true,
canSaveIntegrations: false,
});
jest.spyOn(locationHooks, 'usePrivateLocationsAPI').mockReturnValue({
formData: {} as PrivateLocation,
loading: false,
onSubmit: jest.fn(),
@ -120,7 +124,7 @@ describe('<ManagePrivateLocations />', () => {
canSaveIntegrations: hasFleetPermissions,
canReadAgentPolicies: hasFleetPermissions,
});
jest.spyOn(locationHooks, 'useLocationsAPI').mockReturnValue({
jest.spyOn(locationHooks, 'usePrivateLocationsAPI').mockReturnValue({
formData: {} as PrivateLocation,
loading: false,
onSubmit: jest.fn(),

View file

@ -4,15 +4,15 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { EuiSpacer } from '@elastic/eui';
import { LoadingState } from '../../monitors_page/overview/overview/monitor_detail_flyout';
import { PrivateLocationsTable } from './locations_table';
import { useCanManagePrivateLocation } from '../../../hooks';
import { useCanManagePrivateLocation, useFleetPermissions } from '../../../hooks';
import { ManageEmptyState } from './manage_empty_state';
import { AddLocationFlyout } from './add_location_flyout';
import { useLocationsAPI } from './hooks/use_locations_api';
import { usePrivateLocationsAPI } from './hooks/use_locations_api';
import {
getAgentPoliciesAction,
selectAddingNewPrivateLocation,
@ -26,16 +26,27 @@ export const ManagePrivateLocations = () => {
const dispatch = useDispatch();
const isAddingNew = useSelector(selectAddingNewPrivateLocation);
const setIsAddingNew = (val: boolean) => dispatch(setAddingNewPrivateLocation(val));
const setIsAddingNew = useCallback(
(val: boolean) => dispatch(setAddingNewPrivateLocation(val)),
[dispatch]
);
const { onSubmit, loading, privateLocations, onDelete, deleteLoading } = useLocationsAPI();
const { onSubmit, loading, privateLocations, onDelete, deleteLoading } = usePrivateLocationsAPI();
const { canReadAgentPolicies } = useFleetPermissions();
const canManagePrivateLocation = useCanManagePrivateLocation();
// make sure flyout is closed when first visiting the page
useEffect(() => {
dispatch(getAgentPoliciesAction.get());
setIsAddingNew(false);
}, [setIsAddingNew]);
useEffect(() => {
if (canReadAgentPolicies) {
dispatch(getAgentPoliciesAction.get());
}
dispatch(getServiceLocations());
}, [dispatch]);
}, [dispatch, canReadAgentPolicies]);
const handleSubmit = (formData: PrivateLocation) => {
onSubmit(formData);

View file

@ -43,11 +43,15 @@ export const PolicyName = ({ agentPolicyId }: { agentPolicyId: string }) => {
) : (
agentPolicyId
)}
&nbsp; &nbsp;
<EuiBadge color={policy?.agents === 0 ? 'warning' : 'hollow'}>
{AGENTS_LABEL}
{policy?.agents}
</EuiBadge>
{canReadAgentPolicies && (
<>
&nbsp; &nbsp;
<EuiBadge color={policy?.agents === 0 ? 'warning' : 'hollow'}>
{AGENTS_LABEL}
{policy?.agents}
</EuiBadge>
</>
)}
</EuiText>
);
};

View file

@ -52,11 +52,9 @@ describe('useLocationName', () => {
{ wrapper: WrapperWithState }
);
expect(result.current).toEqual({
geo: { lat: 41.25, lon: -95.86 },
id: 'us_central',
isServiceManaged: true,
label: 'US Central',
status: 'ga',
url: 'mockUrl',
});
});

View file

@ -18,3 +18,4 @@ export * from './monitor_details';
export * from './overview';
export * from './browser_journey';
export * from './ping_status';
export * from './private_locations';

View file

@ -14,7 +14,7 @@ import {
RenderOptions,
} from '@testing-library/react';
import { Router, Route } from 'react-router-dom';
import { merge } from 'lodash';
import { merge, mergeWith } from 'lodash';
import { createMemoryHistory, History } from 'history';
import { CoreStart } from '@kbn/core/public';
import { I18nProvider } from '@kbn/i18n-react';
@ -225,7 +225,11 @@ export function WrappedHelper<ExtraCore>({
path,
history = createMemoryHistory(),
}: RenderRouterOptions<ExtraCore> & { children: ReactElement; useRealStore?: boolean }) {
const testState: AppState = merge({}, mockState, state);
const testState: AppState = mergeWith({}, mockState, state, (objValue, srcValue) => {
if (Array.isArray(objValue)) {
return srcValue;
}
});
if (url) {
history = getHistoryFromUrl(url);

View file

@ -8,11 +8,11 @@
import { renderHook } from '@testing-library/react-hooks';
import { defaultCore, WrappedHelper } from '../../../../../apps/synthetics/utils/testing';
import { useLocationsAPI } from './use_locations_api';
import { usePrivateLocationsAPI } from './use_locations_api';
describe('useLocationsAPI', () => {
describe('usePrivateLocationsAPI', () => {
it('returns expected results', () => {
const { result } = renderHook(() => useLocationsAPI({ isOpen: false }), {
const { result } = renderHook(() => usePrivateLocationsAPI({ isOpen: false }), {
wrapper: WrappedHelper,
});
@ -38,9 +38,12 @@ describe('useLocationsAPI', () => {
},
});
it('returns expected results after data', async () => {
const { result, waitForNextUpdate } = renderHook(() => useLocationsAPI({ isOpen: true }), {
wrapper: WrappedHelper,
});
const { result, waitForNextUpdate } = renderHook(
() => usePrivateLocationsAPI({ isOpen: true }),
{
wrapper: WrappedHelper,
}
);
expect(result.current).toEqual(
expect.objectContaining({
@ -65,9 +68,12 @@ describe('useLocationsAPI', () => {
});
it('adds location on submit', async () => {
const { result, waitForNextUpdate } = renderHook(() => useLocationsAPI({ isOpen: true }), {
wrapper: WrappedHelper,
});
const { result, waitForNextUpdate } = renderHook(
() => usePrivateLocationsAPI({ isOpen: true }),
{
wrapper: WrappedHelper,
}
);
await waitForNextUpdate();
@ -121,9 +127,12 @@ describe('useLocationsAPI', () => {
},
});
const { result, waitForNextUpdate } = renderHook(() => useLocationsAPI({ isOpen: true }), {
wrapper: WrappedHelper,
});
const { result, waitForNextUpdate } = renderHook(
() => usePrivateLocationsAPI({ isOpen: true }),
{
wrapper: WrappedHelper,
}
);
await waitForNextUpdate();

View file

@ -14,7 +14,7 @@ import {
getSyntheticsPrivateLocations,
} from '../../../../state/private_locations/api';
export const useLocationsAPI = ({ isOpen }: { isOpen: boolean }) => {
export const usePrivateLocationsAPI = ({ isOpen }: { isOpen: boolean }) => {
const [formData, setFormData] = useState<PrivateLocation>();
const [deleteId, setDeleteId] = useState<string>();
const [privateLocations, setPrivateLocations] = useState<PrivateLocation[]>([]);

View file

@ -67,7 +67,7 @@ export const LocationForm = ({
{
<FormattedMessage
id="xpack.synthetics.monitorManagement.agentCallout.content"
defaultMessage='If you intend to run "Browser" monitors on this private location, please ensure you are using the {code} Docker container, which contains the dependencies to run these monitors. For more information, {link}.'
defaultMessage='To run "Browser" monitors on this private location, make sure that you&apos;re using the {code} Docker container, which contains the dependencies necessary to run these monitors. For more information, {link}.'
values={{
code: <EuiCode>elastic-agent-complete</EuiCode>,
link: (

View file

@ -27,7 +27,7 @@ import { AddLocationFlyout } from './add_location_flyout';
import { ClientPluginsStart } from '../../../../plugin';
import { getServiceLocations } from '../../../state/actions';
import { PrivateLocationsList } from './locations_list';
import { useLocationsAPI } from './hooks/use_locations_api';
import { usePrivateLocationsAPI } from './hooks/use_locations_api';
import {
getAgentPoliciesAction,
selectAddingNewPrivateLocation,
@ -49,7 +49,7 @@ export const ManageLocationsFlyout = () => {
const setIsAddingNew = (val: boolean) => dispatch(setAddingNewPrivateLocation(val));
const { onSubmit, loading, privateLocations, onDelete } = useLocationsAPI({
const { onSubmit, loading, privateLocations, onDelete } = usePrivateLocationsAPI({
isOpen,
});