[Security Solution][Endpoint] Add ability to isolate the Host from the Endpoint Details flyout (#100482)
* Add un-isolate form to the endpoint flyout * Add Endpoint details flyout footer and action button * Refactor hooks into a directory * Refactor endpoint list actions into reusable list + add it to Take action on details * Refactor Endpoint list row actions to use new common hook for items * generate different values for isolation in endpoint generator * move `isEndpointHostIsolated()` utility to a common folder * refactor detections to also use common `isEndpointHostIsolated()` * httpHandlerMockFactory can now handle API paths with params (`{id}`) * Initial set of re-usable http mocks for endpoint hosts set of pages * fix bug in `composeHttpHandlerMocks()` * small improvements to test utilities * Show API errors for isolate in Form standard place
This commit is contained in:
parent
690e81aa60
commit
36996634c3
|
@ -468,14 +468,23 @@ export type HostMetadata = Immutable<{
|
|||
id: string;
|
||||
status: HostPolicyResponseActionStatus;
|
||||
name: string;
|
||||
/** The endpoint integration policy revision number in kibana */
|
||||
endpoint_policy_version: number;
|
||||
version: number;
|
||||
};
|
||||
};
|
||||
configuration: {
|
||||
/**
|
||||
* Shows whether the endpoint is set up to be isolated. (e.g. a user has isolated a host,
|
||||
* and the endpoint successfully received that action and applied the setting)
|
||||
*/
|
||||
isolation?: boolean;
|
||||
};
|
||||
state: {
|
||||
/**
|
||||
* Shows what the current state of the host is. This could differ from `Endpoint.configuration.isolation`
|
||||
* in some cases, but normally they will match
|
||||
*/
|
||||
isolation?: boolean;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -23,6 +23,7 @@ import { ExperimentalFeatures } from '../../../../common/experimental_features';
|
|||
import { PLUGIN_ID } from '../../../../../fleet/common';
|
||||
import { APP_ID } from '../../../../common/constants';
|
||||
import { KibanaContextProvider } from '../../lib/kibana';
|
||||
import { MANAGEMENT_APP_ID } from '../../../management/common/constants';
|
||||
|
||||
type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
|
||||
|
||||
|
@ -156,6 +157,8 @@ const createCoreStartMock = (): ReturnType<typeof coreMock.createStart> => {
|
|||
return '/app/fleet';
|
||||
case APP_ID:
|
||||
return '/app/security';
|
||||
case MANAGEMENT_APP_ID:
|
||||
return '/app/security/administration';
|
||||
default:
|
||||
return `${appId} not mocked!`;
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import type {
|
|||
HttpHandler,
|
||||
HttpStart,
|
||||
} from 'kibana/public';
|
||||
import { extend } from 'lodash';
|
||||
import { merge } from 'lodash';
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
class ApiRouteNotMocked extends Error {}
|
||||
|
@ -159,6 +159,11 @@ export const httpHandlerMockFactory = <R extends ResponseProvidersInterface = {}
|
|||
}
|
||||
};
|
||||
|
||||
// For debugging purposes.
|
||||
// It will provide a stack trace leading back to the location in the test file
|
||||
// where the `core.http` mocks were applied from.
|
||||
const testContextStackTrace = new Error('HTTP MOCK APPLIED FROM:').stack;
|
||||
|
||||
const responseProvider: MockedApi<R>['responseProvider'] = mocks.reduce(
|
||||
(providers, routeMock) => {
|
||||
// FIXME: find a way to remove the ignore below. May need to limit the calling signature of `RouteMock['handler']`
|
||||
|
@ -195,7 +200,7 @@ export const httpHandlerMockFactory = <R extends ResponseProvidersInterface = {}
|
|||
|
||||
http[method].mockImplementation(async (...args) => {
|
||||
const path = isHttpFetchOptionsWithPath(args[0]) ? args[0].path : args[0];
|
||||
const routeMock = methodMocks.find((handler) => handler.path === path);
|
||||
const routeMock = methodMocks.find((handler) => pathMatchesPattern(handler.path, path));
|
||||
|
||||
if (routeMock) {
|
||||
markApiCallAsHandled(responseProvider[routeMock.id].mockDelay);
|
||||
|
@ -211,6 +216,9 @@ export const httpHandlerMockFactory = <R extends ResponseProvidersInterface = {}
|
|||
}
|
||||
|
||||
const err = new ApiRouteNotMocked(`API [${method.toUpperCase()} ${path}] is not MOCKED!`);
|
||||
// Append additional stack calling data from when this API mock was applied
|
||||
err.stack += `\n${testContextStackTrace}`;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
throw err;
|
||||
|
@ -221,6 +229,29 @@ export const httpHandlerMockFactory = <R extends ResponseProvidersInterface = {}
|
|||
};
|
||||
};
|
||||
|
||||
const pathMatchesPattern = (pathPattern: string, path: string): boolean => {
|
||||
// No path params - pattern is single path
|
||||
if (pathPattern === path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If pathPattern has params (`{value}`), then see if `path` matches it
|
||||
if (/{.*?}/.test(pathPattern)) {
|
||||
const pathParts = path.split(/\//);
|
||||
const patternParts = pathPattern.split(/\//);
|
||||
|
||||
if (pathParts.length !== patternParts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pathParts.every((part, index) => {
|
||||
return part === patternParts[index] || /{.*?}/.test(patternParts[index]);
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isHttpFetchOptionsWithPath = (
|
||||
opt: string | HttpFetchOptions | HttpFetchOptionsWithPath
|
||||
): opt is HttpFetchOptionsWithPath => {
|
||||
|
@ -235,12 +266,14 @@ const isHttpFetchOptionsWithPath = (
|
|||
* @example
|
||||
* import { composeApiHandlerMocks } from './http_handler_mock_factory';
|
||||
* import {
|
||||
* FleetSetupApiMockInterface,
|
||||
* fleetSetupApiMock,
|
||||
* AgentsSetupApiMockInterface,
|
||||
* agentsSetupApiMock,
|
||||
* } from './setup';
|
||||
*
|
||||
* // Create the new interface as an intersection of all other Api Handler Mocks
|
||||
* type ComposedApiHandlerMocks = ReturnType<typeof agentsSetupApiMock> & ReturnType<typeof fleetSetupApiMock>
|
||||
* // Create the new interface as an intersection of all other Api Handler Mock's interfaces
|
||||
* type ComposedApiHandlerMocks = AgentsSetupApiMockInterface & FleetSetupApiMockInterface
|
||||
*
|
||||
* const newComposedHandlerMock = composeApiHandlerMocks<
|
||||
* ComposedApiHandlerMocks
|
||||
|
@ -267,7 +300,7 @@ export const composeHttpHandlerMocks = <
|
|||
|
||||
handlerMocks.forEach((handlerMock) => {
|
||||
const { waitForApi, ...otherInterfaceProps } = handlerMock(http);
|
||||
extend(mockedApiInterfaces, otherInterfaceProps);
|
||||
merge(mockedApiInterfaces, otherInterfaceProps);
|
||||
});
|
||||
|
||||
return mockedApiInterfaces;
|
||||
|
|
|
@ -89,7 +89,9 @@ export const createSpyMiddleware = <
|
|||
type ResolvedAction = A extends { type: typeof actionType } ? A : never;
|
||||
|
||||
// Error is defined here so that we get a better stack trace that points to the test from where it was used
|
||||
const err = new Error(`action '${actionType}' was not dispatched within the allocated time`);
|
||||
const err = new Error(
|
||||
`Timeout! Action '${actionType}' was not dispatched within the allocated time`
|
||||
);
|
||||
|
||||
return new Promise<ResolvedAction>((resolve, reject) => {
|
||||
const watch: ActionWatcher = (action) => {
|
||||
|
@ -108,7 +110,10 @@ export const createSpyMiddleware = <
|
|||
const timeout = setTimeout(() => {
|
||||
watchers.delete(watch);
|
||||
reject(err);
|
||||
// TODO: is there a way we can grab the current timeout value from jest?
|
||||
// For now, this is using the default value (5000ms) - 500.
|
||||
}, 4500);
|
||||
|
||||
watchers.add(watch);
|
||||
});
|
||||
},
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
|
||||
export * from './is_endpoint_host_isolated';
|
||||
|
||||
const urlExpression = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi;
|
||||
|
||||
export const isUrlInvalid = (url: string | null | undefined) => {
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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 { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
|
||||
import { HostMetadata } from '../../../../common/endpoint/types';
|
||||
import { isEndpointHostIsolated } from './is_endpoint_host_isolated';
|
||||
|
||||
describe('When using isEndpointHostIsolated()', () => {
|
||||
const generator = new EndpointDocGenerator();
|
||||
|
||||
const generateMetadataDoc = (isolation: boolean = true) => {
|
||||
const metadataDoc = generator.generateHostMetadata() as HostMetadata;
|
||||
return {
|
||||
...metadataDoc,
|
||||
Endpoint: {
|
||||
...metadataDoc.Endpoint,
|
||||
state: {
|
||||
...metadataDoc.Endpoint.state,
|
||||
isolation,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it('Returns `true` when endpoint is isolated', () => {
|
||||
expect(isEndpointHostIsolated(generateMetadataDoc())).toBe(true);
|
||||
});
|
||||
|
||||
it('Returns `false` when endpoint is isolated', () => {
|
||||
expect(isEndpointHostIsolated(generateMetadataDoc(false))).toBe(false);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* 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 { HostMetadata } from '../../../../common/endpoint/types';
|
||||
|
||||
/**
|
||||
* Given an endpoint host metadata record (`HostMetadata`), this utility will validate if
|
||||
* that host is isolated
|
||||
* @param endpointMetadata
|
||||
*/
|
||||
export const isEndpointHostIsolated = (endpointMetadata: HostMetadata): boolean => {
|
||||
return Boolean(endpointMetadata.Endpoint.state.isolation);
|
||||
};
|
|
@ -11,6 +11,7 @@ import { Maybe } from '../../../../../../observability/common/typings';
|
|||
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
|
||||
import { getHostMetadata } from './api';
|
||||
import { ISOLATION_STATUS_FAILURE } from './translations';
|
||||
import { isEndpointHostIsolated } from '../../../../common/utils/validators';
|
||||
|
||||
interface HostIsolationStatusResponse {
|
||||
loading: boolean;
|
||||
|
@ -36,7 +37,7 @@ export const useHostIsolationStatus = ({
|
|||
try {
|
||||
const metadataResponse = await getHostMetadata({ agentId });
|
||||
if (isMounted) {
|
||||
setIsIsolated(Boolean(metadataResponse.metadata.Endpoint.state.isolation));
|
||||
setIsIsolated(isEndpointHostIsolated(metadataResponse.metadata));
|
||||
}
|
||||
} catch (error) {
|
||||
addError(error.message, { title: ISOLATION_STATUS_FAILURE });
|
||||
|
|
|
@ -66,7 +66,7 @@ export const getEndpointListPath = (
|
|||
|
||||
export const getEndpointDetailsPath = (
|
||||
props: {
|
||||
name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate';
|
||||
name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate' | 'endpointUnIsolate';
|
||||
} & EndpointIndexUIQueryParams &
|
||||
EndpointDetailsUrlProps,
|
||||
search?: string
|
||||
|
@ -79,6 +79,9 @@ export const getEndpointDetailsPath = (
|
|||
case 'endpointIsolate':
|
||||
queryParams.show = 'isolate';
|
||||
break;
|
||||
case 'endpointUnIsolate':
|
||||
queryParams.show = 'unisolate';
|
||||
break;
|
||||
case 'endpointPolicyResponse':
|
||||
queryParams.show = 'policy_response';
|
||||
break;
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* 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 {
|
||||
composeHttpHandlerMocks,
|
||||
httpHandlerMockFactory,
|
||||
ResponseProvidersInterface,
|
||||
} from '../../../common/mock/endpoint/http_handler_mock_factory';
|
||||
import {
|
||||
HostInfo,
|
||||
HostPolicyResponse,
|
||||
HostResultList,
|
||||
HostStatus,
|
||||
MetadataQueryStrategyVersions,
|
||||
} from '../../../../common/endpoint/types';
|
||||
import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
|
||||
import {
|
||||
BASE_POLICY_RESPONSE_ROUTE,
|
||||
HOST_METADATA_GET_ROUTE,
|
||||
HOST_METADATA_LIST_ROUTE,
|
||||
} from '../../../../common/endpoint/constants';
|
||||
import { AGENT_POLICY_API_ROUTES, GetAgentPoliciesResponse } from '../../../../../fleet/common';
|
||||
|
||||
type EndpointMetadataHttpMocksInterface = ResponseProvidersInterface<{
|
||||
metadataList: () => HostResultList;
|
||||
metadataDetails: () => HostInfo;
|
||||
}>;
|
||||
export const endpointMetadataHttpMocks = httpHandlerMockFactory<EndpointMetadataHttpMocksInterface>(
|
||||
[
|
||||
{
|
||||
id: 'metadataList',
|
||||
path: HOST_METADATA_LIST_ROUTE,
|
||||
method: 'post',
|
||||
handler: () => {
|
||||
const generator = new EndpointDocGenerator('seed');
|
||||
|
||||
return {
|
||||
hosts: Array.from({ length: 10 }, () => {
|
||||
return {
|
||||
metadata: generator.generateHostMetadata(),
|
||||
host_status: HostStatus.UNHEALTHY,
|
||||
query_strategy_version: MetadataQueryStrategyVersions.VERSION_2,
|
||||
};
|
||||
}),
|
||||
total: 10,
|
||||
request_page_size: 10,
|
||||
request_page_index: 0,
|
||||
query_strategy_version: MetadataQueryStrategyVersions.VERSION_2,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'metadataDetails',
|
||||
path: HOST_METADATA_GET_ROUTE,
|
||||
method: 'get',
|
||||
handler: () => {
|
||||
const generator = new EndpointDocGenerator('seed');
|
||||
|
||||
return {
|
||||
metadata: generator.generateHostMetadata(),
|
||||
host_status: HostStatus.UNHEALTHY,
|
||||
query_strategy_version: MetadataQueryStrategyVersions.VERSION_2,
|
||||
};
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
type EndpointPolicyResponseHttpMockInterface = ResponseProvidersInterface<{
|
||||
policyResponse: () => HostPolicyResponse;
|
||||
}>;
|
||||
export const endpointPolicyResponseHttpMock = httpHandlerMockFactory<EndpointPolicyResponseHttpMockInterface>(
|
||||
[
|
||||
{
|
||||
id: 'policyResponse',
|
||||
path: BASE_POLICY_RESPONSE_ROUTE,
|
||||
method: 'get',
|
||||
handler: () => {
|
||||
return new EndpointDocGenerator('seed').generatePolicyResponse();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
type FleetApisHttpMockInterface = ResponseProvidersInterface<{
|
||||
agentPolicy: () => GetAgentPoliciesResponse;
|
||||
}>;
|
||||
export const fleetApisHttpMock = httpHandlerMockFactory<FleetApisHttpMockInterface>([
|
||||
{
|
||||
id: 'agentPolicy',
|
||||
path: AGENT_POLICY_API_ROUTES.LIST_PATTERN,
|
||||
method: 'get',
|
||||
handler: () => {
|
||||
const generator = new EndpointDocGenerator('seed');
|
||||
const endpointMetadata = generator.generateHostMetadata();
|
||||
const agentPolicy = generator.generateAgentPolicy();
|
||||
|
||||
// Make sure that the Agent policy returned from the API has the Integration Policy ID that
|
||||
// the endpoint metadata is using. This is needed especially when testing the Endpoint Details
|
||||
// flyout where certain actions might be disabled if we know the endpoint integration policy no
|
||||
// longer exists.
|
||||
(agentPolicy.package_policies as string[]).push(endpointMetadata.Endpoint.policy.applied.id);
|
||||
|
||||
return {
|
||||
items: [agentPolicy],
|
||||
perPage: 10,
|
||||
total: 1,
|
||||
page: 1,
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
type EndpointPageHttpMockInterface = EndpointMetadataHttpMocksInterface &
|
||||
EndpointPolicyResponseHttpMockInterface &
|
||||
FleetApisHttpMockInterface;
|
||||
/**
|
||||
* HTTP Mocks that support the Endpoint List and Details page
|
||||
*/
|
||||
export const endpointPageHttpMock = composeHttpHandlerMocks<EndpointPageHttpMockInterface>([
|
||||
endpointMetadataHttpMocks,
|
||||
endpointPolicyResponseHttpMock,
|
||||
fleetApisHttpMock,
|
||||
]);
|
|
@ -11,6 +11,7 @@ import {
|
|||
HostInfo,
|
||||
GetHostPolicyResponse,
|
||||
HostIsolationRequestBody,
|
||||
ISOLATION_ACTIONS,
|
||||
} from '../../../../../common/endpoint/types';
|
||||
import { ServerApiError } from '../../../../common/types';
|
||||
import { GetPolicyListResponse } from '../../policy/types';
|
||||
|
@ -137,7 +138,10 @@ export interface ServerFailedToReturnEndpointsTotal {
|
|||
}
|
||||
|
||||
export type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & {
|
||||
payload: HostIsolationRequestBody;
|
||||
payload: {
|
||||
type: ISOLATION_ACTIONS;
|
||||
data: HostIsolationRequestBody;
|
||||
};
|
||||
};
|
||||
|
||||
export type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & {
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
HostResultList,
|
||||
HostIsolationResponse,
|
||||
EndpointAction,
|
||||
ISOLATION_ACTIONS,
|
||||
} from '../../../../../common/endpoint/types';
|
||||
import { AppAction } from '../../../../common/store/actions';
|
||||
import { mockEndpointResultList } from './mock_endpoint_result_list';
|
||||
|
@ -135,10 +136,13 @@ describe('endpoint list middleware', () => {
|
|||
|
||||
describe('handling of IsolateEndpointHost action', () => {
|
||||
const getKibanaServicesMock = KibanaServices.get as jest.Mock;
|
||||
const dispatchIsolateEndpointHost = () => {
|
||||
const dispatchIsolateEndpointHost = (action: ISOLATION_ACTIONS = 'isolate') => {
|
||||
dispatch({
|
||||
type: 'endpointIsolationRequest',
|
||||
payload: hostIsolationRequestBodyMock(),
|
||||
payload: {
|
||||
type: action,
|
||||
data: hostIsolationRequestBodyMock(),
|
||||
},
|
||||
});
|
||||
};
|
||||
let isolateApiResponseHandlers: ReturnType<typeof hostIsolationHttpMocks>;
|
||||
|
@ -161,7 +165,24 @@ describe('endpoint list middleware', () => {
|
|||
|
||||
it('should call isolate api', async () => {
|
||||
dispatchIsolateEndpointHost();
|
||||
expect(fakeHttpServices.post).toHaveBeenCalled();
|
||||
await waitForAction('endpointIsolationRequestStateChange', {
|
||||
validate(action) {
|
||||
return isLoadedResourceState(action.payload);
|
||||
},
|
||||
});
|
||||
|
||||
expect(isolateApiResponseHandlers.responseProvider.isolateHost).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call unisolate api', async () => {
|
||||
dispatchIsolateEndpointHost('unisolate');
|
||||
await waitForAction('endpointIsolationRequestStateChange', {
|
||||
validate(action) {
|
||||
return isLoadedResourceState(action.payload);
|
||||
},
|
||||
});
|
||||
|
||||
expect(isolateApiResponseHandlers.responseProvider.unIsolateHost).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set Isolation state to loaded if api is successful', async () => {
|
||||
|
|
|
@ -51,7 +51,7 @@ import {
|
|||
createLoadedResourceState,
|
||||
createLoadingResourceState,
|
||||
} from '../../../state';
|
||||
import { isolateHost } from '../../../../common/lib/host_isolation';
|
||||
import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation';
|
||||
import { AppAction } from '../../../../common/store/actions';
|
||||
import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables';
|
||||
|
||||
|
@ -504,7 +504,13 @@ const handleIsolateEndpointHost = async (
|
|||
|
||||
try {
|
||||
// Cast needed below due to the value of payload being `Immutable<>`
|
||||
const response = await isolateHost(action.payload as HostIsolationRequestBody);
|
||||
let response: HostIsolationResponse;
|
||||
|
||||
if (action.payload.type === 'unisolate') {
|
||||
response = await unIsolateHost(action.payload.data as HostIsolationRequestBody);
|
||||
} else {
|
||||
response = await isolateHost(action.payload.data as HostIsolationRequestBody);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'endpointIsolationRequestStateChange',
|
||||
|
|
|
@ -32,6 +32,7 @@ import {
|
|||
isLoadingResourceState,
|
||||
} from '../../../state';
|
||||
import { ServerApiError } from '../../../../common/types';
|
||||
import { isEndpointHostIsolated } from '../../../../common/utils/validators';
|
||||
|
||||
export const listData = (state: Immutable<EndpointState>) => state.hosts;
|
||||
|
||||
|
@ -204,6 +205,14 @@ export const uiQueryParams: (
|
|||
'admin_query',
|
||||
];
|
||||
|
||||
const allowedShowValues: Array<EndpointIndexUIQueryParams['show']> = [
|
||||
'policy_response',
|
||||
'details',
|
||||
'isolate',
|
||||
'unisolate',
|
||||
'activity_log',
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
const value: string | undefined =
|
||||
typeof query[key] === 'string'
|
||||
|
@ -214,13 +223,8 @@ export const uiQueryParams: (
|
|||
|
||||
if (value !== undefined) {
|
||||
if (key === 'show') {
|
||||
if (
|
||||
value === 'policy_response' ||
|
||||
value === 'details' ||
|
||||
value === 'activity_log' ||
|
||||
value === 'isolate'
|
||||
) {
|
||||
data[key] = value;
|
||||
if (allowedShowValues.includes(value as EndpointIndexUIQueryParams['show'])) {
|
||||
data[key] = value as EndpointIndexUIQueryParams['show'];
|
||||
}
|
||||
} else {
|
||||
data[key] = value;
|
||||
|
@ -378,3 +382,7 @@ export const getActivityLogError: (
|
|||
return activityLog.error;
|
||||
}
|
||||
});
|
||||
|
||||
export const getIsEndpointHostIsolated = createSelector(detailsData, (details) => {
|
||||
return (details && isEndpointHostIsolated(details)) || false;
|
||||
});
|
||||
|
|
|
@ -114,7 +114,7 @@ export interface EndpointIndexUIQueryParams {
|
|||
/** Which page to show */
|
||||
page_index?: string;
|
||||
/** show the policy response or host details */
|
||||
show?: 'policy_response' | 'activity_log' | 'details' | 'isolate';
|
||||
show?: 'policy_response' | 'activity_log' | 'details' | 'isolate' | 'unisolate';
|
||||
/** Query text from search bar*/
|
||||
admin_query?: string;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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, { memo } from 'react';
|
||||
import { EuiContextMenuItem, EuiContextMenuItemProps } from '@elastic/eui';
|
||||
import { NavigateToAppOptions } from 'kibana/public';
|
||||
import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler';
|
||||
|
||||
export interface ContextMenuItemNavByRouterProps extends EuiContextMenuItemProps {
|
||||
navigateAppId: string;
|
||||
navigateOptions: NavigateToAppOptions;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Just like `EuiContextMenuItem`, but allows for additional props to be defined which will
|
||||
* allow navigation to a URL path via React Router
|
||||
*/
|
||||
export const ContextMenuItemNavByRouter = memo<ContextMenuItemNavByRouterProps>(
|
||||
({ navigateAppId, navigateOptions, onClick, children, ...otherMenuItemProps }) => {
|
||||
const handleOnClick = useNavigateToAppEventHandler(navigateAppId, {
|
||||
...navigateOptions,
|
||||
onClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiContextMenuItem {...otherMenuItemProps} onClick={handleOnClick}>
|
||||
{children}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -9,37 +9,31 @@ import React, { memo, useCallback, useMemo, useState } from 'react';
|
|||
import {
|
||||
EuiButtonIcon,
|
||||
EuiContextMenuPanel,
|
||||
EuiPopover,
|
||||
EuiContextMenuItemProps,
|
||||
EuiContextMenuPanelProps,
|
||||
EuiContextMenuItem,
|
||||
EuiPopover,
|
||||
EuiPopoverProps,
|
||||
} from '@elastic/eui';
|
||||
import { NavigateToAppOptions } from 'kibana/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler';
|
||||
import { ContextMenuItemNavByRouter } from './context_menu_item_nav_by_rotuer';
|
||||
import { HostMetadata } from '../../../../../../common/endpoint/types';
|
||||
import { useEndpointActionItems } from '../hooks';
|
||||
|
||||
export interface TableRowActionProps {
|
||||
items: Array<
|
||||
Omit<EuiContextMenuItemProps, 'onClick'> & {
|
||||
navigateAppId: string;
|
||||
navigateOptions: NavigateToAppOptions;
|
||||
children: React.ReactNode;
|
||||
key: string;
|
||||
}
|
||||
>;
|
||||
endpointMetadata: HostMetadata;
|
||||
}
|
||||
|
||||
export const TableRowActions = memo<TableRowActionProps>(({ items }) => {
|
||||
export const TableRowActions = memo<TableRowActionProps>(({ endpointMetadata }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const endpointActions = useEndpointActionItems(endpointMetadata);
|
||||
|
||||
const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]);
|
||||
const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]);
|
||||
|
||||
const menuItems: EuiContextMenuPanelProps['items'] = useMemo(() => {
|
||||
return items.map((itemProps) => {
|
||||
return <EuiContextMenuItemNavByRouter {...itemProps} onClick={handleCloseMenu} />;
|
||||
return endpointActions.map((itemProps) => {
|
||||
return <ContextMenuItemNavByRouter {...itemProps} onClick={handleCloseMenu} />;
|
||||
});
|
||||
}, [handleCloseMenu, items]);
|
||||
}, [handleCloseMenu, endpointActions]);
|
||||
|
||||
const panelProps: EuiPopoverProps['panelProps'] = useMemo(() => {
|
||||
return { 'data-test-subj': 'tableRowActionsMenuPanel' };
|
||||
|
@ -69,22 +63,4 @@ export const TableRowActions = memo<TableRowActionProps>(({ items }) => {
|
|||
});
|
||||
TableRowActions.displayName = 'EndpointTableRowActions';
|
||||
|
||||
const EuiContextMenuItemNavByRouter = memo<
|
||||
EuiContextMenuItemProps & {
|
||||
navigateAppId: string;
|
||||
navigateOptions: NavigateToAppOptions;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
>(({ navigateAppId, navigateOptions, onClick, children, ...otherMenuItemProps }) => {
|
||||
const handleOnClick = useNavigateToAppEventHandler(navigateAppId, {
|
||||
...navigateOptions,
|
||||
onClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiContextMenuItem {...otherMenuItemProps} onClick={handleOnClick}>
|
||||
{children}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
});
|
||||
EuiContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter';
|
||||
ContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter';
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* 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 {
|
||||
AppContextTestRender,
|
||||
createAppRootMockRenderer,
|
||||
} from '../../../../../../common/mock/endpoint';
|
||||
import { useKibana } from '../../../../../../common/lib/kibana';
|
||||
import { ActionsMenu } from './actions_menu';
|
||||
import React from 'react';
|
||||
import { act } from '@testing-library/react';
|
||||
import { endpointPageHttpMock } from '../../../mocks';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
|
||||
jest.mock('../../../../../../common/lib/kibana');
|
||||
|
||||
describe('When using the Endpoint Details Actions Menu', () => {
|
||||
let render: () => Promise<ReturnType<AppContextTestRender['render']>>;
|
||||
let coreStart: AppContextTestRender['coreStart'];
|
||||
let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction'];
|
||||
let renderResult: ReturnType<AppContextTestRender['render']>;
|
||||
let httpMocks: ReturnType<typeof endpointPageHttpMock>;
|
||||
|
||||
const setEndpointMetadataResponse = (isolation: boolean = false) => {
|
||||
const endpointHost = httpMocks.responseProvider.metadataDetails();
|
||||
// Safe to mutate this mocked data
|
||||
// @ts-ignore
|
||||
endpointHost.metadata.Endpoint.state.isolation = isolation;
|
||||
httpMocks.responseProvider.metadataDetails.mockReturnValue(endpointHost);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const mockedContext = createAppRootMockRenderer();
|
||||
|
||||
(useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices });
|
||||
coreStart = mockedContext.coreStart;
|
||||
waitForAction = mockedContext.middlewareSpy.waitForAction;
|
||||
httpMocks = endpointPageHttpMock(mockedContext.coreStart.http);
|
||||
|
||||
act(() => {
|
||||
mockedContext.history.push(
|
||||
'/endpoints?selected_endpoint=5fe11314-678c-413e-87a2-b4a3461878ee'
|
||||
);
|
||||
});
|
||||
|
||||
render = async () => {
|
||||
renderResult = mockedContext.render(<ActionsMenu />);
|
||||
|
||||
await act(async () => {
|
||||
await waitForAction('serverReturnedEndpointDetails');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId('endpointDetailsActionsButton'));
|
||||
});
|
||||
|
||||
return renderResult;
|
||||
};
|
||||
});
|
||||
|
||||
describe('and endpoint host is NOT isolated', () => {
|
||||
beforeEach(() => setEndpointMetadataResponse());
|
||||
|
||||
it.each([
|
||||
['Isolate host', 'isolateLink'],
|
||||
['View host details', 'hostLink'],
|
||||
['View agent policy', 'agentPolicyLink'],
|
||||
['View agent details', 'agentDetailsLink'],
|
||||
])('should display %s action', async (_, dataTestSubj) => {
|
||||
await render();
|
||||
expect(renderResult.getByTestId(dataTestSubj)).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Isolate host', 'isolateLink'],
|
||||
['View host details', 'hostLink'],
|
||||
['View agent policy', 'agentPolicyLink'],
|
||||
['View agent details', 'agentDetailsLink'],
|
||||
])(
|
||||
'should navigate via kibana `navigateToApp()` when %s is clicked',
|
||||
async (_, dataTestSubj) => {
|
||||
await render();
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId(dataTestSubj));
|
||||
});
|
||||
|
||||
expect(coreStart.application.navigateToApp).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('and endpoint host is isolated', () => {
|
||||
beforeEach(() => setEndpointMetadataResponse(true));
|
||||
|
||||
it('should display Unisolate action', async () => {
|
||||
await render();
|
||||
expect(renderResult.getByTestId('unIsolateLink')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should navigate via router when unisolate is clicked', async () => {
|
||||
await render();
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId('unIsolateLink'));
|
||||
});
|
||||
|
||||
expect(coreStart.application.navigateToApp).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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, { useState, useCallback, useMemo } from 'react';
|
||||
import { EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { useEndpointActionItems, useEndpointSelector } from '../../hooks';
|
||||
import { detailsData } from '../../../store/selectors';
|
||||
import { ContextMenuItemNavByRouter } from '../../components/context_menu_item_nav_by_rotuer';
|
||||
|
||||
export const ActionsMenu = React.memo<{}>(() => {
|
||||
const endpointDetails = useEndpointSelector(detailsData);
|
||||
const menuOptions = useEndpointActionItems(endpointDetails);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const closePopoverHandler = useCallback(() => {
|
||||
setIsPopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
const takeActionItems = useMemo(() => {
|
||||
return menuOptions.map((item) => {
|
||||
return <ContextMenuItemNavByRouter {...item} onClick={closePopoverHandler} />;
|
||||
});
|
||||
}, [closePopoverHandler, menuOptions]);
|
||||
|
||||
const takeActionButton = useMemo(() => {
|
||||
return (
|
||||
<EuiButton
|
||||
iconSide="right"
|
||||
fill
|
||||
iconType="arrowDown"
|
||||
data-test-subj="endpointDetailsActionsButton"
|
||||
onClick={() => {
|
||||
setIsPopoverOpen(!isPopoverOpen);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.detailsActions.buttonLabel"
|
||||
defaultMessage="Take action"
|
||||
/>
|
||||
</EuiButton>
|
||||
);
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
id="endpointDetailsActionsPanel"
|
||||
button={takeActionButton}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopoverHandler}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
>
|
||||
<EuiContextMenuPanel size="s" items={takeActionItems} />
|
||||
</EuiPopover>
|
||||
);
|
||||
});
|
||||
|
||||
ActionsMenu.displayName = 'ActionMenu';
|
|
@ -5,17 +5,19 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useState } from 'react';
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Dispatch } from 'redux';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiForm } from '@elastic/eui';
|
||||
import { HostMetadata } from '../../../../../../../common/endpoint/types';
|
||||
import { BackToEndpointDetailsFlyoutSubHeader } from './back_to_endpoint_details_flyout_subheader';
|
||||
import {
|
||||
EndpointIsolatedFormProps,
|
||||
EndpointIsolateForm,
|
||||
EndpointIsolateSuccess,
|
||||
EndpointUnisolateForm,
|
||||
} from '../../../../../../common/components/endpoint/host_isolation';
|
||||
import { FlyoutBodyNoTopPadding } from './flyout_body_no_top_padding';
|
||||
import { getEndpointDetailsPath } from '../../../../../common/routing';
|
||||
|
@ -25,18 +27,21 @@ import {
|
|||
getIsIsolationRequestPending,
|
||||
getWasIsolationRequestSuccessful,
|
||||
uiQueryParams,
|
||||
getIsEndpointHostIsolated,
|
||||
} from '../../../store/selectors';
|
||||
import { AppAction } from '../../../../../../common/store/actions';
|
||||
import { useToasts } from '../../../../../../common/lib/kibana';
|
||||
|
||||
export const EndpointIsolateFlyoutPanel = memo<{
|
||||
/**
|
||||
* Component handles both isolate and un-isolate for a given endpoint
|
||||
*/
|
||||
export const EndpointIsolationFlyoutPanel = memo<{
|
||||
hostMeta: HostMetadata;
|
||||
}>(({ hostMeta }) => {
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch<Dispatch<AppAction>>();
|
||||
const toast = useToasts();
|
||||
|
||||
const { show, ...queryParams } = useEndpointSelector(uiQueryParams);
|
||||
const isCurrentlyIsolated = useEndpointSelector(getIsEndpointHostIsolated);
|
||||
const isPending = useEndpointSelector(getIsIsolationRequestPending);
|
||||
const wasSuccessful = useEndpointSelector(getWasIsolationRequestSuccessful);
|
||||
const isolateError = useEndpointSelector(getIsolationRequestError);
|
||||
|
@ -45,6 +50,8 @@ export const EndpointIsolateFlyoutPanel = memo<{
|
|||
Parameters<EndpointIsolatedFormProps['onChange']>[0]
|
||||
>({ comment: '' });
|
||||
|
||||
const IsolationForm = isCurrentlyIsolated ? EndpointUnisolateForm : EndpointIsolateForm;
|
||||
|
||||
const handleCancel: EndpointIsolatedFormProps['onCancel'] = useCallback(() => {
|
||||
history.push(
|
||||
getEndpointDetailsPath({
|
||||
|
@ -59,11 +66,14 @@ export const EndpointIsolateFlyoutPanel = memo<{
|
|||
dispatch({
|
||||
type: 'endpointIsolationRequest',
|
||||
payload: {
|
||||
endpoint_ids: [hostMeta.agent.id],
|
||||
comment: formValues.comment,
|
||||
type: isCurrentlyIsolated ? 'unisolate' : 'isolate',
|
||||
data: {
|
||||
endpoint_ids: [hostMeta.agent.id],
|
||||
comment: formValues.comment,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [dispatch, formValues.comment, hostMeta.agent.id]);
|
||||
}, [dispatch, formValues.comment, hostMeta.agent.id, isCurrentlyIsolated]);
|
||||
|
||||
const handleChange: EndpointIsolatedFormProps['onChange'] = useCallback((changes) => {
|
||||
setFormValues((prevState) => {
|
||||
|
@ -74,12 +84,6 @@ export const EndpointIsolateFlyoutPanel = memo<{
|
|||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isolateError) {
|
||||
toast.addDanger(isolateError.message);
|
||||
}
|
||||
}, [isolateError, toast]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackToEndpointDetailsFlyoutSubHeader endpointId={hostMeta.agent.id} />
|
||||
|
@ -88,6 +92,7 @@ export const EndpointIsolateFlyoutPanel = memo<{
|
|||
{wasSuccessful ? (
|
||||
<EndpointIsolateSuccess
|
||||
hostName={hostMeta.host.name}
|
||||
isolateAction={isCurrentlyIsolated ? 'unisolateHost' : 'isolateHost'}
|
||||
completeButtonLabel={i18n.translate(
|
||||
'xpack.securitySolution.endpoint.hostIsolation.successProceedButton',
|
||||
{ defaultMessage: 'Return to endpoint details' }
|
||||
|
@ -95,17 +100,23 @@ export const EndpointIsolateFlyoutPanel = memo<{
|
|||
onComplete={handleCancel}
|
||||
/>
|
||||
) : (
|
||||
<EndpointIsolateForm
|
||||
comment={formValues.comment}
|
||||
isLoading={isPending}
|
||||
hostName={hostMeta.host.name}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<EuiForm
|
||||
isInvalid={!!isolateError}
|
||||
error={isolateError?.message}
|
||||
data-test-subj="endpointIsolationForm"
|
||||
>
|
||||
<IsolationForm
|
||||
comment={formValues.comment}
|
||||
isLoading={isPending}
|
||||
hostName={hostMeta.host.name}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</EuiForm>
|
||||
)}
|
||||
</FlyoutBodyNoTopPadding>
|
||||
</>
|
||||
);
|
||||
});
|
||||
EndpointIsolateFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel';
|
||||
EndpointIsolationFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel';
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiFlyoutFooter,
|
||||
EuiLoadingContent,
|
||||
EuiTitle,
|
||||
EuiText,
|
||||
|
@ -55,10 +56,11 @@ import {
|
|||
} from './components/endpoint_details_tabs';
|
||||
|
||||
import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date';
|
||||
import { EndpointIsolateFlyoutPanel } from './components/endpoint_isolate_flyout_panel';
|
||||
import { EndpointIsolationFlyoutPanel } from './components/endpoint_isolate_flyout_panel';
|
||||
import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpoint_details_flyout_subheader';
|
||||
import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding';
|
||||
import { getEndpointListPath } from '../../../../common/routing';
|
||||
import { ActionsMenu } from './components/actions_menu';
|
||||
|
||||
const DetailsFlyoutBody = styled(EuiFlyoutBody)`
|
||||
overflow-y: hidden;
|
||||
|
@ -128,6 +130,9 @@ export const EndpointDetailsFlyout = memo(() => {
|
|||
},
|
||||
];
|
||||
|
||||
const showFlyoutFooter =
|
||||
show === 'details' || show === 'policy_response' || show === 'activity_log';
|
||||
|
||||
const handleFlyoutClose = useCallback(() => {
|
||||
const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint;
|
||||
history.push(
|
||||
|
@ -203,7 +208,15 @@ export const EndpointDetailsFlyout = memo(() => {
|
|||
|
||||
{show === 'policy_response' && <PolicyResponseFlyoutPanel hostMeta={hostDetails} />}
|
||||
|
||||
{show === 'isolate' && <EndpointIsolateFlyoutPanel hostMeta={hostDetails} />}
|
||||
{(show === 'isolate' || show === 'unisolate') && (
|
||||
<EndpointIsolationFlyoutPanel hostMeta={hostDetails} />
|
||||
)}
|
||||
|
||||
{showFlyoutFooter && (
|
||||
<EuiFlyoutFooter className="eui-textRight" data-test-subj="endpointDetailsFlyoutFooter">
|
||||
<ActionsMenu />
|
||||
</EuiFlyoutFooter>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</EuiFlyout>
|
||||
|
|
|
@ -7,13 +7,14 @@
|
|||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useMemo } from 'react';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { EndpointState } from '../types';
|
||||
import { EndpointState } from '../../types';
|
||||
import { State } from '../../../../../common/store';
|
||||
import {
|
||||
MANAGEMENT_STORE_ENDPOINTS_NAMESPACE,
|
||||
MANAGEMENT_STORE_GLOBAL_NAMESPACE,
|
||||
} from '../../../common/constants';
|
||||
import { State } from '../../../../common/store';
|
||||
} from '../../../../common/constants';
|
||||
import { useKibana } from '../../../../../common/lib/kibana';
|
||||
|
||||
export function useEndpointSelector<TSelected>(selector: (state: EndpointState) => TSelected) {
|
||||
return useSelector(function (state: State) {
|
||||
return selector(
|
||||
|
@ -38,7 +39,6 @@ export const useIngestUrl = (subpath: string): { url: string; appId: string; app
|
|||
};
|
||||
}, [services.application, subpath]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an object that contains Fleet app and URL information
|
||||
*/
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './hooks';
|
||||
export * from './use_endpoint_action_items';
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* 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, { useMemo } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { MANAGEMENT_APP_ID } from '../../../../common/constants';
|
||||
import { APP_ID, SecurityPageName } from '../../../../../../common/constants';
|
||||
import { pagePathGetters } from '../../../../../../../fleet/public';
|
||||
import { getEndpointDetailsPath } from '../../../../common/routing';
|
||||
import { HostMetadata, MaybeImmutable } from '../../../../../../common/endpoint/types';
|
||||
import { useFormatUrl } from '../../../../../common/components/link_to';
|
||||
import { useEndpointSelector } from './hooks';
|
||||
import { agentPolicies, uiQueryParams } from '../../store/selectors';
|
||||
import { useKibana } from '../../../../../common/lib/kibana';
|
||||
import { ContextMenuItemNavByRouterProps } from '../components/context_menu_item_nav_by_rotuer';
|
||||
import { isEndpointHostIsolated } from '../../../../../common/utils/validators/is_endpoint_host_isolated';
|
||||
|
||||
/**
|
||||
* Returns a list (array) of actions for an individual endpoint
|
||||
* @param endpointMetadata
|
||||
*/
|
||||
export const useEndpointActionItems = (
|
||||
endpointMetadata: MaybeImmutable<HostMetadata> | undefined
|
||||
): ContextMenuItemNavByRouterProps[] => {
|
||||
const { formatUrl } = useFormatUrl(SecurityPageName.administration);
|
||||
const fleetAgentPolicies = useEndpointSelector(agentPolicies);
|
||||
const allCurrentUrlParams = useEndpointSelector(uiQueryParams);
|
||||
const {
|
||||
services: {
|
||||
application: { getUrlForApp },
|
||||
},
|
||||
} = useKibana();
|
||||
|
||||
return useMemo<ContextMenuItemNavByRouterProps[]>(() => {
|
||||
if (endpointMetadata) {
|
||||
const isIsolated = isEndpointHostIsolated(endpointMetadata);
|
||||
const endpointId = endpointMetadata.agent.id;
|
||||
const endpointPolicyId = endpointMetadata.Endpoint.policy.applied.id;
|
||||
const endpointHostName = endpointMetadata.host.hostname;
|
||||
const fleetAgentId = endpointMetadata.elastic.agent.id;
|
||||
const {
|
||||
show,
|
||||
selected_endpoint: _selectedEndpoint,
|
||||
...currentUrlParams
|
||||
} = allCurrentUrlParams;
|
||||
const endpointIsolatePath = getEndpointDetailsPath({
|
||||
name: 'endpointIsolate',
|
||||
...currentUrlParams,
|
||||
selected_endpoint: endpointId,
|
||||
});
|
||||
const endpointUnIsolatePath = getEndpointDetailsPath({
|
||||
name: 'endpointUnIsolate',
|
||||
...currentUrlParams,
|
||||
selected_endpoint: endpointId,
|
||||
});
|
||||
|
||||
return [
|
||||
isIsolated
|
||||
? {
|
||||
'data-test-subj': 'unIsolateLink',
|
||||
icon: 'logoSecurity',
|
||||
key: 'unIsolateHost',
|
||||
navigateAppId: MANAGEMENT_APP_ID,
|
||||
navigateOptions: {
|
||||
path: endpointUnIsolatePath,
|
||||
},
|
||||
href: formatUrl(endpointUnIsolatePath),
|
||||
children: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.actions.unIsolateHost"
|
||||
defaultMessage="Unisolate host"
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {
|
||||
'data-test-subj': 'isolateLink',
|
||||
icon: 'logoSecurity',
|
||||
key: 'isolateHost',
|
||||
navigateAppId: MANAGEMENT_APP_ID,
|
||||
navigateOptions: {
|
||||
path: endpointIsolatePath,
|
||||
},
|
||||
href: formatUrl(endpointIsolatePath),
|
||||
children: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.actions.isolateHost"
|
||||
defaultMessage="Isolate host"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
'data-test-subj': 'hostLink',
|
||||
icon: 'logoSecurity',
|
||||
key: 'hostDetailsLink',
|
||||
navigateAppId: APP_ID,
|
||||
navigateOptions: { path: `hosts/${endpointHostName}` },
|
||||
href: `${getUrlForApp('securitySolution')}/hosts/${endpointHostName}`,
|
||||
children: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.actions.hostDetails"
|
||||
defaultMessage="View host details"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: 'gear',
|
||||
key: 'agentConfigLink',
|
||||
'data-test-subj': 'agentPolicyLink',
|
||||
navigateAppId: 'fleet',
|
||||
navigateOptions: {
|
||||
path: `#${pagePathGetters.policy_details({
|
||||
policyId: fleetAgentPolicies[endpointPolicyId],
|
||||
})}`,
|
||||
},
|
||||
href: `${getUrlForApp('fleet')}#${pagePathGetters.policy_details({
|
||||
policyId: fleetAgentPolicies[endpointPolicyId],
|
||||
})}`,
|
||||
disabled: fleetAgentPolicies[endpointPolicyId] === undefined,
|
||||
children: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.actions.agentPolicy"
|
||||
defaultMessage="View agent policy"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: 'gear',
|
||||
key: 'agentDetailsLink',
|
||||
'data-test-subj': 'agentDetailsLink',
|
||||
navigateAppId: 'fleet',
|
||||
navigateOptions: {
|
||||
path: `#${pagePathGetters.fleet_agent_details({
|
||||
agentId: fleetAgentId,
|
||||
})}`,
|
||||
},
|
||||
href: `${getUrlForApp('fleet')}#${pagePathGetters.fleet_agent_details({
|
||||
agentId: fleetAgentId,
|
||||
})}`,
|
||||
children: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.actions.agentDetails"
|
||||
defaultMessage="View agent details"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [allCurrentUrlParams, endpointMetadata, fleetAgentPolicies, formatUrl, getUrlForApp]);
|
||||
};
|
|
@ -522,7 +522,7 @@ describe('when on the endpoint list page', () => {
|
|||
const {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
host_status,
|
||||
metadata: { agent, ...details },
|
||||
metadata: { agent, Endpoint, ...details },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
query_strategy_version,
|
||||
} = mockEndpointDetailsApiResult();
|
||||
|
@ -531,6 +531,13 @@ describe('when on the endpoint list page', () => {
|
|||
host_status,
|
||||
metadata: {
|
||||
...details,
|
||||
Endpoint: {
|
||||
...Endpoint,
|
||||
state: {
|
||||
...Endpoint.state,
|
||||
isolation: false,
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
...agent,
|
||||
id: '1',
|
||||
|
@ -633,11 +640,10 @@ describe('when on the endpoint list page', () => {
|
|||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show the flyout', async () => {
|
||||
it('should show the flyout and footer', async () => {
|
||||
const renderResult = await renderAndWaitForData();
|
||||
return renderResult.findByTestId('endpointDetailsFlyout').then((flyout) => {
|
||||
expect(flyout).not.toBeNull();
|
||||
});
|
||||
await expect(renderResult.findByTestId('endpointDetailsFlyout')).not.toBeNull();
|
||||
await expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should display policy name value as a link', async () => {
|
||||
|
@ -743,6 +749,11 @@ describe('when on the endpoint list page', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should show the Take Action button', async () => {
|
||||
const renderResult = await renderAndWaitForData();
|
||||
expect(renderResult.getByTestId('endpointDetailsActionsButton')).not.toBeNull();
|
||||
});
|
||||
|
||||
describe('when link to reassignment in Ingest is clicked', () => {
|
||||
beforeEach(async () => {
|
||||
coreStart.application.getUrlForApp.mockReturnValue('/app/fleet');
|
||||
|
@ -993,18 +1004,13 @@ describe('when on the endpoint list page', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should show error toast if isolate fails', async () => {
|
||||
it('should show error if isolate fails', async () => {
|
||||
isolateApiMock.responseProvider.isolateHost.mockImplementation(() => {
|
||||
throw new Error('oh oh. something went wrong');
|
||||
});
|
||||
|
||||
// coreStart.http.post.mockReset();
|
||||
// coreStart.http.post.mockRejectedValue(new Error('oh oh. something went wrong'));
|
||||
await confirmIsolateAndWaitForApiResponse('failure');
|
||||
|
||||
expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith(
|
||||
'oh oh. something went wrong'
|
||||
);
|
||||
expect(renderResult.getByText('oh oh. something went wrong')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should reset isolation state and show form again', async () => {
|
||||
|
@ -1031,6 +1037,10 @@ describe('when on the endpoint list page', () => {
|
|||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT show the flyout footer', async () => {
|
||||
await expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1045,9 +1055,19 @@ describe('when on the endpoint list page', () => {
|
|||
const { hosts, query_strategy_version: queryStrategyVersion } = mockEndpointResultList();
|
||||
hostInfo = {
|
||||
host_status: hosts[0].host_status,
|
||||
metadata: hosts[0].metadata,
|
||||
metadata: {
|
||||
...hosts[0].metadata,
|
||||
Endpoint: {
|
||||
...hosts[0].metadata.Endpoint,
|
||||
state: {
|
||||
...hosts[0].metadata.Endpoint.state,
|
||||
isolation: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
query_strategy_version: queryStrategyVersion,
|
||||
};
|
||||
|
||||
const packagePolicy = docGenerator.generatePolicyPackagePolicy();
|
||||
packagePolicy.id = hosts[0].metadata.Endpoint.policy.applied.id;
|
||||
const agentPolicy = generator.generateAgentPolicy();
|
||||
|
@ -1098,6 +1118,8 @@ describe('when on the endpoint list page', () => {
|
|||
expect(isolateLink.getAttribute('href')).toEqual(
|
||||
getEndpointDetailsPath({
|
||||
name: 'endpointIsolate',
|
||||
page_index: '0',
|
||||
page_size: '10',
|
||||
selected_endpoint: hostInfo.metadata.agent.id,
|
||||
})
|
||||
);
|
||||
|
|
|
@ -39,11 +39,7 @@ import {
|
|||
import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler';
|
||||
import { CreateStructuredSelector } from '../../../../common/store';
|
||||
import { Immutable, HostInfo } from '../../../../../common/endpoint/types';
|
||||
import {
|
||||
DEFAULT_POLL_INTERVAL,
|
||||
MANAGEMENT_APP_ID,
|
||||
MANAGEMENT_PAGE_SIZE_OPTIONS,
|
||||
} from '../../../common/constants';
|
||||
import { DEFAULT_POLL_INTERVAL, MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants';
|
||||
import { PolicyEmptyState, HostsEmptyState } from '../../../components/management_empty_state';
|
||||
import { FormattedDate } from '../../../../common/components/formatted_date';
|
||||
import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler';
|
||||
|
@ -61,7 +57,6 @@ import { OutOfDate } from './components/out_of_date';
|
|||
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';
|
||||
import { LinkToApp } from '../../../../common/components/endpoint/link_to_app';
|
||||
import { TableRowActions } from './components/table_row_actions';
|
||||
|
||||
|
@ -120,7 +115,6 @@ export const EndpointList = () => {
|
|||
policyItemsLoading,
|
||||
endpointPackageVersion,
|
||||
endpointsExist,
|
||||
agentPolicies,
|
||||
autoRefreshInterval,
|
||||
isAutoRefreshEnabled,
|
||||
patternsError,
|
||||
|
@ -130,7 +124,6 @@ export const EndpointList = () => {
|
|||
isTransformEnabled,
|
||||
} = useEndpointSelector(selector);
|
||||
const { formatUrl, search } = useFormatUrl(SecurityPageName.administration);
|
||||
|
||||
const dispatch = useDispatch<(a: EndpointAction) => void>();
|
||||
// cap ability to page at 10k records. (max_result_window)
|
||||
const maxPageCount = totalItemCount > MAX_PAGINATED_ITEM ? MAX_PAGINATED_ITEM : totalItemCount;
|
||||
|
@ -427,102 +420,15 @@ export const EndpointList = () => {
|
|||
}),
|
||||
actions: [
|
||||
{
|
||||
// eslint-disable-next-line react/display-name
|
||||
render: (item: HostInfo) => {
|
||||
const endpointIsolatePath = getEndpointDetailsPath({
|
||||
name: 'endpointIsolate',
|
||||
selected_endpoint: item.metadata.agent.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRowActions
|
||||
items={[
|
||||
{
|
||||
'data-test-subj': 'isolateLink',
|
||||
icon: 'logoSecurity',
|
||||
key: 'isolateHost',
|
||||
navigateAppId: MANAGEMENT_APP_ID,
|
||||
navigateOptions: {
|
||||
path: endpointIsolatePath,
|
||||
},
|
||||
href: formatUrl(endpointIsolatePath),
|
||||
children: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.list.actions.isolateHost"
|
||||
defaultMessage="Isolate Host"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
'data-test-subj': 'hostLink',
|
||||
icon: 'logoSecurity',
|
||||
key: 'hostDetailsLink',
|
||||
navigateAppId: APP_ID,
|
||||
navigateOptions: { path: `hosts/${item.metadata.host.hostname}` },
|
||||
href: `${services?.application?.getUrlForApp('securitySolution')}/hosts/${
|
||||
item.metadata.host.hostname
|
||||
}`,
|
||||
children: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.list.actions.hostDetails"
|
||||
defaultMessage="View Host Details"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: 'logoObservability',
|
||||
key: 'agentConfigLink',
|
||||
'data-test-subj': 'agentPolicyLink',
|
||||
navigateAppId: 'fleet',
|
||||
navigateOptions: {
|
||||
path: `#${pagePathGetters.policy_details({
|
||||
policyId: agentPolicies[item.metadata.Endpoint.policy.applied.id],
|
||||
})}`,
|
||||
},
|
||||
href: `${services?.application?.getUrlForApp(
|
||||
'fleet'
|
||||
)}#${pagePathGetters.policy_details({
|
||||
policyId: agentPolicies[item.metadata.Endpoint.policy.applied.id],
|
||||
})}`,
|
||||
disabled:
|
||||
agentPolicies[item.metadata.Endpoint.policy.applied.id] === undefined,
|
||||
children: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.list.actions.agentPolicy"
|
||||
defaultMessage="View Agent Policy"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: 'logoObservability',
|
||||
key: 'agentDetailsLink',
|
||||
'data-test-subj': 'agentDetailsLink',
|
||||
navigateAppId: 'fleet',
|
||||
navigateOptions: {
|
||||
path: `#${pagePathGetters.fleet_agent_details({
|
||||
agentId: item.metadata.elastic.agent.id,
|
||||
})}`,
|
||||
},
|
||||
href: `${services?.application?.getUrlForApp(
|
||||
'fleet'
|
||||
)}#${pagePathGetters.fleet_agent_details({
|
||||
agentId: item.metadata.elastic.agent.id,
|
||||
})}`,
|
||||
children: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.list.actions.agentDetails"
|
||||
defaultMessage="View Agent Details"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
return <TableRowActions endpointMetadata={item.metadata} />;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [queryParams, search, formatUrl, PAD_LEFT, services?.application, agentPolicies]);
|
||||
}, [queryParams, search, formatUrl, PAD_LEFT]);
|
||||
|
||||
const renderTableOrEmptyState = useMemo(() => {
|
||||
if (endpointsExist || areEndpointsEnrolling) {
|
||||
|
|
|
@ -20313,9 +20313,6 @@
|
|||
"xpack.securitySolution.endpoint.ingestToastTitle": "アプリを初期化できませんでした",
|
||||
"xpack.securitySolution.endpoint.list.actionmenu": "開く",
|
||||
"xpack.securitySolution.endpoint.list.actions": "アクション",
|
||||
"xpack.securitySolution.endpoint.list.actions.agentDetails": "エージェント詳細を表示",
|
||||
"xpack.securitySolution.endpoint.list.actions.agentPolicy": "エージェントポリシーを表示",
|
||||
"xpack.securitySolution.endpoint.list.actions.hostDetails": "ホスト詳細を表示",
|
||||
"xpack.securitySolution.endpoint.list.endpointsEnrolling": "エンドポイントを登録しています。進行状況を追跡するには、{agentsLink}してください。",
|
||||
"xpack.securitySolution.endpoint.list.endpointsEnrolling.viewAgentsLink": "エージェントを表示",
|
||||
"xpack.securitySolution.endpoint.list.endpointVersion": "バージョン",
|
||||
|
|
|
@ -20613,9 +20613,6 @@
|
|||
"xpack.securitySolution.endpoint.ingestToastTitle": "应用无法初始化",
|
||||
"xpack.securitySolution.endpoint.list.actionmenu": "未结",
|
||||
"xpack.securitySolution.endpoint.list.actions": "操作",
|
||||
"xpack.securitySolution.endpoint.list.actions.agentDetails": "查看代理详情",
|
||||
"xpack.securitySolution.endpoint.list.actions.agentPolicy": "查看代理策略",
|
||||
"xpack.securitySolution.endpoint.list.actions.hostDetails": "查看主机详情",
|
||||
"xpack.securitySolution.endpoint.list.endpointsEnrolling": "正在注册终端。{agentsLink}以跟踪进度。",
|
||||
"xpack.securitySolution.endpoint.list.endpointsEnrolling.viewAgentsLink": "查看代理",
|
||||
"xpack.securitySolution.endpoint.list.endpointVersion": "版本",
|
||||
|
|
|
@ -13,7 +13,13 @@
|
|||
"status": "failure"
|
||||
}
|
||||
},
|
||||
"status": "enrolled"
|
||||
"status": "enrolled",
|
||||
"configuration": {
|
||||
"isolation": false
|
||||
},
|
||||
"state": {
|
||||
"isolation": false
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"id": "3838df35-a095-4af4-8fce-0b6d78793f2e",
|
||||
|
@ -81,7 +87,13 @@
|
|||
"status": "failure"
|
||||
}
|
||||
},
|
||||
"status": "enrolled"
|
||||
"status": "enrolled",
|
||||
"configuration": {
|
||||
"isolation": false
|
||||
},
|
||||
"state": {
|
||||
"isolation": false
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"id": "963b081e-60d1-482c-befd-a5815fa8290f",
|
||||
|
@ -152,7 +164,13 @@
|
|||
"status": "success"
|
||||
}
|
||||
},
|
||||
"status": "enrolled"
|
||||
"status": "enrolled",
|
||||
"configuration": {
|
||||
"isolation": false
|
||||
},
|
||||
"state": {
|
||||
"isolation": false
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"id": "b3412d6f-b022-4448-8fee-21cc936ea86b",
|
||||
|
|
Loading…
Reference in a new issue