diff --git a/x-pack/plugins/ingest_manager/public/index.ts b/x-pack/plugins/ingest_manager/public/index.ts index 75ba0e584230f..730ab59c3eb19 100644 --- a/x-pack/plugins/ingest_manager/public/index.ts +++ b/x-pack/plugins/ingest_manager/public/index.ts @@ -20,3 +20,5 @@ export { export { NewPackagePolicy } from './applications/ingest_manager/types'; export * from './applications/ingest_manager/types/intra_app_route_state'; + +export { pagePathGetters } from './applications/ingest_manager/constants'; diff --git a/x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts b/x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts index 65375a076e9a4..47108508ec68a 100644 --- a/x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts +++ b/x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts @@ -14,7 +14,11 @@ import { PostAgentEnrollRequest, PostAgentEnrollResponse, } from '../../common/types'; +import * as kibanaPackage from '../../package.json'; +// @ts-ignore +// Using the ts-ignore because we are importing directly from a json to a script file +const version = kibanaPackage.version; const CHECKIN_INTERVAL = 3000; // 3 seconds type Agent = Pick<_Agent, 'id' | 'access_api_key'>; @@ -104,6 +108,7 @@ async function enroll(kibanaURL: string, apiKey: string, log: ToolingLog): Promi ip: '127.0.0.1', system: `${os.type()} ${os.release()}`, memory: os.totalmem(), + elastic: { agent: { version } }, }, user_provided: { dev_agent_version: '0.0.1', diff --git a/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts b/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts index 190009440529c..943b30925a54c 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts @@ -12,7 +12,7 @@ type NavigateToAppHandlerOptions = NavigateToAppOptions & { state?: S; onClick?: EventHandlerCallback; }; -type EventHandlerCallback = MouseEventHandler; +type EventHandlerCallback = MouseEventHandler; /** * Provides an event handlers that can be used with (for example) `onClick` to prevent the diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index 84d09adfc295e..c2a838404b0bb 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -82,6 +82,11 @@ interface ServerReturnedEndpointNonExistingPolicies { payload: EndpointState['nonExistingPolicies']; } +interface ServerReturnedEndpointAgentPolicies { + type: 'serverReturnedEndpointAgentPolicies'; + payload: EndpointState['agentPolicies']; +} + interface ServerReturnedEndpointExistValue { type: 'serverReturnedEndpointExistValue'; payload: boolean; @@ -126,4 +131,5 @@ export type EndpointAction = | ServerFailedToReturnMetadataPatterns | AppRequestedEndpointList | ServerReturnedEndpointNonExistingPolicies + | ServerReturnedEndpointAgentPolicies | UserUpdatedEndpointListRefreshOptions; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index f28ae9bf55ab2..4faef85afbdc8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -52,6 +52,7 @@ describe('EndpointList store concerns', () => { policyItemsLoading: false, endpointPackageInfo: undefined, nonExistingPolicies: {}, + agentPolicies: {}, endpointsExist: true, patterns: [], patternsError: undefined, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 5bf085023c65d..7673702f54370 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -18,7 +18,7 @@ import { patterns, searchBarQuery, } from './selectors'; -import { EndpointState } from '../types'; +import { EndpointState, PolicyIds } from '../types'; import { sendGetEndpointSpecificPackagePolicies, sendGetEndpointSecurityPackage, @@ -105,15 +105,21 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory => { +): Promise => { if (hosts.length === 0) { return; } @@ -318,29 +336,38 @@ const getNonExistingPoliciesForEndpointsList = async ( )})`, }, }) - ).items.reduce((list, agentPolicy) => { - (agentPolicy.package_policies as string[]).forEach((packagePolicy) => { - list[packagePolicy as string] = true; - }); - return list; - }, {}); + ).items.reduce( + (list, agentPolicy) => { + (agentPolicy.package_policies as string[]).forEach((packagePolicy) => { + list.packagePolicy[packagePolicy as string] = true; + list.agentPolicy[packagePolicy as string] = agentPolicy.id; + }); + return list; + }, + { packagePolicy: {}, agentPolicy: {} } + ); - const nonExisting = policyIdsToCheck.reduce( - (list, policyId) => { - if (policiesFound[policyId]) { + // packagePolicy contains non-existing packagePolicy ids whereas agentPolicy contains existing agentPolicy ids + const nonExistingPackagePoliciesAndExistingAgentPolicies = policyIdsToCheck.reduce( + (list, policyId: string) => { + if (policiesFound.packagePolicy[policyId as string]) { + list.agentPolicy[policyId as string] = policiesFound.agentPolicy[policyId]; return list; } - list[policyId] = true; + list.packagePolicy[policyId as string] = true; return list; }, - {} + { packagePolicy: {}, agentPolicy: {} } ); - if (Object.keys(nonExisting).length === 0) { + if ( + Object.keys(nonExistingPackagePoliciesAndExistingAgentPolicies.packagePolicy).length === 0 && + Object.keys(nonExistingPackagePoliciesAndExistingAgentPolicies.agentPolicy).length === 0 + ) { return; } - return nonExisting; + return nonExistingPackagePoliciesAndExistingAgentPolicies; }; const doEndpointsExist = async (http: HttpStart): Promise => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts index cfde474c6290d..c5363a5ae9522 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts @@ -20,6 +20,7 @@ import { } from '../../policy/store/policy_list/services/ingest'; import { GetAgentPoliciesResponse, + GetAgentPoliciesResponseItem, GetPackagesResponse, } from '../../../../../../ingest_manager/common/types/rest_spec'; import { GetPolicyListResponse } from '../../policy/types'; @@ -43,7 +44,7 @@ export const mockEndpointResultList: (options?: { // total - numberToSkip is the count of non-skipped ones, but return no more than a pageSize, and no less than 0 const actualCountToReturn = Math.max(Math.min(total - numberToSkip, requestPageSize), 0); - const hosts = []; + const hosts: HostInfo[] = []; for (let index = 0; index < actualCountToReturn; index++) { hosts.push({ metadata: generator.generateHostMetadata(), @@ -78,12 +79,14 @@ const endpointListApiPathHandlerMocks = ({ epmPackages = [generator.generateEpmPackage()], endpointPackagePolicies = [], policyResponse = generator.generatePolicyResponse(), + agentPolicy = generator.generateAgentPolicy(), }: { /** route handlers will be setup for each individual host in this array */ endpointsResults?: HostResultList['hosts']; epmPackages?: GetPackagesResponse['response']; endpointPackagePolicies?: GetPolicyListResponse['items']; policyResponse?: HostPolicyResponse; + agentPolicy?: GetAgentPoliciesResponseItem; } = {}) => { const apiHandlers = { // endpoint package info @@ -106,7 +109,6 @@ const endpointListApiPathHandlerMocks = ({ // Do policies referenced in endpoint list exist // just returns 1 single agent policy that includes all of the packagePolicy IDs provided [INGEST_API_AGENT_POLICIES]: (): GetAgentPoliciesResponse => { - const agentPolicy = generator.generateAgentPolicy(); (agentPolicy.package_policies as string[]).push( ...endpointPackagePolicies.map((packagePolicy) => packagePolicy.id) ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index d688fa3b76b5a..99a1df7eb4002 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -30,6 +30,7 @@ export const initialEndpointListState: Immutable = { policyItemsLoading: false, endpointPackageInfo: undefined, nonExistingPolicies: {}, + agentPolicies: {}, endpointsExist: true, patterns: [], patternsError: undefined, @@ -72,6 +73,14 @@ export const endpointListReducer: ImmutableReducer = ( ...action.payload, }, }; + } else if (action.type === 'serverReturnedEndpointAgentPolicies') { + return { + ...state, + agentPolicies: { + ...state.agentPolicies, + ...action.payload, + }, + }; } else if (action.type === 'serverReturnedMetadataPatterns') { // handle error case return { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 8eefcc271794a..852bc9791fc90 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -217,6 +217,13 @@ export const nonExistingPolicies: ( state: Immutable ) => Immutable = (state) => state.nonExistingPolicies; +/** + * returns the list of known existing agent policies + */ +export const agentPolicies: ( + state: Immutable +) => Immutable = (state) => state.agentPolicies; + /** * Return boolean that indicates whether endpoints exist * @param state diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index b73e60718d12e..77f21243ea120 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -51,8 +51,10 @@ export interface EndpointState { selectedPolicyId?: string; /** Endpoint package info */ endpointPackageInfo?: GetPackagesResponse['response'][0]; - /** tracks the list of policies IDs used in Host metadata that may no longer exist */ - nonExistingPolicies: Record; + /** Tracks the list of policies IDs used in Host metadata that may no longer exist */ + nonExistingPolicies: PolicyIds['packagePolicy']; + /** List of Package Policy Ids mapped to an associated Fleet Parent Agent Policy Id*/ + agentPolicies: PolicyIds['agentPolicy']; /** Tracks whether hosts exist and helps control if onboarding should be visible */ endpointsExist: boolean; /** index patterns for query bar */ @@ -65,6 +67,15 @@ export interface EndpointState { autoRefreshInterval: number; } +/** + * packagePolicy contains a list of Package Policy IDs (received via Endpoint metadata policy response) mapped to a boolean whether they exist or not. + * agentPolicy contains a list of existing Package Policy Ids mapped to an associated Fleet parent Agent Config. + */ +export interface PolicyIds { + packagePolicy: Record; + agentPolicy: Record; +} + /** * Query params on the host page parsed from the URL */ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 6e37367930466..14167f25d5b90 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -6,7 +6,6 @@ import React from 'react'; import * as reactTestingLibrary from '@testing-library/react'; - import { EndpointList } from './index'; import '../../../../common/mock/match_media.ts'; import { @@ -669,4 +668,98 @@ describe('when on the list page', () => { }); }); }); + + describe('when the more actions column is opened', () => { + let hostInfo: HostInfo; + let agentId: string; + let agentPolicyId: string; + const generator = new EndpointDocGenerator('seed'); + let renderAndWaitForData: () => Promise>; + + const mockEndpointListApi = () => { + const { hosts } = mockEndpointResultList(); + hostInfo = { + host_status: hosts[0].host_status, + metadata: hosts[0].metadata, + }; + const packagePolicy = docGenerator.generatePolicyPackagePolicy(); + packagePolicy.id = hosts[0].metadata.Endpoint.policy.applied.id; + const agentPolicy = generator.generateAgentPolicy(); + agentPolicyId = agentPolicy.id; + agentId = hosts[0].metadata.elastic.agent.id; + + setEndpointListApiMockImplementation(coreStart.http, { + endpointsResults: [hostInfo], + endpointPackagePolicies: [packagePolicy], + agentPolicy, + }); + }; + + beforeEach(() => { + mockEndpointListApi(); + + reactTestingLibrary.act(() => { + history.push('/endpoints'); + }); + + renderAndWaitForData = async () => { + const renderResult = render(); + await middlewareSpy.waitForAction('serverReturnedEndpointList'); + await middlewareSpy.waitForAction('serverReturnedEndpointAgentPolicies'); + return renderResult; + }; + + coreStart.application.getUrlForApp.mockImplementation((appName) => { + switch (appName) { + case 'securitySolution': + return '/app/security'; + case 'ingestManager': + return '/app/ingestManager'; + } + return appName; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('navigates to the Security Solution Host Details page', async () => { + const renderResult = await renderAndWaitForData(); + // open the endpoint actions menu + const endpointActionsButton = await renderResult.findByTestId('endpointTableRowActions'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(endpointActionsButton); + }); + + const hostLink = await renderResult.findByTestId('hostLink'); + expect(hostLink.getAttribute('href')).toEqual( + `/app/security/hosts/${hostInfo.metadata.host.hostname}` + ); + }); + it('navigates to the Ingest Agent Policy page', async () => { + const renderResult = await renderAndWaitForData(); + const endpointActionsButton = await renderResult.findByTestId('endpointTableRowActions'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(endpointActionsButton); + }); + + const agentPolicyLink = await renderResult.findByTestId('agentPolicyLink'); + expect(agentPolicyLink.getAttribute('href')).toEqual( + `/app/ingestManager#/policies/${agentPolicyId}` + ); + }); + it('navigates to the Ingest Agent Details page', async () => { + const renderResult = await renderAndWaitForData(); + const endpointActionsButton = await renderResult.findByTestId('endpointTableRowActions'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(endpointActionsButton); + }); + + const agentDetailsLink = await renderResult.findByTestId('agentDetailsLink'); + expect(agentDetailsLink.getAttribute('href')).toEqual( + `/app/ingestManager#/fleet/agents/${agentId}` + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 378f3cc4cb316..166f1660bf3d6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useCallback, memo } from 'react'; +import React, { useMemo, useCallback, memo, useState } from 'react'; import { EuiHorizontalRule, EuiBasicTable, @@ -16,6 +16,11 @@ import { EuiSelectableProps, EuiSuperDatePicker, EuiSpacer, + EuiPopover, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiContextMenuPanelProps, + EuiButtonIcon, EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; @@ -24,6 +29,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { createStructuredSelector } from 'reselect'; import { useDispatch } from 'react-redux'; +import { EuiContextMenuItemProps } from '@elastic/eui/src/components/context_menu/context_menu_item'; +import { NavigateToAppOptions } from 'kibana/public'; import { EndpointDetailsFlyout } from './details'; import * as selectors from '../store/selectors'; import { useEndpointSelector } from './hooks'; @@ -42,6 +49,7 @@ import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/ import { CreatePackagePolicyRouteState, AgentPolicyDetailsDeployAgentAction, + pagePathGetters, } from '../../../../../../ingest_manager/public'; import { SecurityPageName } from '../../../../app/types'; import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/routing'; @@ -50,6 +58,8 @@ import { EndpointAction } from '../store/action'; import { EndpointPolicyLink } from './components/endpoint_policy_link'; import { AdminSearchBar } from './components/search_bar'; import { AdministrationListPage } from '../../../components/administration_list_page'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { APP_ID } from '../../../../../common/constants'; const EndpointListNavLink = memo<{ name: string; @@ -73,9 +83,40 @@ const EndpointListNavLink = memo<{ }); EndpointListNavLink.displayName = 'EndpointListNavLink'; +const TableRowActions = memo<{ + items: EuiContextMenuPanelProps['items']; +}>(({ items }) => { + const [isOpen, setIsOpen] = useState(false); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); + const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); + + return ( + + } + isOpen={isOpen} + closePopover={handleCloseMenu} + > + + + ); +}); +TableRowActions.displayName = 'EndpointTableRowActions'; + const selector = (createStructuredSelector as CreateStructuredSelector)(selectors); export const EndpointList = () => { const history = useHistory(); + const { services } = useKibana(); const { listData, pageIndex, @@ -90,6 +131,7 @@ export const EndpointList = () => { policyItemsLoading, endpointPackageVersion, endpointsExist, + agentPolicies, autoRefreshInterval, isAutoRefreshEnabled, patternsError, @@ -350,8 +392,87 @@ export const EndpointList = () => { ); }, }, + { + field: '', + name: i18n.translate('xpack.securitySolution.endpoint.list.actions', { + defaultMessage: 'Actions', + }), + actions: [ + { + // eslint-disable-next-line react/display-name + render: (item: HostInfo) => { + return ( + + + , + + + , + + + , + ]} + /> + ); + }, + }, + ], + }, ]; - }, [formatUrl, queryParams, search]); + }, [formatUrl, queryParams, search, agentPolicies, services?.application?.getUrlForApp]); const renderTableOrEmptyState = useMemo(() => { if (endpointsExist) { @@ -467,3 +588,20 @@ export const EndpointList = () => { ); }; + +const EuiContextMenuItemNavByRouter = memo< + Omit & { + navigateAppId: string; + navigateOptions: NavigateToAppOptions; + children: React.ReactNode; + } +>(({ navigateAppId, navigateOptions, children, ...otherMenuItemProps }) => { + const handleOnClick = useNavigateToAppEventHandler(navigateAppId, navigateOptions); + + return ( + + {children} + + ); +}); +EuiContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter'; diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index 00b4b82f9d602..b0b8d14108004 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -28,6 +28,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'IP Address', 'Version', 'Last Active', + 'Actions', ], [ 'rezzani-7.example.com', @@ -38,6 +39,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '10.101.149.26, 2606:a000:ffc0:39:11ef:37b9:3371:578c', '6.8.0', 'Jan 24, 2020 @ 16:06:09.541', + '', ], [ 'cadmann-4.example.com', @@ -48,6 +50,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '10.192.213.130, 10.70.28.129', '6.6.1', 'Jan 24, 2020 @ 16:06:09.541', + '', ], [ 'thurlow-9.example.com', @@ -58,6 +61,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '10.46.229.234', '6.0.0', 'Jan 24, 2020 @ 16:06:09.541', + '', ], ]; @@ -238,6 +242,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'IP Address', 'Version', 'Last Active', + 'Actions', ], ['No items found'], ]; @@ -268,6 +273,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'IP Address', 'Version', 'Last Active', + 'Actions', ], [ 'cadmann-4.example.com', @@ -278,6 +284,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '10.192.213.130, 10.70.28.129', '6.6.1', 'Jan 24, 2020 @ 16:06:09.541', + '', ], [ 'thurlow-9.example.com', @@ -288,6 +295,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '10.46.229.234', '6.0.0', 'Jan 24, 2020 @ 16:06:09.541', + '', ], ];