mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution][Endpoint] Add host isolation action to the endpoint list (#100240)
* Refactor TableRowAction into separate component and enable menu close on item click * add `show=isolate` to valid url param string for details panel * Reusable BackToEndpointDetailsFlyoutSubHeader component * new FlyoutBodyNoTopPadding compoent + refactor Policy response to use it * Endpoint Isolate flyout panel * New Service for doing isolate/unisolate of hosts * Refactor detection isolate API call to use common method from new service
This commit is contained in:
parent
21820e9aff
commit
093044f10f
26 changed files with 1031 additions and 275 deletions
|
@ -9,10 +9,14 @@ import { schema } from '@kbn/config-schema';
|
|||
|
||||
export const HostIsolationRequestSchema = {
|
||||
body: schema.object({
|
||||
agent_ids: schema.nullable(schema.arrayOf(schema.string())),
|
||||
endpoint_ids: schema.nullable(schema.arrayOf(schema.string())),
|
||||
alert_ids: schema.nullable(schema.arrayOf(schema.string())),
|
||||
case_ids: schema.nullable(schema.arrayOf(schema.string())),
|
||||
comment: schema.nullable(schema.string()),
|
||||
/** A list of Fleet Agent IDs whose hosts will be isolated */
|
||||
agent_ids: schema.maybe(schema.arrayOf(schema.string())),
|
||||
/** A list of endpoint IDs whose hosts will be isolated (Fleet Agent IDs will be retrieved for these) */
|
||||
endpoint_ids: schema.maybe(schema.arrayOf(schema.string())),
|
||||
/** If defined, any case associated with the given IDs will be updated */
|
||||
alert_ids: schema.maybe(schema.arrayOf(schema.string())),
|
||||
/** Case IDs to be updated */
|
||||
case_ids: schema.maybe(schema.arrayOf(schema.string())),
|
||||
comment: schema.maybe(schema.string()),
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { TypeOf } from '@kbn/config-schema';
|
||||
import { HostIsolationRequestSchema } from '../schema/actions';
|
||||
|
||||
export type ISOLATION_ACTIONS = 'isolate' | 'unisolate';
|
||||
|
||||
export interface EndpointAction {
|
||||
|
@ -21,7 +24,8 @@ export interface EndpointAction {
|
|||
};
|
||||
}
|
||||
|
||||
export type HostIsolationRequestBody = TypeOf<typeof HostIsolationRequestSchema.body>;
|
||||
|
||||
export interface HostIsolationResponse {
|
||||
action?: string;
|
||||
message?: string;
|
||||
action: string;
|
||||
}
|
||||
|
|
|
@ -70,12 +70,22 @@ export const EndpointIsolateForm = memo<EndpointIsolatedFormProps>(
|
|||
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty onClick={onCancel} disabled={isLoading}>
|
||||
<EuiButtonEmpty
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
data-test-subj="hostIsolateCancelButton"
|
||||
>
|
||||
{CANCEL}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton fill onClick={onConfirm} disabled={isLoading} isLoading={isLoading}>
|
||||
<EuiButton
|
||||
fill
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
isLoading={isLoading}
|
||||
data-test-subj="hostIsolateConfirmButton"
|
||||
>
|
||||
{CONFIRM}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -20,13 +20,22 @@ export const EndpointIsolateSuccess = memo<EndpointIsolateSuccessProps>(
|
|||
({ hostName, onComplete, completeButtonLabel, additionalInfo }) => {
|
||||
return (
|
||||
<>
|
||||
<EuiCallOut iconType="check" color="success" title={GET_SUCCESS_MESSAGE(hostName)}>
|
||||
<EuiCallOut
|
||||
iconType="check"
|
||||
color="success"
|
||||
title={GET_SUCCESS_MESSAGE(hostName)}
|
||||
data-test-subj="hostIsolateSuccessMessage"
|
||||
>
|
||||
{additionalInfo}
|
||||
</EuiCallOut>
|
||||
|
||||
<EuiFlexGroup gutterSize="none" justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty flush="right" onClick={onComplete}>
|
||||
<EuiButtonEmpty
|
||||
flush="right"
|
||||
onClick={onComplete}
|
||||
data-test-subj="hostIsolateSuccessCompleteButton"
|
||||
>
|
||||
<EuiText size="s">
|
||||
<p>{completeButtonLabel}</p>
|
||||
</EuiText>
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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 { KibanaServices } from '../kibana';
|
||||
import { coreMock } from '../../../../../../../src/core/public/mocks';
|
||||
import { isolateHost, unIsolateHost } from './index';
|
||||
import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE } from '../../../../common/endpoint/constants';
|
||||
import { hostIsolationRequestBodyMock } from './mocks';
|
||||
|
||||
jest.mock('../kibana');
|
||||
|
||||
describe('When using Host Isolation library', () => {
|
||||
const mockKibanaServices = KibanaServices.get as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockKibanaServices.mockReturnValue(coreMock.createStart({ basePath: '/mock' }));
|
||||
});
|
||||
|
||||
it('should send an isolate POST request', async () => {
|
||||
const requestBody = hostIsolationRequestBodyMock();
|
||||
await isolateHost(requestBody);
|
||||
|
||||
expect(mockKibanaServices().http.post).toHaveBeenCalledWith(ISOLATE_HOST_ROUTE, {
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
});
|
||||
|
||||
it('should send an un-isolate POST request', async () => {
|
||||
const requestBody = hostIsolationRequestBodyMock();
|
||||
await unIsolateHost(requestBody);
|
||||
|
||||
expect(mockKibanaServices().http.post).toHaveBeenCalledWith(UNISOLATE_HOST_ROUTE, {
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 { HostIsolationRequestBody, HostIsolationResponse } from '../../../../common/endpoint/types';
|
||||
import { KibanaServices } from '../kibana';
|
||||
import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE } from '../../../../common/endpoint/constants';
|
||||
|
||||
/** Isolates a Host running either elastic endpoint or fleet agent */
|
||||
export const isolateHost = async (
|
||||
params: HostIsolationRequestBody
|
||||
): Promise<HostIsolationResponse> => {
|
||||
return KibanaServices.get().http.post<HostIsolationResponse>(ISOLATE_HOST_ROUTE, {
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
};
|
||||
|
||||
/** Un-isolates a Host running either elastic endpoint or fleet agent */
|
||||
export const unIsolateHost = async (
|
||||
params: HostIsolationRequestBody
|
||||
): Promise<HostIsolationResponse> => {
|
||||
return KibanaServices.get().http.post<HostIsolationResponse>(UNISOLATE_HOST_ROUTE, {
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
};
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 { HostIsolationRequestBody, HostIsolationResponse } from '../../../../common/endpoint/types';
|
||||
import {
|
||||
httpHandlerMockFactory,
|
||||
ResponseProvidersInterface,
|
||||
} from '../../mock/endpoint/http_handler_mock_factory';
|
||||
import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE } from '../../../../common/endpoint/constants';
|
||||
|
||||
export const hostIsolationRequestBodyMock = (): HostIsolationRequestBody => {
|
||||
return {
|
||||
agent_ids: ['fd8a122b-4c54-4c05-b295-111'],
|
||||
endpoint_ids: ['88c04a90-b19c-11eb-b838-222'],
|
||||
alert_ids: ['88c04a90-b19c-11eb-b838-333'],
|
||||
case_ids: ['88c04a90-b19c-11eb-b838-444'],
|
||||
comment: 'Lock it',
|
||||
};
|
||||
};
|
||||
|
||||
export const hostIsolationResponseMock = (): HostIsolationResponse => {
|
||||
return {
|
||||
action: '111-222-333-444',
|
||||
};
|
||||
};
|
||||
|
||||
export type HostIsolationHttpMockProviders = ResponseProvidersInterface<{
|
||||
isolateHost: () => HostIsolationResponse;
|
||||
unIsolateHost: () => HostIsolationResponse;
|
||||
}>;
|
||||
|
||||
export const hostIsolationHttpMocks = httpHandlerMockFactory<HostIsolationHttpMockProviders>([
|
||||
{
|
||||
id: 'isolateHost',
|
||||
method: 'post',
|
||||
path: ISOLATE_HOST_ROUTE,
|
||||
handler: () => hostIsolationResponseMock(),
|
||||
},
|
||||
{
|
||||
id: 'unIsolateHost',
|
||||
method: 'post',
|
||||
path: UNISOLATE_HOST_ROUTE,
|
||||
handler: () => hostIsolationResponseMock(),
|
||||
},
|
||||
]);
|
|
@ -10,16 +10,19 @@ import { createMemoryHistory } from 'history';
|
|||
import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react';
|
||||
import { Action, Reducer, Store } from 'redux';
|
||||
import { coreMock } from '../../../../../../../src/core/public/mocks';
|
||||
import { StartPlugins } from '../../../types';
|
||||
import { StartPlugins, StartServices } from '../../../types';
|
||||
import { depsStartMock } from './dependencies_start_mock';
|
||||
import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../../store/test_utils';
|
||||
import { kibanaObservable } from '../test_providers';
|
||||
import { createStore, State } from '../../store';
|
||||
import { AppRootProvider } from './app_root_provider';
|
||||
import { managementMiddlewareFactory } from '../../../management/store/middleware';
|
||||
import { createKibanaContextProviderMock } from '../../lib/kibana/kibana_react.mock';
|
||||
import { createStartServicesMock } from '../../lib/kibana/kibana_react.mock';
|
||||
import { SUB_PLUGINS_REDUCER, mockGlobalState, createSecuritySolutionStorageMock } from '..';
|
||||
import { ExperimentalFeatures } from '../../../../common/experimental_features';
|
||||
import { PLUGIN_ID } from '../../../../../fleet/common';
|
||||
import { APP_ID } from '../../../../common/constants';
|
||||
import { KibanaContextProvider } from '../../lib/kibana';
|
||||
|
||||
type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
|
||||
|
||||
|
@ -31,6 +34,7 @@ export interface AppContextTestRender {
|
|||
history: ReturnType<typeof createMemoryHistory>;
|
||||
coreStart: ReturnType<typeof coreMock.createStart>;
|
||||
depsStart: Pick<StartPlugins, 'data' | 'fleet'>;
|
||||
startServices: StartServices;
|
||||
middlewareSpy: MiddlewareActionSpyHelper;
|
||||
/**
|
||||
* A wrapper around `AppRootContext` component. Uses the mocked modules as input to the
|
||||
|
@ -87,10 +91,14 @@ const experimentalFeaturesReducer: Reducer<State['app'], UpdateExperimentalFeatu
|
|||
*/
|
||||
export const createAppRootMockRenderer = (): AppContextTestRender => {
|
||||
const history = createMemoryHistory<never>();
|
||||
const coreStart = coreMock.createStart({ basePath: '/mock' });
|
||||
const coreStart = createCoreStartMock();
|
||||
const depsStart = depsStartMock();
|
||||
const middlewareSpy = createSpyMiddleware();
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
const startServices: StartServices = {
|
||||
...createStartServicesMock(),
|
||||
...coreStart,
|
||||
};
|
||||
|
||||
const storeReducer = {
|
||||
...SUB_PLUGINS_REDUCER,
|
||||
|
@ -104,14 +112,12 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
|
|||
middlewareSpy.actionSpyMiddleware,
|
||||
]);
|
||||
|
||||
const MockKibanaContextProvider = createKibanaContextProviderMock();
|
||||
|
||||
const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => (
|
||||
<MockKibanaContextProvider>
|
||||
<KibanaContextProvider services={startServices}>
|
||||
<AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}>
|
||||
{children}
|
||||
</AppRootProvider>
|
||||
</MockKibanaContextProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
const render: UiRender = (ui, options) => {
|
||||
return reactRender(ui, {
|
||||
|
@ -132,9 +138,28 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
|
|||
history,
|
||||
coreStart,
|
||||
depsStart,
|
||||
startServices,
|
||||
middlewareSpy,
|
||||
AppWrapper,
|
||||
render,
|
||||
setExperimentalFlag,
|
||||
};
|
||||
};
|
||||
|
||||
const createCoreStartMock = (): ReturnType<typeof coreMock.createStart> => {
|
||||
const coreStart = coreMock.createStart({ basePath: '/mock' });
|
||||
|
||||
// Mock the certain APP Ids returned by `application.getUrlForApp()`
|
||||
coreStart.application.getUrlForApp.mockImplementation((appId) => {
|
||||
switch (appId) {
|
||||
case PLUGIN_ID:
|
||||
return '/app/fleet';
|
||||
case APP_ID:
|
||||
return '/app/security';
|
||||
default:
|
||||
return `${appId} not mocked!`;
|
||||
}
|
||||
});
|
||||
|
||||
return coreStart;
|
||||
};
|
||||
|
|
|
@ -22,13 +22,15 @@ import {
|
|||
createSignalIndex,
|
||||
createHostIsolation,
|
||||
} from './api';
|
||||
import { coreMock } from '../../../../../../../../src/core/public/mocks';
|
||||
|
||||
const abortCtrl = new AbortController();
|
||||
const mockKibanaServices = KibanaServices.get as jest.Mock;
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
const fetchMock = jest.fn();
|
||||
mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } });
|
||||
const coreStartMock = coreMock.createStart({ basePath: '/mock' });
|
||||
mockKibanaServices.mockReturnValue(coreStartMock);
|
||||
const fetchMock = coreStartMock.http.fetch;
|
||||
|
||||
describe('Detections Alerts API', () => {
|
||||
describe('fetchQueryAlerts', () => {
|
||||
|
@ -167,9 +169,11 @@ describe('Detections Alerts API', () => {
|
|||
});
|
||||
|
||||
describe('createHostIsolation', () => {
|
||||
const postMock = coreStartMock.http.post;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockClear();
|
||||
fetchMock.mockResolvedValue(mockHostIsolation);
|
||||
postMock.mockClear();
|
||||
postMock.mockResolvedValue(mockHostIsolation);
|
||||
});
|
||||
|
||||
test('check parameter url', async () => {
|
||||
|
@ -178,8 +182,7 @@ describe('Detections Alerts API', () => {
|
|||
comment: 'commento',
|
||||
caseIds: ['88c04a90-b19c-11eb-b838-bf3c7840b969'],
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/endpoint/isolate', {
|
||||
method: 'POST',
|
||||
expect(postMock).toHaveBeenCalledWith('/api/endpoint/isolate', {
|
||||
body:
|
||||
'{"agent_ids":["fd8a122b-4c54-4c05-b295-e5f8381fc59d"],"comment":"commento","case_ids":["88c04a90-b19c-11eb-b838-bf3c7840b969"]}',
|
||||
});
|
||||
|
|
|
@ -14,7 +14,6 @@ import {
|
|||
DETECTION_ENGINE_INDEX_URL,
|
||||
DETECTION_ENGINE_PRIVILEGES_URL,
|
||||
} from '../../../../../common/constants';
|
||||
import { ISOLATE_HOST_ROUTE } from '../../../../../common/endpoint/constants';
|
||||
import { KibanaServices } from '../../../../common/lib/kibana';
|
||||
import {
|
||||
BasicSignals,
|
||||
|
@ -25,6 +24,7 @@ import {
|
|||
UpdateAlertStatusProps,
|
||||
CasesFromAlertsResponse,
|
||||
} from './types';
|
||||
import { isolateHost } from '../../../../common/lib/host_isolation';
|
||||
|
||||
/**
|
||||
* Fetch Alerts by providing a query
|
||||
|
@ -124,13 +124,10 @@ export const createHostIsolation = async ({
|
|||
comment?: string;
|
||||
caseIds?: string[];
|
||||
}): Promise<HostIsolationResponse> =>
|
||||
KibanaServices.get().http.fetch<HostIsolationResponse>(ISOLATE_HOST_ROUTE, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
agent_ids: [agentId],
|
||||
comment,
|
||||
case_ids: caseIds,
|
||||
}),
|
||||
isolateHost({
|
||||
agent_ids: [agentId],
|
||||
comment,
|
||||
case_ids: caseIds,
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
@ -43,12 +43,15 @@ const querystringStringify = <ExpectedType, ArgType>(
|
|||
type EndpointDetailsUrlProps = Omit<EndpointIndexUIQueryParams, 'selected_endpoint'> &
|
||||
Required<Pick<EndpointIndexUIQueryParams, 'selected_endpoint'>>;
|
||||
|
||||
/** URL search params that are only applicable to the list page */
|
||||
type EndpointListUrlProps = Omit<EndpointIndexUIQueryParams, 'selected_endpoint' | 'show'>;
|
||||
|
||||
export const getEndpointListPath = (
|
||||
props: { name: 'default' | 'endpointList' } & EndpointIndexUIQueryParams,
|
||||
props: { name: 'default' | 'endpointList' } & EndpointListUrlProps,
|
||||
search?: string
|
||||
) => {
|
||||
const { name, ...queryParams } = props;
|
||||
const urlQueryParams = querystringStringify<EndpointIndexUIQueryParams, typeof queryParams>(
|
||||
const urlQueryParams = querystringStringify<EndpointListUrlProps, typeof queryParams>(
|
||||
queryParams
|
||||
);
|
||||
const urlSearch = `${urlQueryParams && !isEmpty(search) ? '&' : ''}${search ?? ''}`;
|
||||
|
@ -62,14 +65,25 @@ export const getEndpointListPath = (
|
|||
};
|
||||
|
||||
export const getEndpointDetailsPath = (
|
||||
props: { name: 'endpointDetails' | 'endpointPolicyResponse' } & EndpointIndexUIQueryParams &
|
||||
props: {
|
||||
name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate';
|
||||
} & EndpointIndexUIQueryParams &
|
||||
EndpointDetailsUrlProps,
|
||||
search?: string
|
||||
) => {
|
||||
const { name, ...queryParams } = props;
|
||||
queryParams.show = (props.name === 'endpointPolicyResponse'
|
||||
? 'policy_response'
|
||||
: '') as EndpointIndexUIQueryParams['show'];
|
||||
const { name, show, ...rest } = props;
|
||||
|
||||
const queryParams: EndpointDetailsUrlProps = { ...rest };
|
||||
|
||||
switch (props.name) {
|
||||
case 'endpointIsolate':
|
||||
queryParams.show = 'isolate';
|
||||
break;
|
||||
case 'endpointPolicyResponse':
|
||||
queryParams.show = 'policy_response';
|
||||
break;
|
||||
}
|
||||
|
||||
const urlQueryParams = querystringStringify<EndpointDetailsUrlProps, typeof queryParams>(
|
||||
queryParams
|
||||
);
|
||||
|
|
|
@ -5,10 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Action } from 'redux';
|
||||
import {
|
||||
HostResultList,
|
||||
HostInfo,
|
||||
GetHostPolicyResponse,
|
||||
HostIsolationRequestBody,
|
||||
} from '../../../../../common/endpoint/types';
|
||||
import { ServerApiError } from '../../../../common/types';
|
||||
import { GetPolicyListResponse } from '../../policy/types';
|
||||
|
@ -134,6 +136,14 @@ interface ServerFailedToReturnEndpointsTotal {
|
|||
payload: ServerApiError;
|
||||
}
|
||||
|
||||
type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & {
|
||||
payload: HostIsolationRequestBody;
|
||||
};
|
||||
|
||||
type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & {
|
||||
payload: EndpointState['isolationRequestState'];
|
||||
};
|
||||
|
||||
export type EndpointAction =
|
||||
| ServerReturnedEndpointList
|
||||
| ServerFailedToReturnEndpointList
|
||||
|
@ -157,4 +167,6 @@ export type EndpointAction =
|
|||
| UserUpdatedEndpointListRefreshOptions
|
||||
| ServerReturnedEndpointsTotal
|
||||
| ServerFailedToReturnAgenstWithEndpointsTotal
|
||||
| ServerFailedToReturnEndpointsTotal;
|
||||
| ServerFailedToReturnEndpointsTotal
|
||||
| EndpointIsolationRequest
|
||||
| EndpointIsolationRequestStateChange;
|
||||
|
|
|
@ -65,6 +65,9 @@ describe('EndpointList store concerns', () => {
|
|||
endpointsTotalError: undefined,
|
||||
queryStrategyVersion: undefined,
|
||||
policyVersionInfo: undefined,
|
||||
isolationRequestState: {
|
||||
type: 'UninitialisedResourceState',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -9,14 +9,16 @@ import { CoreStart, HttpSetup } from 'kibana/public';
|
|||
import { applyMiddleware, createStore, Store } from 'redux';
|
||||
import { coreMock } from '../../../../../../../../src/core/public/mocks';
|
||||
import { History, createBrowserHistory } from 'history';
|
||||
|
||||
import { DepsStartMock, depsStartMock } from '../../../../common/mock/endpoint';
|
||||
|
||||
import {
|
||||
createSpyMiddleware,
|
||||
MiddlewareActionSpyHelper,
|
||||
} from '../../../../common/store/test_utils';
|
||||
import { Immutable, HostResultList } from '../../../../../common/endpoint/types';
|
||||
import {
|
||||
Immutable,
|
||||
HostResultList,
|
||||
HostIsolationResponse,
|
||||
} from '../../../../../common/endpoint/types';
|
||||
import { AppAction } from '../../../../common/store/actions';
|
||||
import { mockEndpointResultList } from './mock_endpoint_result_list';
|
||||
import { listData } from './selectors';
|
||||
|
@ -24,6 +26,19 @@ import { EndpointState } from '../types';
|
|||
import { endpointListReducer } from './reducer';
|
||||
import { endpointMiddlewareFactory } from './middleware';
|
||||
import { getEndpointListPath } from '../../../common/routing';
|
||||
import {
|
||||
FailedResourceState,
|
||||
isFailedResourceState,
|
||||
isLoadedResourceState,
|
||||
isLoadingResourceState,
|
||||
LoadedResourceState,
|
||||
} from '../../../state';
|
||||
import { KibanaServices } from '../../../../common/lib/kibana';
|
||||
import {
|
||||
hostIsolationHttpMocks,
|
||||
hostIsolationRequestBodyMock,
|
||||
hostIsolationResponseMock,
|
||||
} from '../../../../common/lib/host_isolation/mocks';
|
||||
|
||||
jest.mock('../../policy/store/services/ingest', () => ({
|
||||
sendGetAgentConfigList: () => Promise.resolve({ items: [] }),
|
||||
|
@ -31,21 +46,25 @@ jest.mock('../../policy/store/services/ingest', () => ({
|
|||
sendGetEndpointSecurityPackage: () => Promise.resolve({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
type EndpointListStore = Store<Immutable<EndpointState>, Immutable<AppAction>>;
|
||||
|
||||
describe('endpoint list middleware', () => {
|
||||
let fakeCoreStart: jest.Mocked<CoreStart>;
|
||||
let depsStart: DepsStartMock;
|
||||
let fakeHttpServices: jest.Mocked<HttpSetup>;
|
||||
type EndpointListStore = Store<Immutable<EndpointState>, Immutable<AppAction>>;
|
||||
let store: EndpointListStore;
|
||||
let getState: EndpointListStore['getState'];
|
||||
let dispatch: EndpointListStore['dispatch'];
|
||||
let waitForAction: MiddlewareActionSpyHelper['waitForAction'];
|
||||
let actionSpyMiddleware;
|
||||
|
||||
let history: History<never>;
|
||||
|
||||
const getEndpointListApiResponse = (): HostResultList => {
|
||||
return mockEndpointResultList({ request_page_size: 1, request_page_index: 1, total: 10 });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fakeCoreStart = coreMock.createStart({ basePath: '/mock' });
|
||||
depsStart = depsStartMock();
|
||||
|
@ -59,6 +78,7 @@ describe('endpoint list middleware', () => {
|
|||
dispatch = store.dispatch;
|
||||
history = createBrowserHistory();
|
||||
});
|
||||
|
||||
it('handles `userChangedUrl`', async () => {
|
||||
const apiResponse = getEndpointListApiResponse();
|
||||
fakeHttpServices.post.mockResolvedValue(apiResponse);
|
||||
|
@ -109,4 +129,67 @@ describe('endpoint list middleware', () => {
|
|||
});
|
||||
expect(listData(getState())).toEqual(apiResponse.hosts);
|
||||
});
|
||||
|
||||
describe('handling of IsolateEndpointHost action', () => {
|
||||
const getKibanaServicesMock = KibanaServices.get as jest.Mock;
|
||||
const dispatchIsolateEndpointHost = () => {
|
||||
dispatch({
|
||||
type: 'endpointIsolationRequest',
|
||||
payload: hostIsolationRequestBodyMock(),
|
||||
});
|
||||
};
|
||||
let isolateApiResponseHandlers: ReturnType<typeof hostIsolationHttpMocks>;
|
||||
|
||||
beforeEach(() => {
|
||||
isolateApiResponseHandlers = hostIsolationHttpMocks(fakeHttpServices);
|
||||
getKibanaServicesMock.mockReturnValue(fakeCoreStart);
|
||||
});
|
||||
|
||||
it('should set Isolation state to loading', async () => {
|
||||
const loadingDispatched = waitForAction('endpointIsolationRequestStateChange', {
|
||||
validate(action) {
|
||||
return isLoadingResourceState(action.payload);
|
||||
},
|
||||
});
|
||||
|
||||
dispatchIsolateEndpointHost();
|
||||
expect(await loadingDispatched).not.toBeFalsy();
|
||||
});
|
||||
|
||||
it('should call isolate api', async () => {
|
||||
dispatchIsolateEndpointHost();
|
||||
expect(fakeHttpServices.post).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set Isolation state to loaded if api is successful', async () => {
|
||||
const loadedDispatched = waitForAction('endpointIsolationRequestStateChange', {
|
||||
validate(action) {
|
||||
return isLoadedResourceState(action.payload);
|
||||
},
|
||||
});
|
||||
|
||||
dispatchIsolateEndpointHost();
|
||||
expect(
|
||||
((await loadedDispatched).payload as LoadedResourceState<HostIsolationResponse>).data
|
||||
).toEqual(hostIsolationResponseMock());
|
||||
});
|
||||
|
||||
it('should set Isolation to Failed if api failed', async () => {
|
||||
const apiError = new Error('oh oh');
|
||||
const failedDispatched = waitForAction('endpointIsolationRequestStateChange', {
|
||||
validate(action) {
|
||||
return isFailedResourceState(action.payload);
|
||||
},
|
||||
});
|
||||
|
||||
isolateApiResponseHandlers.responseProvider.isolateHost.mockImplementation(() => {
|
||||
throw apiError;
|
||||
});
|
||||
dispatchIsolateEndpointHost();
|
||||
|
||||
const failedAction = (await failedDispatched)
|
||||
.payload as FailedResourceState<HostIsolationResponse>;
|
||||
expect(failedAction.error).toBe(apiError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,9 +6,15 @@
|
|||
*/
|
||||
|
||||
import { HttpStart } from 'kibana/public';
|
||||
import { HostInfo, HostResultList } from '../../../../../common/endpoint/types';
|
||||
import {
|
||||
HostInfo,
|
||||
HostIsolationRequestBody,
|
||||
HostIsolationResponse,
|
||||
HostResultList,
|
||||
Immutable,
|
||||
} from '../../../../../common/endpoint/types';
|
||||
import { GetPolicyListResponse } from '../../policy/types';
|
||||
import { ImmutableMiddlewareFactory } from '../../../../common/store';
|
||||
import { ImmutableMiddlewareAPI, ImmutableMiddlewareFactory } from '../../../../common/store';
|
||||
import {
|
||||
isOnEndpointPage,
|
||||
hasSelectedEndpoint,
|
||||
|
@ -19,6 +25,8 @@ import {
|
|||
patterns,
|
||||
searchBarQuery,
|
||||
isTransformEnabled,
|
||||
getIsIsolationRequestPending,
|
||||
getCurrentIsolationRequestState,
|
||||
} from './selectors';
|
||||
import { EndpointState, PolicyIds } from '../types';
|
||||
import {
|
||||
|
@ -30,6 +38,15 @@ import {
|
|||
import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common';
|
||||
import { metadataCurrentIndexPattern } from '../../../../../common/endpoint/constants';
|
||||
import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public';
|
||||
import {
|
||||
createFailedResourceState,
|
||||
createLoadedResourceState,
|
||||
createLoadingResourceState,
|
||||
} from '../../../state';
|
||||
import { isolateHost } from '../../../../common/lib/host_isolation';
|
||||
import { AppAction } from '../../../../common/store/actions';
|
||||
|
||||
type EndpointPageStore = ImmutableMiddlewareAPI<EndpointState, AppAction>;
|
||||
|
||||
export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState> = (
|
||||
coreStart,
|
||||
|
@ -47,9 +64,11 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState
|
|||
return [indexPattern];
|
||||
}
|
||||
// eslint-disable-next-line complexity
|
||||
return ({ getState, dispatch }) => (next) => async (action) => {
|
||||
return (store) => (next) => async (action) => {
|
||||
next(action);
|
||||
|
||||
const { getState, dispatch } = store;
|
||||
|
||||
// Endpoint list
|
||||
if (
|
||||
(action.type === 'userChangedUrl' || action.type === 'appRequestedEndpointList') &&
|
||||
|
@ -328,6 +347,11 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Isolate Host
|
||||
if (action.type === 'endpointIsolationRequest') {
|
||||
return handleIsolateEndpointHost(store, action);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -428,3 +452,36 @@ const doEndpointsExist = async (http: HttpStart): Promise<boolean> => {
|
|||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleIsolateEndpointHost = async (
|
||||
{ getState, dispatch }: EndpointPageStore,
|
||||
action: Immutable<AppAction & { type: 'endpointIsolationRequest' }>
|
||||
) => {
|
||||
const state = getState();
|
||||
|
||||
if (getIsIsolationRequestPending(state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'endpointIsolationRequestStateChange',
|
||||
// Ignore will be fixed with when AsyncResourceState is refactored (#830)
|
||||
// @ts-ignore
|
||||
payload: createLoadingResourceState(getCurrentIsolationRequestState(state)),
|
||||
});
|
||||
|
||||
try {
|
||||
// Cast needed below due to the value of payload being `Immutable<>`
|
||||
const response = await isolateHost(action.payload as HostIsolationRequestBody);
|
||||
|
||||
dispatch({
|
||||
type: 'endpointIsolationRequestStateChange',
|
||||
payload: createLoadedResourceState<HostIsolationResponse>(response),
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'endpointIsolationRequestStateChange',
|
||||
payload: createFailedResourceState<HostIsolationResponse>(error.body ?? error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -5,12 +5,18 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { isOnEndpointPage, hasSelectedEndpoint } from './selectors';
|
||||
import {
|
||||
isOnEndpointPage,
|
||||
hasSelectedEndpoint,
|
||||
uiQueryParams,
|
||||
getCurrentIsolationRequestState,
|
||||
} from './selectors';
|
||||
import { EndpointState } from '../types';
|
||||
import { AppAction } from '../../../../common/store/actions';
|
||||
import { ImmutableReducer } from '../../../../common/store';
|
||||
import { Immutable } from '../../../../../common/endpoint/types';
|
||||
import { DEFAULT_POLL_INTERVAL } from '../../../common/constants';
|
||||
import { createUninitialisedResourceState, isUninitialisedResourceState } from '../../../state';
|
||||
|
||||
export const initialEndpointListState: Immutable<EndpointState> = {
|
||||
hosts: [],
|
||||
|
@ -44,6 +50,7 @@ export const initialEndpointListState: Immutable<EndpointState> = {
|
|||
queryStrategyVersion: undefined,
|
||||
policyVersionInfo: undefined,
|
||||
hostStatus: undefined,
|
||||
isolationRequestState: createUninitialisedResourceState(),
|
||||
};
|
||||
|
||||
/* eslint-disable-next-line complexity */
|
||||
|
@ -199,6 +206,8 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
|
|||
isAutoRefreshEnabled: action.payload.isAutoRefreshEnabled ?? state.isAutoRefreshEnabled,
|
||||
autoRefreshInterval: action.payload.autoRefreshInterval ?? state.autoRefreshInterval,
|
||||
};
|
||||
} else if (action.type === 'endpointIsolationRequestStateChange') {
|
||||
return handleEndpointIsolationRequestStateChanged(state, action);
|
||||
} else if (action.type === 'userChangedUrl') {
|
||||
const newState: Immutable<EndpointState> = {
|
||||
...state,
|
||||
|
@ -209,16 +218,29 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
|
|||
const isCurrentlyOnDetailsPage = isOnEndpointPage(newState) && hasSelectedEndpoint(newState);
|
||||
const wasPreviouslyOnDetailsPage = isOnEndpointPage(state) && hasSelectedEndpoint(state);
|
||||
|
||||
const stateUpdates: Partial<EndpointState> = {
|
||||
location: action.payload,
|
||||
error: undefined,
|
||||
detailsError: undefined,
|
||||
policyResponseError: undefined,
|
||||
};
|
||||
|
||||
// Reset `isolationRequestState` if needed
|
||||
if (
|
||||
uiQueryParams(newState).show !== 'isolate' &&
|
||||
!isUninitialisedResourceState(getCurrentIsolationRequestState(newState))
|
||||
) {
|
||||
stateUpdates.isolationRequestState = createUninitialisedResourceState();
|
||||
}
|
||||
|
||||
// if on the endpoint list page for the first time, return new location and load list
|
||||
if (isCurrentlyOnListPage) {
|
||||
if (!wasPreviouslyOnListPage) {
|
||||
return {
|
||||
...state,
|
||||
location: action.payload,
|
||||
...stateUpdates,
|
||||
loading: true,
|
||||
policyItemsLoading: true,
|
||||
error: undefined,
|
||||
detailsError: undefined,
|
||||
};
|
||||
}
|
||||
} else if (isCurrentlyOnDetailsPage) {
|
||||
|
@ -226,24 +248,18 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
|
|||
if (wasPreviouslyOnDetailsPage || wasPreviouslyOnListPage) {
|
||||
return {
|
||||
...state,
|
||||
location: action.payload,
|
||||
...stateUpdates,
|
||||
detailsLoading: true,
|
||||
policyResponseLoading: true,
|
||||
error: undefined,
|
||||
detailsError: undefined,
|
||||
policyResponseError: undefined,
|
||||
};
|
||||
} else {
|
||||
// if previous page was not endpoint list or endpoint details, load both list and details
|
||||
return {
|
||||
...state,
|
||||
location: action.payload,
|
||||
...stateUpdates,
|
||||
loading: true,
|
||||
detailsLoading: true,
|
||||
policyResponseLoading: true,
|
||||
error: undefined,
|
||||
detailsError: undefined,
|
||||
policyResponseError: undefined,
|
||||
policyItemsLoading: true,
|
||||
};
|
||||
}
|
||||
|
@ -251,12 +267,20 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
|
|||
// otherwise we are not on a endpoint list or details page
|
||||
return {
|
||||
...state,
|
||||
location: action.payload,
|
||||
error: undefined,
|
||||
detailsError: undefined,
|
||||
policyResponseError: undefined,
|
||||
...stateUpdates,
|
||||
endpointsExist: true,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const handleEndpointIsolationRequestStateChanged: ImmutableReducer<
|
||||
EndpointState,
|
||||
AppAction & { type: 'endpointIsolationRequestStateChange' }
|
||||
> = (state, action) => {
|
||||
return {
|
||||
...state!,
|
||||
isolationRequestState: action.payload,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -26,6 +26,12 @@ import {
|
|||
MANAGEMENT_ROUTING_ENDPOINTS_PATH,
|
||||
} from '../../../common/constants';
|
||||
import { Query } from '../../../../../../../../src/plugins/data/common/query/types';
|
||||
import {
|
||||
isFailedResourceState,
|
||||
isLoadedResourceState,
|
||||
isLoadingResourceState,
|
||||
} from '../../../state';
|
||||
import { ServerApiError } from '../../../../common/types';
|
||||
|
||||
export const listData = (state: Immutable<EndpointState>) => state.hosts;
|
||||
|
||||
|
@ -171,6 +177,7 @@ export const isOnEndpointPage = (state: Immutable<EndpointState>) => {
|
|||
);
|
||||
};
|
||||
|
||||
/** Sanitized list of URL query params supported by the Details page */
|
||||
export const uiQueryParams: (
|
||||
state: Immutable<EndpointState>
|
||||
) => Immutable<EndpointIndexUIQueryParams> = createSelector(
|
||||
|
@ -202,7 +209,7 @@ export const uiQueryParams: (
|
|||
|
||||
if (value !== undefined) {
|
||||
if (key === 'show') {
|
||||
if (value === 'policy_response' || value === 'details') {
|
||||
if (value === 'policy_response' || value === 'details' || value === 'isolate') {
|
||||
data[key] = value;
|
||||
}
|
||||
} else {
|
||||
|
@ -227,12 +234,11 @@ export const hasSelectedEndpoint: (state: Immutable<EndpointState>) => boolean =
|
|||
);
|
||||
|
||||
/** What policy details panel view to show */
|
||||
export const showView: (state: EndpointState) => 'policy_response' | 'details' = createSelector(
|
||||
uiQueryParams,
|
||||
(searchParams) => {
|
||||
return searchParams.show === 'policy_response' ? 'policy_response' : 'details';
|
||||
}
|
||||
);
|
||||
export const showView: (
|
||||
state: EndpointState
|
||||
) => EndpointIndexUIQueryParams['show'] = createSelector(uiQueryParams, (searchParams) => {
|
||||
return searchParams.show ?? 'details';
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the Host Status which is connected the fleet agent
|
||||
|
@ -299,3 +305,29 @@ export const searchBarQuery: (state: Immutable<EndpointState>) => Query = create
|
|||
return decodedQuery;
|
||||
}
|
||||
);
|
||||
|
||||
export const getCurrentIsolationRequestState = (
|
||||
state: Immutable<EndpointState>
|
||||
): EndpointState['isolationRequestState'] => {
|
||||
return state.isolationRequestState;
|
||||
};
|
||||
|
||||
export const getIsIsolationRequestPending: (
|
||||
state: Immutable<EndpointState>
|
||||
) => boolean = createSelector(getCurrentIsolationRequestState, (isolateHost) =>
|
||||
isLoadingResourceState(isolateHost)
|
||||
);
|
||||
|
||||
export const getWasIsolationRequestSuccessful: (
|
||||
state: Immutable<EndpointState>
|
||||
) => boolean = createSelector(getCurrentIsolationRequestState, (isolateHost) =>
|
||||
isLoadedResourceState(isolateHost)
|
||||
);
|
||||
|
||||
export const getIsolationRequestError: (
|
||||
state: Immutable<EndpointState>
|
||||
) => ServerApiError | undefined = createSelector(getCurrentIsolationRequestState, (isolateHost) => {
|
||||
if (isFailedResourceState(isolateHost)) {
|
||||
return isolateHost.error;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -14,10 +14,12 @@ import {
|
|||
PolicyData,
|
||||
MetadataQueryStrategyVersions,
|
||||
HostStatus,
|
||||
HostIsolationResponse,
|
||||
} from '../../../../common/endpoint/types';
|
||||
import { ServerApiError } from '../../../common/types';
|
||||
import { GetPackagesResponse } from '../../../../../fleet/common';
|
||||
import { IIndexPattern } from '../../../../../../../src/plugins/data/public';
|
||||
import { AsyncResourceState } from '../../state';
|
||||
|
||||
export interface EndpointState {
|
||||
/** list of host **/
|
||||
|
@ -80,9 +82,10 @@ export interface EndpointState {
|
|||
queryStrategyVersion?: MetadataQueryStrategyVersions;
|
||||
/** The policy IDs and revision number of the corresponding agent, and endpoint. May be more recent than what's running */
|
||||
policyVersionInfo?: HostInfo['policy_info'];
|
||||
/** The status of the host, which is mapped to the Elastic Agent status in Fleet
|
||||
*/
|
||||
/** The status of the host, which is mapped to the Elastic Agent status in Fleet */
|
||||
hostStatus?: HostStatus;
|
||||
/* Host isolation state */
|
||||
isolationRequestState: AsyncResourceState<HostIsolationResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -105,7 +108,7 @@ export interface EndpointIndexUIQueryParams {
|
|||
/** Which page to show */
|
||||
page_index?: string;
|
||||
/** show the policy response or host details */
|
||||
show?: 'policy_response' | 'details';
|
||||
show?: 'policy_response' | 'details' | 'isolate';
|
||||
/** Query text from search bar*/
|
||||
admin_query?: string;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* 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, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiContextMenuPanel,
|
||||
EuiPopover,
|
||||
EuiContextMenuItemProps,
|
||||
EuiContextMenuPanelProps,
|
||||
EuiContextMenuItem,
|
||||
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';
|
||||
|
||||
export interface TableRowActionProps {
|
||||
items: Array<
|
||||
Omit<EuiContextMenuItemProps, 'onClick'> & {
|
||||
navigateAppId: string;
|
||||
navigateOptions: NavigateToAppOptions;
|
||||
children: React.ReactNode;
|
||||
key: string;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export const TableRowActions = memo<TableRowActionProps>(({ items }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
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} />;
|
||||
});
|
||||
}, [handleCloseMenu, items]);
|
||||
|
||||
const panelProps: EuiPopoverProps['panelProps'] = useMemo(() => {
|
||||
return { 'data-test-subj': 'tableRowActionsMenuPanel' };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
anchorPosition="downRight"
|
||||
panelPaddingSize="none"
|
||||
panelProps={panelProps}
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
data-test-subj="endpointTableRowActions"
|
||||
iconType="boxesHorizontal"
|
||||
onClick={handleToggleMenu}
|
||||
aria-label={i18n.translate('xpack.securitySolution.endpoint.list.actionmenu', {
|
||||
defaultMessage: 'Open',
|
||||
})}
|
||||
/>
|
||||
}
|
||||
isOpen={isOpen}
|
||||
closePopover={handleCloseMenu}
|
||||
>
|
||||
<EuiContextMenuPanel items={menuItems} />
|
||||
</EuiPopover>
|
||||
);
|
||||
});
|
||||
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';
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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, useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FlyoutSubHeader, FlyoutSubHeaderProps } from './flyout_sub_header';
|
||||
import { getEndpointDetailsPath } from '../../../../../common/routing';
|
||||
import { useNavigateByRouterEventHandler } from '../../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler';
|
||||
import { useFormatUrl } from '../../../../../../common/components/link_to';
|
||||
import { SecurityPageName } from '../../../../../../../common/constants';
|
||||
import { useEndpointSelector } from '../../hooks';
|
||||
import { uiQueryParams } from '../../../store/selectors';
|
||||
|
||||
export const BackToEndpointDetailsFlyoutSubHeader = memo<{ endpointId: string }>(
|
||||
({ endpointId }) => {
|
||||
const { formatUrl } = useFormatUrl(SecurityPageName.administration);
|
||||
const { show, ...currentUrlQueryParams } = useEndpointSelector(uiQueryParams);
|
||||
|
||||
const detailsRoutePath = useMemo(
|
||||
() =>
|
||||
getEndpointDetailsPath({
|
||||
name: 'endpointDetails',
|
||||
...currentUrlQueryParams,
|
||||
selected_endpoint: endpointId,
|
||||
}),
|
||||
[currentUrlQueryParams, endpointId]
|
||||
);
|
||||
|
||||
const backToDetailsClickHandler = useNavigateByRouterEventHandler(detailsRoutePath);
|
||||
|
||||
const backButtonProp = useMemo((): FlyoutSubHeaderProps['backButton'] => {
|
||||
return {
|
||||
title: i18n.translate('xpack.securitySolution.endpoint.policyResponse.backLinkTitle', {
|
||||
defaultMessage: 'Endpoint Details',
|
||||
}),
|
||||
href: formatUrl(detailsRoutePath),
|
||||
onClick: backToDetailsClickHandler,
|
||||
};
|
||||
}, [backToDetailsClickHandler, detailsRoutePath, formatUrl]);
|
||||
|
||||
return (
|
||||
<FlyoutSubHeader
|
||||
backButton={backButtonProp}
|
||||
data-test-subj="endpointDetailsPolicyResponseFlyoutHeader"
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
BackToEndpointDetailsFlyoutSubHeader.displayName = 'BackToEndpointDetailsFlyoutSubHeader';
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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, useCallback, useEffect, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Dispatch } from 'redux';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { HostMetadata } from '../../../../../../../common/endpoint/types';
|
||||
import { BackToEndpointDetailsFlyoutSubHeader } from './back_to_endpoint_details_flyout_subheader';
|
||||
import {
|
||||
EndpointIsolatedFormProps,
|
||||
EndpointIsolateForm,
|
||||
EndpointIsolateSuccess,
|
||||
} from '../../../../../../common/components/endpoint/host_isolation';
|
||||
import { FlyoutBodyNoTopPadding } from './flyout_body_no_top_padding';
|
||||
import { getEndpointDetailsPath } from '../../../../../common/routing';
|
||||
import { useEndpointSelector } from '../../hooks';
|
||||
import {
|
||||
getIsolationRequestError,
|
||||
getIsIsolationRequestPending,
|
||||
getWasIsolationRequestSuccessful,
|
||||
uiQueryParams,
|
||||
} from '../../../store/selectors';
|
||||
import { AppAction } from '../../../../../../common/store/actions';
|
||||
import { useToasts } from '../../../../../../common/lib/kibana';
|
||||
|
||||
export const EndpointIsolateFlyoutPanel = memo<{
|
||||
hostMeta: HostMetadata;
|
||||
}>(({ hostMeta }) => {
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch<Dispatch<AppAction>>();
|
||||
const toast = useToasts();
|
||||
|
||||
const { show, ...queryParams } = useEndpointSelector(uiQueryParams);
|
||||
const isPending = useEndpointSelector(getIsIsolationRequestPending);
|
||||
const wasSuccessful = useEndpointSelector(getWasIsolationRequestSuccessful);
|
||||
const isolateError = useEndpointSelector(getIsolationRequestError);
|
||||
|
||||
const [formValues, setFormValues] = useState<
|
||||
Parameters<EndpointIsolatedFormProps['onChange']>[0]
|
||||
>({ comment: '' });
|
||||
|
||||
const handleCancel: EndpointIsolatedFormProps['onCancel'] = useCallback(() => {
|
||||
history.push(
|
||||
getEndpointDetailsPath({
|
||||
name: 'endpointDetails',
|
||||
...queryParams,
|
||||
selected_endpoint: hostMeta.agent.id,
|
||||
})
|
||||
);
|
||||
}, [history, hostMeta.agent.id, queryParams]);
|
||||
|
||||
const handleConfirm: EndpointIsolatedFormProps['onConfirm'] = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'endpointIsolationRequest',
|
||||
payload: {
|
||||
endpoint_ids: [hostMeta.agent.id],
|
||||
comment: formValues.comment,
|
||||
},
|
||||
});
|
||||
}, [dispatch, formValues.comment, hostMeta.agent.id]);
|
||||
|
||||
const handleChange: EndpointIsolatedFormProps['onChange'] = useCallback((changes) => {
|
||||
setFormValues((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
...changes,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isolateError) {
|
||||
toast.addDanger(isolateError.message);
|
||||
}
|
||||
}, [isolateError, toast]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackToEndpointDetailsFlyoutSubHeader endpointId={hostMeta.agent.id} />
|
||||
|
||||
<FlyoutBodyNoTopPadding>
|
||||
{wasSuccessful ? (
|
||||
<EndpointIsolateSuccess
|
||||
hostName={hostMeta.host.name}
|
||||
completeButtonLabel={i18n.translate(
|
||||
'xpack.securitySolution.endpoint.hostIsolation.successProceedButton',
|
||||
{ defaultMessage: 'Return to endpoint details' }
|
||||
)}
|
||||
onComplete={handleCancel}
|
||||
/>
|
||||
) : (
|
||||
<EndpointIsolateForm
|
||||
comment={formValues.comment}
|
||||
isLoading={isPending}
|
||||
hostName={hostMeta.host.name}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</FlyoutBodyNoTopPadding>
|
||||
</>
|
||||
);
|
||||
});
|
||||
EndpointIsolateFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel';
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 styled from 'styled-components';
|
||||
import { EuiFlyoutBody } from '@elastic/eui';
|
||||
|
||||
/**
|
||||
* Removes the `padding-top` from the `EuiFlyoutBody` component. Normally done when there is a
|
||||
* sub-header present above the flyout body.
|
||||
*/
|
||||
export const FlyoutBodyNoTopPadding = styled(EuiFlyoutBody)`
|
||||
.euiFlyoutBody__overflowContent {
|
||||
padding-top: 0;
|
||||
}
|
||||
`;
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, memo, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, memo } from 'react';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
|
@ -20,10 +20,8 @@ import {
|
|||
import { useHistory } from 'react-router-dom';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import styled from 'styled-components';
|
||||
import { useToasts } from '../../../../../common/lib/kibana';
|
||||
import { useEndpointSelector } from '../hooks';
|
||||
import { urlFromQueryParams } from '../url_from_query_params';
|
||||
import {
|
||||
uiQueryParams,
|
||||
detailsData,
|
||||
|
@ -43,12 +41,11 @@ import {
|
|||
import { EndpointDetails } from './endpoint_details';
|
||||
import { PolicyResponse } from './policy_response';
|
||||
import { HostMetadata } from '../../../../../../common/endpoint/types';
|
||||
import { FlyoutSubHeader, FlyoutSubHeaderProps } from './components/flyout_sub_header';
|
||||
import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler';
|
||||
import { getEndpointListPath } from '../../../../common/routing';
|
||||
import { SecurityPageName } from '../../../../../app/types';
|
||||
import { useFormatUrl } from '../../../../../common/components/link_to';
|
||||
import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date';
|
||||
import { EndpointIsolateFlyoutPanel } 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';
|
||||
|
||||
export const EndpointDetailsFlyout = memo(() => {
|
||||
const history = useHistory();
|
||||
|
@ -66,7 +63,13 @@ export const EndpointDetailsFlyout = memo(() => {
|
|||
const show = useEndpointSelector(showView);
|
||||
|
||||
const handleFlyoutClose = useCallback(() => {
|
||||
history.push(urlFromQueryParams(queryParamsWithoutSelectedEndpoint));
|
||||
const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint;
|
||||
history.push(
|
||||
getEndpointListPath({
|
||||
name: 'endpointList',
|
||||
...urlSearchParams,
|
||||
})
|
||||
);
|
||||
}, [history, queryParamsWithoutSelectedEndpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -106,25 +109,20 @@ export const EndpointDetailsFlyout = memo(() => {
|
|||
)}
|
||||
</EuiFlyoutHeader>
|
||||
{details === undefined ? (
|
||||
<>
|
||||
<EuiFlyoutBody>
|
||||
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
|
||||
</EuiFlyoutBody>
|
||||
</>
|
||||
<EuiFlyoutBody>
|
||||
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
|
||||
</EuiFlyoutBody>
|
||||
) : (
|
||||
<>
|
||||
{show === 'details' && (
|
||||
<>
|
||||
<EuiFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
|
||||
<EndpointDetails
|
||||
details={details}
|
||||
policyInfo={policyInfo}
|
||||
hostStatus={hostStatus}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
</>
|
||||
<EuiFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
|
||||
<EndpointDetails details={details} policyInfo={policyInfo} hostStatus={hostStatus} />
|
||||
</EuiFlyoutBody>
|
||||
)}
|
||||
|
||||
{show === 'policy_response' && <PolicyResponseFlyoutPanel hostMeta={details} />}
|
||||
|
||||
{show === 'isolate' && <EndpointIsolateFlyoutPanel hostMeta={details} />}
|
||||
</>
|
||||
)}
|
||||
</EuiFlyout>
|
||||
|
@ -133,59 +131,22 @@ export const EndpointDetailsFlyout = memo(() => {
|
|||
|
||||
EndpointDetailsFlyout.displayName = 'EndpointDetailsFlyout';
|
||||
|
||||
const PolicyResponseFlyoutBody = styled(EuiFlyoutBody)`
|
||||
.euiFlyoutBody__overflowContent {
|
||||
padding-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const PolicyResponseFlyoutPanel = memo<{
|
||||
hostMeta: HostMetadata;
|
||||
}>(({ hostMeta }) => {
|
||||
const { show, ...queryParams } = useEndpointSelector(uiQueryParams);
|
||||
const responseConfig = useEndpointSelector(policyResponseConfigurations);
|
||||
const responseActions = useEndpointSelector(policyResponseActions);
|
||||
const responseAttentionCount = useEndpointSelector(policyResponseFailedOrWarningActionCount);
|
||||
const loading = useEndpointSelector(policyResponseLoading);
|
||||
const error = useEndpointSelector(policyResponseError);
|
||||
const { formatUrl } = useFormatUrl(SecurityPageName.administration);
|
||||
const responseTimestamp = useEndpointSelector(policyResponseTimestamp);
|
||||
const responsePolicyRevisionNumber = useEndpointSelector(policyResponseAppliedRevision);
|
||||
const [detailsUri, detailsRoutePath] = useMemo(
|
||||
() => [
|
||||
formatUrl(
|
||||
getEndpointListPath({
|
||||
name: 'endpointList',
|
||||
...queryParams,
|
||||
selected_endpoint: hostMeta.agent.id,
|
||||
})
|
||||
),
|
||||
getEndpointListPath({
|
||||
name: 'endpointList',
|
||||
...queryParams,
|
||||
selected_endpoint: hostMeta.agent.id,
|
||||
}),
|
||||
],
|
||||
[hostMeta.agent.id, formatUrl, queryParams]
|
||||
);
|
||||
const backToDetailsClickHandler = useNavigateByRouterEventHandler(detailsRoutePath);
|
||||
const backButtonProp = useMemo((): FlyoutSubHeaderProps['backButton'] => {
|
||||
return {
|
||||
title: i18n.translate('xpack.securitySolution.endpoint.policyResponse.backLinkTitle', {
|
||||
defaultMessage: 'Endpoint Details',
|
||||
}),
|
||||
href: detailsUri,
|
||||
onClick: backToDetailsClickHandler,
|
||||
};
|
||||
}, [backToDetailsClickHandler, detailsUri]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlyoutSubHeader
|
||||
backButton={backButtonProp}
|
||||
data-test-subj="endpointDetailsPolicyResponseFlyoutHeader"
|
||||
/>
|
||||
<PolicyResponseFlyoutBody
|
||||
<BackToEndpointDetailsFlyoutSubHeader endpointId={hostMeta.agent.id} />
|
||||
|
||||
<FlyoutBodyNoTopPadding
|
||||
data-test-subj="endpointDetailsPolicyResponseFlyoutBody"
|
||||
className="endpointDetailsPolicyResponseFlyoutBody"
|
||||
>
|
||||
|
@ -227,7 +188,7 @@ const PolicyResponseFlyoutPanel = memo<{
|
|||
responseAttentionCount={responseAttentionCount}
|
||||
/>
|
||||
)}
|
||||
</PolicyResponseFlyoutBody>
|
||||
</FlyoutBodyNoTopPadding>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -27,6 +27,16 @@ import {
|
|||
import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data';
|
||||
import { POLICY_STATUS_TO_TEXT } from './host_constants';
|
||||
import { mockPolicyResultList } from '../../policy/store/test_mock_utils';
|
||||
import { getEndpointDetailsPath } from '../../../common/routing';
|
||||
import { KibanaServices, useKibana, useToasts } from '../../../../common/lib/kibana';
|
||||
import { hostIsolationHttpMocks } from '../../../../common/lib/host_isolation/mocks';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import {
|
||||
isFailedResourceState,
|
||||
isLoadedResourceState,
|
||||
isUninitialisedResourceState,
|
||||
} from '../../../state';
|
||||
import { getCurrentIsolationRequestState } from '../store/selectors';
|
||||
|
||||
// not sure why this can't be imported from '../../../../common/mock/formatted_relative';
|
||||
// but sure enough it needs to be inline in this one file
|
||||
|
@ -47,8 +57,13 @@ jest.mock('../../policy/store/services/ingest', () => {
|
|||
sendGetEndpointSecurityPackage: () => Promise.resolve({}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
describe('when on the endpoint list page', () => {
|
||||
const docGenerator = new EndpointDocGenerator();
|
||||
const act = reactTestingLibrary.act;
|
||||
|
||||
let render: () => ReturnType<AppContextTestRender['render']>;
|
||||
let history: AppContextTestRender['history'];
|
||||
let store: AppContextTestRender['store'];
|
||||
|
@ -71,6 +86,11 @@ describe('when on the endpoint list page', () => {
|
|||
reactTestingLibrary.act(() => {
|
||||
history.push('/endpoints');
|
||||
});
|
||||
|
||||
// Because `.../common/lib/kibana` was mocked, we need to alter these hooks (which are jest.MockFunctions)
|
||||
// to use services that we have in our test `mockedContext`
|
||||
(useToasts as jest.Mock).mockReturnValue(coreStart.notifications.toasts);
|
||||
(useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices });
|
||||
});
|
||||
|
||||
it('should NOT display timeline', async () => {
|
||||
|
@ -608,6 +628,7 @@ describe('when on the endpoint list page', () => {
|
|||
return renderResult;
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
@ -873,14 +894,152 @@ describe('when on the endpoint list page', () => {
|
|||
expect(renderResult.getByText('A New Unknown Action')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when showing the Host Isolate panel', () => {
|
||||
const getKibanaServicesMock = KibanaServices.get as jest.Mock;
|
||||
const confirmIsolateAndWaitForApiResponse = async (
|
||||
typeOfResponse: 'success' | 'failure' = 'success'
|
||||
) => {
|
||||
const isolateResponseAction = middlewareSpy.waitForAction(
|
||||
'endpointIsolationRequestStateChange',
|
||||
{
|
||||
validate(action) {
|
||||
if (typeOfResponse === 'failure') {
|
||||
return isFailedResourceState(action.payload);
|
||||
}
|
||||
|
||||
return isLoadedResourceState(action.payload);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(renderResult.getByTestId('hostIsolateConfirmButton'));
|
||||
await isolateResponseAction;
|
||||
});
|
||||
};
|
||||
|
||||
let isolateApiMock: ReturnType<typeof hostIsolationHttpMocks>;
|
||||
let renderResult: ReturnType<AppContextTestRender['render']>;
|
||||
|
||||
beforeEach(async () => {
|
||||
getKibanaServicesMock.mockReturnValue(coreStart);
|
||||
reactTestingLibrary.act(() => {
|
||||
history.push('/endpoints?selected_endpoint=1&show=isolate');
|
||||
});
|
||||
renderResult = await renderAndWaitForData();
|
||||
coreStart.http.post.mockReset();
|
||||
isolateApiMock = hostIsolationHttpMocks(coreStart.http);
|
||||
});
|
||||
|
||||
it('should show the isolate form', () => {
|
||||
expect(renderResult.getByTestId('host_isolation_comment')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should take you back to details when back link below the flyout header is clicked', async () => {
|
||||
const backButtonLink = renderResult.getByTestId('flyoutSubHeaderBackButton');
|
||||
|
||||
expect(backButtonLink.getAttribute('href')).toEqual(
|
||||
getEndpointDetailsPath({
|
||||
name: 'endpointDetails',
|
||||
page_index: '0',
|
||||
page_size: '10',
|
||||
selected_endpoint: '1',
|
||||
})
|
||||
);
|
||||
|
||||
const changeUrlAction = middlewareSpy.waitForAction('userChangedUrl');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(backButtonLink);
|
||||
});
|
||||
|
||||
expect((await changeUrlAction).payload).toMatchObject({
|
||||
pathname: '/endpoints',
|
||||
search: '?page_index=0&page_size=10&selected_endpoint=1',
|
||||
});
|
||||
});
|
||||
|
||||
it('take you back to details when Cancel button is clicked', async () => {
|
||||
const changeUrlAction = middlewareSpy.waitForAction('userChangedUrl');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId('hostIsolateCancelButton'));
|
||||
});
|
||||
|
||||
expect((await changeUrlAction).payload).toMatchObject({
|
||||
pathname: '/endpoints',
|
||||
search: '?page_index=0&page_size=10&selected_endpoint=1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should isolate endpoint host when confirm is clicked', async () => {
|
||||
await confirmIsolateAndWaitForApiResponse();
|
||||
expect(renderResult.getByTestId('hostIsolateSuccessMessage')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should navigate to details when the Complete button on success message is clicked', async () => {
|
||||
await confirmIsolateAndWaitForApiResponse();
|
||||
|
||||
const changeUrlAction = middlewareSpy.waitForAction('userChangedUrl');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId('hostIsolateSuccessCompleteButton'));
|
||||
});
|
||||
|
||||
expect((await changeUrlAction).payload).toMatchObject({
|
||||
pathname: '/endpoints',
|
||||
search: '?page_index=0&page_size=10&selected_endpoint=1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error toast 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'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset isolation state and show form again', async () => {
|
||||
// ensures that after the host isolation has been successful, if user navigates away from the panel
|
||||
// (`show` is NOT `isolate`), then the state should be reset so that the form show up again the next
|
||||
// time `isolate host` is clicked
|
||||
await confirmIsolateAndWaitForApiResponse();
|
||||
expect(renderResult.getByTestId('hostIsolateSuccessMessage')).not.toBeNull();
|
||||
|
||||
// Close flyout
|
||||
const changeUrlAction = middlewareSpy.waitForAction('userChangedUrl');
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId('euiFlyoutCloseButton'));
|
||||
});
|
||||
|
||||
expect((await changeUrlAction).payload).toMatchObject({
|
||||
pathname: '/endpoints',
|
||||
search: '?page_index=0&page_size=10',
|
||||
});
|
||||
|
||||
expect(
|
||||
isUninitialisedResourceState(
|
||||
getCurrentIsolationRequestState(store.getState().management.endpoints)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the more actions column is opened', () => {
|
||||
const generator = new EndpointDocGenerator('seed');
|
||||
let hostInfo: HostInfo;
|
||||
let agentId: string;
|
||||
let agentPolicyId: string;
|
||||
const generator = new EndpointDocGenerator('seed');
|
||||
let renderAndWaitForData: () => Promise<ReturnType<AppContextTestRender['render']>>;
|
||||
let renderResult: ReturnType<AppContextTestRender['render']>;
|
||||
|
||||
const mockEndpointListApi = () => {
|
||||
const { hosts, query_strategy_version: queryStrategyVersion } = mockEndpointResultList();
|
||||
|
@ -902,20 +1061,13 @@ describe('when on the endpoint list page', () => {
|
|||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
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':
|
||||
|
@ -925,42 +1077,43 @@ describe('when on the endpoint list page', () => {
|
|||
}
|
||||
return appName;
|
||||
});
|
||||
|
||||
renderResult = render();
|
||||
await middlewareSpy.waitForAction('serverReturnedEndpointList');
|
||||
await middlewareSpy.waitForAction('serverReturnedEndpointAgentPolicies');
|
||||
|
||||
const endpointActionsButton = await renderResult.findByTestId('endpointTableRowActions');
|
||||
|
||||
reactTestingLibrary.act(() => {
|
||||
reactTestingLibrary.fireEvent.click(endpointActionsButton);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
it('navigates to the Host Details Isolate flyout', async () => {
|
||||
const isolateLink = await renderResult.findByTestId('isolateLink');
|
||||
expect(isolateLink.getAttribute('href')).toEqual(
|
||||
getEndpointDetailsPath({
|
||||
name: 'endpointIsolate',
|
||||
selected_endpoint: hostInfo.metadata.agent.id,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the Security Solution Host Details page', async () => {
|
||||
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/fleet#/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/fleet#/fleet/agents/${agentId}`);
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback, memo, useState, useContext } from 'react';
|
||||
import React, { useMemo, useCallback, memo, useContext } from 'react';
|
||||
import {
|
||||
EuiHorizontalRule,
|
||||
EuiBasicTable,
|
||||
|
@ -17,11 +17,6 @@ import {
|
|||
EuiSelectableProps,
|
||||
EuiSuperDatePicker,
|
||||
EuiSpacer,
|
||||
EuiPopover,
|
||||
EuiContextMenuItem,
|
||||
EuiContextMenuPanel,
|
||||
EuiContextMenuPanelProps,
|
||||
EuiButtonIcon,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiCallOut,
|
||||
|
@ -31,8 +26,6 @@ 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 { ThemeContext } from 'styled-components';
|
||||
import { EndpointDetailsFlyout } from './details';
|
||||
import * as selectors from '../store/selectors';
|
||||
|
@ -46,7 +39,11 @@ 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_PAGE_SIZE_OPTIONS } from '../../../common/constants';
|
||||
import {
|
||||
DEFAULT_POLL_INTERVAL,
|
||||
MANAGEMENT_APP_ID,
|
||||
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';
|
||||
|
@ -66,6 +63,7 @@ import { AdministrationListPage } from '../../../components/administration_list_
|
|||
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';
|
||||
|
||||
const MAX_PAGINATED_ITEM = 9999;
|
||||
|
||||
|
@ -104,36 +102,6 @@ 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 (
|
||||
<EuiPopover
|
||||
anchorPosition="downRight"
|
||||
panelPaddingSize="none"
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
data-test-subj="endpointTableRowActions"
|
||||
iconType="boxesHorizontal"
|
||||
onClick={handleToggleMenu}
|
||||
aria-label={i18n.translate('xpack.securitySolution.endpoint.list.actionmenu', {
|
||||
defaultMessage: 'Open',
|
||||
})}
|
||||
/>
|
||||
}
|
||||
isOpen={isOpen}
|
||||
closePopover={handleCloseMenu}
|
||||
>
|
||||
<EuiContextMenuPanel items={items} />
|
||||
</EuiPopover>
|
||||
);
|
||||
});
|
||||
TableRowActions.displayName = 'EndpointTableRowActions';
|
||||
|
||||
const selector = (createStructuredSelector as CreateStructuredSelector)(selectors);
|
||||
export const EndpointList = () => {
|
||||
const history = useHistory();
|
||||
|
@ -234,7 +202,7 @@ export const EndpointList = () => {
|
|||
|
||||
const NOOP = useCallback(() => {}, []);
|
||||
|
||||
const PAD_LEFT: React.CSSProperties = { paddingLeft: '6px' };
|
||||
const PAD_LEFT: React.CSSProperties = useMemo(() => ({ paddingLeft: '6px' }), []);
|
||||
|
||||
const handleDeployEndpointsClick = useNavigateToAppEventHandler<AgentPolicyDetailsDeployAgentAction>(
|
||||
'fleet',
|
||||
|
@ -459,71 +427,93 @@ 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={[
|
||||
<EuiContextMenuItemNavByRouter
|
||||
data-test-subj="hostLink"
|
||||
icon="logoSecurity"
|
||||
key="hostDetailsLink"
|
||||
navigateAppId={APP_ID}
|
||||
navigateOptions={{ path: `hosts/${item.metadata.host.hostname}` }}
|
||||
href={`${services?.application?.getUrlForApp('securitySolution')}/hosts/${
|
||||
{
|
||||
'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
|
||||
}`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.list.actions.hostDetails"
|
||||
defaultMessage="View Host Details"
|
||||
/>
|
||||
</EuiContextMenuItemNavByRouter>,
|
||||
<EuiContextMenuItemNavByRouter
|
||||
icon="logoObservability"
|
||||
key="agentConfigLink"
|
||||
data-test-subj="agentPolicyLink"
|
||||
navigateAppId="fleet"
|
||||
navigateOptions={{
|
||||
}`,
|
||||
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(
|
||||
},
|
||||
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
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.list.actions.agentPolicy"
|
||||
defaultMessage="View Agent Policy"
|
||||
/>
|
||||
</EuiContextMenuItemNavByRouter>,
|
||||
<EuiContextMenuItemNavByRouter
|
||||
icon="logoObservability"
|
||||
key="agentDetailsLink"
|
||||
data-test-subj="agentDetailsLink"
|
||||
navigateAppId="fleet"
|
||||
navigateOptions={{
|
||||
})}`,
|
||||
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(
|
||||
},
|
||||
href: `${services?.application?.getUrlForApp(
|
||||
'fleet'
|
||||
)}#${pagePathGetters.fleet_agent_details({
|
||||
agentId: item.metadata.elastic.agent.id,
|
||||
})}`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.list.actions.agentDetails"
|
||||
defaultMessage="View Agent Details"
|
||||
/>
|
||||
</EuiContextMenuItemNavByRouter>,
|
||||
})}`,
|
||||
children: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.list.actions.agentDetails"
|
||||
defaultMessage="View Agent Details"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
@ -532,8 +522,7 @@ export const EndpointList = () => {
|
|||
],
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formatUrl, queryParams, search, agentPolicies, services?.application?.getUrlForApp]);
|
||||
}, [queryParams, search, formatUrl, PAD_LEFT, services?.application, agentPolicies]);
|
||||
|
||||
const renderTableOrEmptyState = useMemo(() => {
|
||||
if (endpointsExist || areEndpointsEnrolling) {
|
||||
|
@ -697,20 +686,3 @@ export const EndpointList = () => {
|
|||
</AdministrationListPage>
|
||||
);
|
||||
};
|
||||
|
||||
const EuiContextMenuItemNavByRouter = memo<
|
||||
Omit<EuiContextMenuItemProps, 'onClick'> & {
|
||||
navigateAppId: string;
|
||||
navigateOptions: NavigateToAppOptions;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
>(({ navigateAppId, navigateOptions, children, ...otherMenuItemProps }) => {
|
||||
const handleOnClick = useNavigateToAppEventHandler(navigateAppId, navigateOptions);
|
||||
|
||||
return (
|
||||
<EuiContextMenuItem {...otherMenuItemProps} onClick={handleOnClick}>
|
||||
{children}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
});
|
||||
EuiContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter';
|
||||
|
|
|
@ -173,7 +173,7 @@ describe('Host Isolation', () => {
|
|||
expect(mockResponse.ok).not.toBeCalled();
|
||||
const response = mockResponse.customError.mock.calls[0][0];
|
||||
expect(response.statusCode).toEqual(500);
|
||||
expect((response.body as HostIsolationResponse).message).toEqual(ErrMessage);
|
||||
expect((response.body as Error).message).toEqual(ErrMessage);
|
||||
});
|
||||
it('accepts a comment field', async () => {
|
||||
await callRoute(ISOLATE_HOST_ROUTE, { body: { agent_ids: ['XYZ'], comment: 'XYZ' } });
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue