[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:
Paul Tavares 2021-06-04 10:59:53 -04:00 committed by GitHub
parent 690e81aa60
commit 36996634c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 799 additions and 207 deletions

View file

@ -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;
};
};

View file

@ -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!`;
}

View file

@ -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;

View file

@ -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);
});
},

View file

@ -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) => {

View file

@ -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);
});
});

View file

@ -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);
};

View file

@ -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 });

View file

@ -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;

View file

@ -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,
]);

View file

@ -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'> & {

View file

@ -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 () => {

View file

@ -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',

View file

@ -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;
});

View file

@ -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;
}

View file

@ -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>
);
}
);

View file

@ -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';

View file

@ -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();
});
});
});

View file

@ -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';

View file

@ -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';

View file

@ -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>

View file

@ -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
*/

View file

@ -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';

View file

@ -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]);
};

View file

@ -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,
})
);

View file

@ -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) {

View file

@ -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": "バージョン",

View file

@ -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": "版本",

View file

@ -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",