[Security solution][Endpoint] Enable the new agent status API/Component (#186669)

## Summary

PR enables the use of the new Agent Status API and associated UI
components, including:

- Deleted feature key `agentStatusClientEnabled`
- Removal of several modules that are now absolute
- cleaned up code in several components and tests to now only use
`AgentStatus` component (handles all `agentType`s)
- Adjust `AgentStatus` component to also accept `statusInfo` as a prop
- Needed in order to enable more efficient use of this component on a
list
    - API call to get agent status will not be made when prop is used
This commit is contained in:
Paul Tavares 2024-06-26 14:10:53 -04:00 committed by GitHub
parent 8ada1ac8d2
commit e5d37fca63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 928 additions and 1969 deletions

View file

@ -0,0 +1,47 @@
/*
* 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 { merge } from 'lodash';
import type { DeepPartial } from 'utility-types';
import type { AgentStatusRecords, AgentStatusApiResponse, AgentStatusInfo } from '../../../types';
import { HostStatus } from '../../../types';
const generateAgentStatusMock = (overrides: DeepPartial<AgentStatusInfo> = {}): AgentStatusInfo => {
return merge(
{
agentId: 'abfe4a35-d5b4-42a0-a539-bd054c791769',
agentType: 'endpoint',
found: true,
isolated: false,
lastSeen: new Date().toISOString(),
pendingActions: {},
status: HostStatus.HEALTHY,
},
overrides
) as AgentStatusInfo;
};
const generateAgentStatusRecordsMock = (
overrides: DeepPartial<AgentStatusRecords> = {}
): AgentStatusRecords => {
return merge(
{ 'abfe4a35-d5b4-42a0-a539-bd054c791769': generateAgentStatusMock() },
overrides
) as AgentStatusRecords;
};
const generateAgentStatusApiResponseMock = (
overrides: DeepPartial<AgentStatusApiResponse> = {}
): AgentStatusApiResponse => {
return merge({ data: generateAgentStatusRecordsMock() }, overrides);
};
export const agentStatusMocks = Object.freeze({
generateAgentStatus: generateAgentStatusMock,
generateAgentStatusRecords: generateAgentStatusRecordsMock,
generateAgentStatusApiResponse: generateAgentStatusApiResponseMock,
});

View file

@ -11,22 +11,20 @@ import type {
ResponseActionsApiCommandNames,
} from '../service/response_actions/constants';
export interface AgentStatusRecords {
[agentId: string]: {
agentId: string;
agentType: ResponseActionAgentType;
found: boolean;
isolated: boolean;
lastSeen: string; // ISO date
pendingActions: Record<ResponseActionsApiCommandNames | string, number>;
status: HostStatus;
};
export interface AgentStatusInfo {
agentId: string;
agentType: ResponseActionAgentType;
found: boolean;
isolated: boolean;
lastSeen: string; // ISO date
pendingActions: Record<ResponseActionsApiCommandNames | string, number>;
status: HostStatus;
}
// TODO: 8.15 remove when `agentStatusClientEnabled` is enabled/removed
export interface AgentStatusInfo {
[agentId: string]: AgentStatusRecords[string] & {
isPendingUninstall: boolean;
isUninstalled: boolean;
};
export interface AgentStatusRecords {
[agentId: string]: AgentStatusInfo;
}
export interface AgentStatusApiResponse {
data: AgentStatusRecords;
}

View file

@ -81,13 +81,6 @@ export const allowedExperimentalValues = Object.freeze({
/** Enables the `get-file` response action for SentinelOne */
responseActionsSentinelOneGetFileEnabled: false,
/**
* 8.15
* Enables use of agent status service to get agent status information
* for endpoint and third-party agents.
*/
agentStatusClientEnabled: false,
/**
* Enables the ability to send Response actions to Crowdstrike and persist the results
* in ES.

View file

@ -12,7 +12,6 @@ export * from './detail_panel';
export * from './header_actions';
export * from './session_view';
export * from './bulk_actions';
export * from './third_party_agent';
export const FILTER_OPEN: Status = 'open';
export const FILTER_CLOSED: Status = 'closed';

View file

@ -1,24 +0,0 @@
/*
* 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 type { ResponseActionAgentType } from '../../endpoint/service/response_actions/constants';
export interface ThirdPartyAgentInfo {
agent: {
id: string;
type: ResponseActionAgentType;
};
host: {
name: string;
os: {
name: string;
family: string;
version: string;
};
};
lastCheckin: string;
}

View file

@ -8,10 +8,10 @@
import React, { memo, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiTextColor, EuiToolTip } from '@elastic/eui';
import { ISOLATED_LABEL, ISOLATING_LABEL, RELEASING_LABEL } from './translations';
import type { EndpointPendingActions } from '../../../../../../common/endpoint/types';
import type { ResponseActionsApiCommandNames } from '../../../../../../common/endpoint/service/response_actions/constants';
import { RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP } from '../../../../../../common/endpoint/service/response_actions/constants';
import { ISOLATED_LABEL, ISOLATING_LABEL, RELEASING_LABEL } from './endpoint/endpoint_agent_status';
import { useTestIdGenerator } from '../../../../../management/hooks/use_test_id_generator';
const TOOLTIP_CONTENT_STYLES: React.CSSProperties = Object.freeze({ width: 150 });

View file

@ -7,57 +7,79 @@
import React from 'react';
import type { AgentStatusProps } from './agent_status';
import { AgentStatus } from './agent_status';
import {
useAgentStatusHook,
useGetAgentStatus,
} from '../../../../../management/hooks/agents/use_get_agent_status';
import { useGetAgentStatus as _useGetAgentStatus } from '../../../../../management/hooks/agents/use_get_agent_status';
import {
RESPONSE_ACTION_AGENT_TYPE,
type ResponseActionAgentType,
} from '../../../../../../common/endpoint/service/response_actions/constants';
import type { AppContextTestRender } from '../../../../mock/endpoint';
import { createAppRootMockRenderer } from '../../../../mock/endpoint';
import type { AgentStatusInfo } from '../../../../../../common/endpoint/types';
import { HostStatus } from '../../../../../../common/endpoint/types';
jest.mock('../../../../hooks/use_experimental_features');
jest.mock('../../../../../management/hooks/agents/use_get_agent_status');
const getAgentStatusMock = useGetAgentStatus as jest.Mock;
const useAgentStatusHookMock = useAgentStatusHook as jest.Mock;
const useGetAgentStatusMock = _useGetAgentStatus as jest.Mock;
describe('AgentStatus component', () => {
let render: (agentType?: ResponseActionAgentType) => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let mockedContext: AppContextTestRender;
const agentId = 'agent-id-1234';
const baseData = {
agentId,
found: true,
isolated: false,
lastSeen: new Date().toISOString(),
pendingActions: {},
status: HostStatus.HEALTHY,
};
let baseData: AgentStatusInfo;
let agentId: string;
let statusInfoProp: AgentStatusProps['statusInfo'];
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
render = (agentType?: ResponseActionAgentType) =>
(renderResult = mockedContext.render(
<AgentStatus agentId={agentId} agentType={agentType || 'endpoint'} data-test-subj="test" />
));
render = (agentType: ResponseActionAgentType = 'endpoint') => {
baseData.agentType = agentType;
getAgentStatusMock.mockReturnValue({ data: {} });
useAgentStatusHookMock.mockImplementation(() => useGetAgentStatus);
return (renderResult = mockedContext.render(
<AgentStatus
agentId={agentId}
agentType={agentType}
statusInfo={statusInfoProp}
data-test-subj="test"
/>
));
};
useGetAgentStatusMock.mockReturnValue({ data: {} });
baseData = {
agentId,
found: true,
isolated: false,
lastSeen: new Date().toISOString(),
pendingActions: {},
status: HostStatus.HEALTHY,
agentType: 'endpoint',
};
agentId = 'agent-id-1234';
statusInfoProp = undefined;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should call the API when `agentId` is provided and no `statusInfo` prop', () => {
render();
expect(useGetAgentStatusMock).toHaveBeenCalledWith(agentId, 'endpoint', { enabled: true });
});
it('should NOT call the API when `statusInfo` prop is provided', () => {
statusInfoProp = baseData;
render();
expect(useGetAgentStatusMock).toHaveBeenCalledWith(agentId, 'endpoint', { enabled: false });
});
describe.each(RESPONSE_ACTION_AGENT_TYPE)('`%s` agentType', (agentType) => {
it('should show agent health status info', () => {
getAgentStatusMock.mockReturnValue({
useGetAgentStatusMock.mockReturnValue({
data: {
[agentId]: { ...baseData, agentType, status: HostStatus.OFFLINE },
},
@ -74,7 +96,7 @@ describe('AgentStatus component', () => {
});
it('should show agent health status info and Isolated status', () => {
getAgentStatusMock.mockReturnValue({
useGetAgentStatusMock.mockReturnValue({
data: {
[agentId]: {
...baseData,
@ -95,7 +117,7 @@ describe('AgentStatus component', () => {
});
it('should show agent health status info and Releasing status', () => {
getAgentStatusMock.mockReturnValue({
useGetAgentStatusMock.mockReturnValue({
data: {
[agentId]: {
...baseData,
@ -119,7 +141,7 @@ describe('AgentStatus component', () => {
});
it('should show agent health status info and Isolating status', () => {
getAgentStatusMock.mockReturnValue({
useGetAgentStatusMock.mockReturnValue({
data: {
[agentId]: {
...baseData,
@ -142,7 +164,7 @@ describe('AgentStatus component', () => {
});
it('should show agent health status info and Releasing status also when multiple actions are pending', () => {
getAgentStatusMock.mockReturnValue({
useGetAgentStatusMock.mockReturnValue({
data: {
[agentId]: {
...baseData,
@ -168,7 +190,7 @@ describe('AgentStatus component', () => {
});
it('should show agent health status info and Isolating status also when multiple actions are pending', () => {
getAgentStatusMock.mockReturnValue({
useGetAgentStatusMock.mockReturnValue({
data: {
[agentId]: {
...baseData,
@ -193,7 +215,7 @@ describe('AgentStatus component', () => {
});
it('should show agent health status info and pending action status when not isolating/releasing', () => {
getAgentStatusMock.mockReturnValue({
useGetAgentStatusMock.mockReturnValue({
data: {
[agentId]: {
...baseData,
@ -217,7 +239,7 @@ describe('AgentStatus component', () => {
});
it('should show agent health status info and Isolated when pending actions', () => {
getAgentStatusMock.mockReturnValue({
useGetAgentStatusMock.mockReturnValue({
data: {
[agentId]: {
...baseData,

View file

@ -8,20 +8,14 @@
import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { getAgentStatusText } from './translations';
import { getEmptyTagValue } from '../../../empty_value';
import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants';
import type { EndpointPendingActions } from '../../../../../../common/endpoint/types';
import { useAgentStatusHook } from '../../../../../management/hooks/agents/use_get_agent_status';
import { useGetAgentStatus } from '../../../../../management/hooks/agents/use_get_agent_status';
import { useTestIdGenerator } from '../../../../../management/hooks/use_test_id_generator';
import { HOST_STATUS_TO_BADGE_COLOR } from '../../../../../management/pages/endpoint_hosts/view/host_constants';
import { useIsExperimentalFeatureEnabled } from '../../../../hooks/use_experimental_features';
import { getAgentStatusText } from '../agent_status_text';
import { AgentResponseActionsStatus } from './agent_response_action_status';
export enum SENTINEL_ONE_NETWORK_STATUS {
CONNECTING = 'connecting',
CONNECTED = 'connected',
DISCONNECTING = 'disconnecting',
DISCONNECTED = 'disconnected',
}
import type { AgentStatusInfo } from '../../../../../../common/endpoint/types';
const EuiFlexGroupStyled = styled(EuiFlexGroup)`
.isolation-status {
@ -29,42 +23,61 @@ const EuiFlexGroupStyled = styled(EuiFlexGroup)`
}
`;
export const AgentStatus = React.memo(
({
agentId,
agentType,
'data-test-subj': dataTestSubj,
}: {
agentId: string;
agentType: ResponseActionAgentType;
'data-test-subj'?: string;
}) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const useAgentStatus = useAgentStatusHook();
export interface AgentStatusProps {
agentType: ResponseActionAgentType;
/**
* The agent id for which the status will be displayed. An API call will be made to retrieve the
* status. If using this component on a List view, use `statusInfo` prop instead and make API
* call to retrieve all statuses of displayed agents at the view level in order to keep API calls
* to a minimum
*
* NOTE: will be ignored if `statusInfo` prop is defined!
*/
agentId?: string;
/**
* The status info for the agent. When both `agentId` and `agentInfo` are defined, `agentInfo` will
* be used and `agentId` ignored.
*/
statusInfo?: AgentStatusInfo;
'data-test-subj'?: string;
}
const sentinelOneManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'sentinelOneManualHostActionsEnabled'
);
const responseActionsCrowdstrikeManualHostIsolationEnabled = useIsExperimentalFeatureEnabled(
'responseActionsCrowdstrikeManualHostIsolationEnabled'
);
const { data, isLoading, isFetched } = useAgentStatus([agentId], agentType, {
enabled:
sentinelOneManualHostActionsEnabled || responseActionsCrowdstrikeManualHostIsolationEnabled,
/**
* Display the agent status of a host that supports response actions.
*
* IMPORTANT: If using this component on a list view, ensure that `statusInfo` prop is used instead
* of `agentId` in order to ensure API calls are kept to a minimum and the list view
* remains more performant.
*/
export const AgentStatus = React.memo(
({ agentId, agentType, statusInfo, 'data-test-subj': dataTestSubj }: AgentStatusProps) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const enableApiCall = useMemo(() => {
return !statusInfo || !agentId;
}, [agentId, statusInfo]);
const { data, isLoading, isFetched } = useGetAgentStatus(agentId ?? '', agentType, {
enabled: enableApiCall,
});
const agentStatus = data?.[`${agentId}`];
const agentStatus: AgentStatusInfo | undefined = useMemo(() => {
if (statusInfo) {
return statusInfo;
}
return data?.[agentId ?? ''];
}, [agentId, data, statusInfo]);
const isCurrentlyIsolated = Boolean(agentStatus?.isolated);
const pendingActions = agentStatus?.pendingActions;
const [hasPendingActions, hostPendingActions] = useMemo<
[boolean, EndpointPendingActions['pending_actions']]
[boolean, AgentStatusInfo['pendingActions']]
>(() => {
const pendingActions = agentStatus?.pendingActions;
if (!pendingActions) {
return [false, {}];
}
return [Object.keys(pendingActions).length > 0, pendingActions];
}, [pendingActions]);
}, [agentStatus?.pendingActions]);
return (
<EuiFlexGroupStyled
@ -83,7 +96,7 @@ export const AgentStatus = React.memo(
{getAgentStatusText(agentStatus.status)}
</EuiBadge>
) : (
'-'
getEmptyTagValue()
)}
</EuiFlexItem>
{(isCurrentlyIsolated || hasPendingActions) && (

View file

@ -1,366 +0,0 @@
/*
* 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 type { AppContextTestRender } from '../../../../../mock/endpoint';
import { createAppRootMockRenderer } from '../../../../../mock/endpoint';
import type {
EndpointAgentStatusByIdProps,
EndpointAgentStatusProps,
} from './endpoint_agent_status';
import { EndpointAgentStatus, EndpointAgentStatusById } from './endpoint_agent_status';
import type {
EndpointPendingActions,
HostInfoInterface,
} from '../../../../../../../common/endpoint/types';
import { HostStatus } from '../../../../../../../common/endpoint/types';
import React from 'react';
import { EndpointActionGenerator } from '../../../../../../../common/endpoint/data_generators/endpoint_action_generator';
import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data';
import { composeHttpHandlerMocks } from '../../../../../mock/endpoint/http_handler_mock_factory';
import type { EndpointMetadataHttpMocksInterface } from '../../../../../../management/pages/endpoint_hosts/mocks';
import { endpointMetadataHttpMocks } from '../../../../../../management/pages/endpoint_hosts/mocks';
import type { ResponseActionsHttpMocksInterface } from '../../../../../../management/mocks/response_actions_http_mocks';
import { responseActionsHttpMocks } from '../../../../../../management/mocks/response_actions_http_mocks';
import { waitFor, within, fireEvent } from '@testing-library/react';
import { getEmptyValue } from '../../../../empty_value';
import { clone, set } from 'lodash';
type AgentStatusApiMocksInterface = EndpointMetadataHttpMocksInterface &
ResponseActionsHttpMocksInterface;
// API mocks composed from the endpoint metadata API mock and the response actions API mocks
const agentStatusApiMocks = composeHttpHandlerMocks<AgentStatusApiMocksInterface>([
endpointMetadataHttpMocks,
responseActionsHttpMocks,
]);
describe('When showing Endpoint Agent Status', () => {
const ENDPOINT_ISOLATION_OBJ_PATH = 'metadata.Endpoint.state.isolation';
let appTestContext: AppContextTestRender;
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<AppContextTestRender['render']>;
let endpointDetails: HostInfoInterface;
let actionsSummary: EndpointPendingActions;
let apiMocks: ReturnType<typeof agentStatusApiMocks>;
const triggerTooltip = () => {
fireEvent.mouseOver(renderResult.getByTestId('test-actionStatuses-tooltipTrigger'));
};
beforeEach(() => {
appTestContext = createAppRootMockRenderer();
apiMocks = agentStatusApiMocks(appTestContext.coreStart.http);
const actionGenerator = new EndpointActionGenerator('seed');
actionsSummary = actionGenerator.generateAgentPendingActionsSummary();
actionsSummary.pending_actions = {};
apiMocks.responseProvider.agentPendingActionsSummary.mockImplementation(() => {
return {
data: [actionsSummary],
};
});
const metadataGenerator = new EndpointDocGenerator('seed');
endpointDetails = {
metadata: metadataGenerator.generateHostMetadata(),
host_status: HostStatus.HEALTHY,
} as HostInfoInterface;
apiMocks.responseProvider.metadataDetails.mockImplementation(() => endpointDetails);
});
describe('and using `EndpointAgentStatus` component', () => {
let renderProps: EndpointAgentStatusProps;
beforeEach(() => {
renderProps = {
'data-test-subj': 'test',
endpointHostInfo: endpointDetails,
};
render = () => {
renderResult = appTestContext.render(<EndpointAgentStatus {...renderProps} />);
return renderResult;
};
});
it('should display status', () => {
const { getByTestId } = render();
expect(getByTestId('test').textContent).toEqual('Healthy');
});
it('should display status and isolated', () => {
set(endpointDetails, ENDPOINT_ISOLATION_OBJ_PATH, true);
const { getByTestId } = render();
expect(getByTestId('test').textContent).toEqual('HealthyIsolated');
});
it('should display status and isolated and display other pending actions in tooltip', async () => {
set(endpointDetails, ENDPOINT_ISOLATION_OBJ_PATH, true);
actionsSummary.pending_actions = {
'get-file': 2,
execute: 6,
};
const { getByTestId } = render();
await waitFor(() => {
expect(apiMocks.responseProvider.agentPendingActionsSummary).toHaveBeenCalled();
});
expect(getByTestId('test').textContent).toEqual('HealthyIsolated');
triggerTooltip();
await waitFor(() => {
expect(
within(renderResult.baseElement).getByTestId('test-actionStatuses-tooltipContent')
.textContent
).toEqual('Pending actions:execute6get-file2');
});
});
it('should display status and action count', async () => {
actionsSummary.pending_actions = {
'get-file': 2,
execute: 6,
};
const { getByTestId } = render();
await waitFor(() => {
expect(apiMocks.responseProvider.agentPendingActionsSummary).toHaveBeenCalled();
});
expect(getByTestId('test').textContent).toEqual('Healthy8 actions pending');
});
it('should display status and isolating', async () => {
actionsSummary.pending_actions = {
isolate: 1,
};
const { getByTestId } = render();
await waitFor(() => {
expect(apiMocks.responseProvider.agentPendingActionsSummary).toHaveBeenCalled();
});
expect(getByTestId('test').textContent).toEqual('HealthyIsolating');
});
it('should display status and isolating and have tooltip with other pending actions', async () => {
actionsSummary.pending_actions = {
isolate: 1,
'kill-process': 1,
};
const { getByTestId } = render();
await waitFor(() => {
expect(apiMocks.responseProvider.agentPendingActionsSummary).toHaveBeenCalled();
});
expect(getByTestId('test').textContent).toEqual('HealthyIsolating');
triggerTooltip();
await waitFor(() => {
expect(
within(renderResult.baseElement).getByTestId('test-actionStatuses-tooltipContent')
.textContent
).toEqual('Pending actions:isolate1kill-process1');
});
});
it('should display status and releasing', async () => {
actionsSummary.pending_actions = {
unisolate: 1,
};
set(endpointDetails, ENDPOINT_ISOLATION_OBJ_PATH, true);
const { getByTestId } = render();
await waitFor(() => {
expect(apiMocks.responseProvider.agentPendingActionsSummary).toHaveBeenCalled();
});
expect(getByTestId('test').textContent).toEqual('HealthyReleasing');
});
it('should display status and releasing and show other pending actions in tooltip', async () => {
actionsSummary.pending_actions = {
unisolate: 1,
'kill-process': 1,
};
set(endpointDetails, ENDPOINT_ISOLATION_OBJ_PATH, true);
const { getByTestId } = render();
await waitFor(() => {
expect(apiMocks.responseProvider.agentPendingActionsSummary).toHaveBeenCalled();
});
expect(getByTestId('test').textContent).toEqual('HealthyReleasing');
triggerTooltip();
await waitFor(() => {
expect(
within(renderResult.baseElement).getByTestId('test-actionStatuses-tooltipContent')
.textContent
).toEqual('Pending actions:kill-process1release1');
});
});
it('should show individual action count in tooltip (including unknown actions) sorted asc', async () => {
actionsSummary.pending_actions = {
isolate: 1,
'get-file': 2,
execute: 6,
'kill-process': 1,
foo: 2,
};
const { getByTestId } = render();
await waitFor(() => {
expect(apiMocks.responseProvider.agentPendingActionsSummary).toHaveBeenCalled();
});
expect(getByTestId('test').textContent).toEqual('HealthyIsolating');
triggerTooltip();
await waitFor(() => {
expect(
within(renderResult.baseElement).getByTestId('test-actionStatuses-tooltipContent')
.textContent
).toEqual('Pending actions:execute6foo2get-file2isolate1kill-process1');
});
});
it('should still display status and isolation state if action summary api fails', async () => {
set(endpointDetails, ENDPOINT_ISOLATION_OBJ_PATH, true);
apiMocks.responseProvider.agentPendingActionsSummary.mockImplementation(() => {
throw new Error('test error');
});
const { getByTestId } = render();
await waitFor(() => {
expect(apiMocks.responseProvider.agentPendingActionsSummary).toHaveBeenCalled();
});
expect(getByTestId('test').textContent).toEqual('HealthyIsolated');
});
describe('and `autoRefresh` prop is set to true', () => {
beforeEach(() => {
renderProps.autoRefresh = true;
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should keep actions up to date when autoRefresh is true', async () => {
apiMocks.responseProvider.agentPendingActionsSummary.mockReturnValueOnce({
data: [actionsSummary],
});
const { getByTestId } = render();
await waitFor(() => {
expect(apiMocks.responseProvider.agentPendingActionsSummary).toHaveBeenCalled();
});
expect(getByTestId('test').textContent).toEqual('Healthy');
apiMocks.responseProvider.agentPendingActionsSummary.mockReturnValueOnce({
data: [
{
...actionsSummary,
pending_actions: {
'kill-process': 2,
'running-processes': 2,
},
},
],
});
jest.runOnlyPendingTimers();
await waitFor(() => {
expect(getByTestId('test').textContent).toEqual('Healthy4 actions pending');
});
});
});
});
describe('And when using EndpointAgentStatusById', () => {
let renderProps: EndpointAgentStatusByIdProps;
beforeEach(() => {
jest.useFakeTimers();
renderProps = {
'data-test-subj': 'test',
endpointAgentId: '123',
};
render = () => {
renderResult = appTestContext.render(<EndpointAgentStatusById {...renderProps} />);
return renderResult;
};
});
afterEach(() => {
jest.useRealTimers();
});
it('should display status and isolated', async () => {
set(endpointDetails, ENDPOINT_ISOLATION_OBJ_PATH, true);
const { getByTestId } = render();
await waitFor(() => {
expect(getByTestId('test').textContent).toEqual('HealthyIsolated');
});
});
it('should display empty value if API call to host metadata fails', async () => {
apiMocks.responseProvider.metadataDetails.mockImplementation(() => {
throw new Error('test error');
});
const { getByTestId } = render();
await waitFor(() => {
expect(apiMocks.responseProvider.metadataDetails).toHaveBeenCalled();
});
expect(getByTestId('test').textContent).toEqual(getEmptyValue());
});
it('should keep agent status up to date when autoRefresh is true', async () => {
renderProps.autoRefresh = true;
apiMocks.responseProvider.metadataDetails.mockReturnValueOnce(endpointDetails);
const { getByTestId } = render();
await waitFor(() => {
expect(getByTestId('test').textContent).toEqual('Healthy');
});
apiMocks.responseProvider.metadataDetails.mockReturnValueOnce(
set(clone(endpointDetails), 'metadata.Endpoint.state.isolation', true)
);
jest.runOnlyPendingTimers();
await waitFor(() => {
expect(getByTestId('test').textContent).toEqual('HealthyIsolated');
});
});
});
});

View file

@ -1,168 +0,0 @@
/*
* 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 { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { DEFAULT_POLL_INTERVAL } from '../../../../../../management/common/constants';
import { HOST_STATUS_TO_BADGE_COLOR } from '../../../../../../management/pages/endpoint_hosts/view/host_constants';
import { getEmptyValue } from '../../../../empty_value';
import { useGetEndpointPendingActionsSummary } from '../../../../../../management/hooks/response_actions/use_get_endpoint_pending_actions_summary';
import { useTestIdGenerator } from '../../../../../../management/hooks/use_test_id_generator';
import type { EndpointPendingActions, HostInfo } from '../../../../../../../common/endpoint/types';
import { useGetEndpointDetails } from '../../../../../../management/hooks';
import { getAgentStatusText } from '../../agent_status_text';
import { AgentResponseActionsStatus } from '../agent_response_action_status';
export const ISOLATING_LABEL = i18n.translate(
'xpack.securitySolution.endpoint.agentAndActionsStatus.isIsolating',
{ defaultMessage: 'Isolating' }
);
export const RELEASING_LABEL = i18n.translate(
'xpack.securitySolution.endpoint.agentAndActionsStatus.isUnIsolating',
{ defaultMessage: 'Releasing' }
);
export const ISOLATED_LABEL = i18n.translate(
'xpack.securitySolution.endpoint.agentAndActionsStatus.isolated',
{ defaultMessage: 'Isolated' }
);
const EuiFlexGroupStyled = styled(EuiFlexGroup)`
.isolation-status {
margin-left: ${({ theme }) => theme.eui.euiSizeS};
}
`;
export interface EndpointAgentStatusProps {
endpointHostInfo: HostInfo;
/**
* If set to `true` (Default), then the endpoint isolation state and response actions count
* will be kept up to date by querying the API periodically.
* Only used if `pendingActions` is not defined.
*/
autoRefresh?: boolean;
/**
* The pending actions for the host (as return by the pending actions summary api).
* If undefined, then this component will call the API to retrieve that list of pending actions.
* NOTE: if this prop is defined, it will invalidate `autoRefresh` prop.
*/
pendingActions?: EndpointPendingActions['pending_actions'];
'data-test-subj'?: string;
}
/**
* Displays the status of an Endpoint agent along with its Isolation state or the number of pending
* response actions against it.
*
* TIP: if you only have the Endpoint's `agent.id`, then consider using `EndpointAgentStatusById`,
* which will call the needed APIs to get the information necessary to display the status.
*/
// TODO: used by `EndpointAgentStatusById`
// remove usage/code when `agentStatusClientEnabled` FF is enabled and removed
export const EndpointAgentStatus = memo<EndpointAgentStatusProps>(
({ endpointHostInfo, autoRefresh = true, pendingActions, 'data-test-subj': dataTestSubj }) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const { data: endpointPendingActions } = useGetEndpointPendingActionsSummary(
[endpointHostInfo.metadata.agent.id],
{
refetchInterval: autoRefresh ? DEFAULT_POLL_INTERVAL : false,
enabled: !pendingActions,
}
);
const [hasPendingActions, hostPendingActions] = useMemo<
[boolean, EndpointPendingActions['pending_actions']]
>(() => {
if (!endpointPendingActions && !pendingActions) {
return [false, {}];
}
const pending = pendingActions
? pendingActions
: endpointPendingActions?.data[0].pending_actions ?? {};
return [Object.keys(pending).length > 0, pending];
}, [endpointPendingActions, pendingActions]);
const status = endpointHostInfo.host_status;
const isIsolated = Boolean(endpointHostInfo.metadata.Endpoint.state?.isolation);
return (
<EuiFlexGroupStyled
gutterSize="none"
responsive={false}
className="eui-textTruncate"
data-test-subj={dataTestSubj}
>
<EuiFlexItem grow={false}>
<EuiBadge
color={status != null ? HOST_STATUS_TO_BADGE_COLOR[status] : 'warning'}
data-test-subj={getTestId('agentStatus')}
className="eui-textTruncate"
>
{getAgentStatusText(status)}
</EuiBadge>
</EuiFlexItem>
{(isIsolated || hasPendingActions) && (
<EuiFlexItem grow={false} className="eui-textTruncate isolation-status">
<AgentResponseActionsStatus
data-test-subj={getTestId('actionStatuses')}
isIsolated={isIsolated}
pendingActions={hostPendingActions}
/>
</EuiFlexItem>
)}
</EuiFlexGroupStyled>
);
}
);
EndpointAgentStatus.displayName = 'EndpointAgentStatus';
export interface EndpointAgentStatusByIdProps {
endpointAgentId: string;
/**
* If set to `true` (Default), then the endpoint status and isolation/action counts will
* be kept up to date by querying the API periodically
*/
autoRefresh?: boolean;
'data-test-subj'?: string;
}
/**
* Given an Endpoint Agent Id, it will make the necessary API calls and then display the agent
* status using the `<EndpointAgentStatus />` component.
*
* NOTE: if the `HostInfo` is already available, consider using `<EndpointAgentStatus/>` component
* instead in order to avoid duplicate API calls.
*/
export const EndpointAgentStatusById = memo<EndpointAgentStatusByIdProps>(
({ endpointAgentId, autoRefresh, 'data-test-subj': dataTestSubj }) => {
const { data } = useGetEndpointDetails(endpointAgentId, {
refetchInterval: autoRefresh ? DEFAULT_POLL_INTERVAL : false,
});
if (!data) {
return (
<EuiText size="xs" data-test-subj={dataTestSubj}>
<p>{getEmptyValue()}</p>
</EuiText>
);
}
return (
<EndpointAgentStatus
endpointHostInfo={data}
data-test-subj={dataTestSubj}
autoRefresh={autoRefresh}
/>
);
}
);
EndpointAgentStatusById.displayName = 'EndpointAgentStatusById';

View file

@ -5,6 +5,4 @@
* 2.0.
*/
export * from './endpoint/endpoint_agent_status';
export type { EndpointAgentStatusProps } from './endpoint/endpoint_agent_status';
export * from './agent_status';

View file

@ -6,7 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import type { HostStatus } from '../../../../../common/endpoint/types';
import type { HostStatus } from '../../../../../../common/endpoint/types';
export const getAgentStatusText = (hostStatus: HostStatus) => {
return i18n.translate('xpack.securitySolution.endpoint.list.hostStatusValue', {
@ -15,3 +15,15 @@ export const getAgentStatusText = (hostStatus: HostStatus) => {
values: { hostStatus },
});
};
export const ISOLATING_LABEL = i18n.translate(
'xpack.securitySolution.endpoint.agentAndActionsStatus.isIsolating',
{ defaultMessage: 'Isolating' }
);
export const RELEASING_LABEL = i18n.translate(
'xpack.securitySolution.endpoint.agentAndActionsStatus.isUnIsolating',
{ defaultMessage: 'Releasing' }
);
export const ISOLATED_LABEL = i18n.translate(
'xpack.securitySolution.endpoint.agentAndActionsStatus.isolated',
{ defaultMessage: 'Isolated' }
);

View file

@ -5,140 +5,188 @@
* 2.0.
*/
import type { FC, PropsWithChildren } from 'react';
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import type { UseHostIsolationActionProps } from './use_host_isolation_action';
import { useHostIsolationAction } from './use_host_isolation_action';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { AppContextTestRender, UserPrivilegesMockSetter } from '../../../../mock/endpoint';
import { createAppRootMockRenderer, endpointAlertDataMock } from '../../../../mock/endpoint';
import { agentStatusGetHttpMock } from '../../../../../management/mocks';
import { useUserPrivileges as _useUserPrivileges } from '../../../user_privileges';
import type { AlertTableContextMenuItem } from '../../../../../detections/components/alerts_table/types';
import type { ResponseActionsApiCommandNames } from '../../../../../../common/endpoint/service/response_actions/constants';
import { agentStatusMocks } from '../../../../../../common/endpoint/service/response_actions/mocks/agent_status.mocks';
import { ISOLATE_HOST, UNISOLATE_HOST } from './translations';
import type React from 'react';
import {
useAgentStatusHook,
useGetAgentStatus,
useGetSentinelOneAgentStatus,
} from '../../../../../management/hooks/agents/use_get_agent_status';
import { useIsExperimentalFeatureEnabled } from '../../../../hooks/use_experimental_features';
import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants';
import { ExperimentalFeaturesService as ExperimentalFeaturesServiceMock } from '../../../../experimental_features_service';
import { endpointAlertDataMock } from '../../../../mock/endpoint';
HOST_ENDPOINT_UNENROLLED_TOOLTIP,
LOADING_ENDPOINT_DATA_TOOLTIP,
NOT_FROM_ENDPOINT_HOST_TOOLTIP,
} from '../..';
import { HostStatus } from '../../../../../../common/endpoint/types';
jest.mock('../../../../../management/hooks/agents/use_get_agent_status');
jest.mock('../../../../hooks/use_experimental_features');
jest.mock('../../../../experimental_features_service');
jest.mock('../../../user_privileges');
const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
const useGetSentinelOneAgentStatusMock = useGetSentinelOneAgentStatus as jest.Mock;
const useGetAgentStatusMock = useGetAgentStatus as jest.Mock;
const useAgentStatusHookMock = useAgentStatusHook as jest.Mock;
const useUserPrivilegesMock = _useUserPrivileges as jest.Mock;
describe('useHostIsolationAction', () => {
const setFeatureFlags = (isEnabled: boolean = true): void => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(isEnabled);
(ExperimentalFeaturesServiceMock.get as jest.Mock).mockReturnValue({
responseActionsSentinelOneV1Enabled: isEnabled,
responseActionsCrowdstrikeManualHostIsolationEnabled: isEnabled,
});
let appContextMock: AppContextTestRender;
let hookProps: UseHostIsolationActionProps;
let apiMock: ReturnType<typeof agentStatusGetHttpMock>;
let authMockSetter: UserPrivilegesMockSetter;
const buildExpectedMenuItemResult = (
overrides: Partial<AlertTableContextMenuItem> = {}
): AlertTableContextMenuItem => {
return {
'data-test-subj': 'isolate-host-action-item',
disabled: false,
key: 'isolate-host-action-item',
name: ISOLATE_HOST,
onClick: expect.any(Function),
...overrides,
};
};
const createReactQueryWrapper = () => {
const queryClient = new QueryClient();
const wrapper: FC<PropsWithChildren<unknown>> = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
const render = () => {
return appContextMock.renderHook(() => useHostIsolationAction(hookProps));
};
beforeEach(() => {
appContextMock = createAppRootMockRenderer();
authMockSetter = appContextMock.getUserPrivilegesMockSetter(useUserPrivilegesMock);
hookProps = {
closePopover: jest.fn(),
detailsData: endpointAlertDataMock.generateEndpointAlertDetailsItemData(),
isHostIsolationPanelOpen: false,
onAddIsolationStatusClick: jest.fn(),
};
apiMock = agentStatusGetHttpMock(appContextMock.coreStart.http);
appContextMock.setExperimentalFlag({
responseActionsSentinelOneV1Enabled: true,
responseActionsCrowdstrikeManualHostIsolationEnabled: true,
});
authMockSetter.set({
canIsolateHost: true,
canUnIsolateHost: true,
});
});
afterEach(() => {
authMockSetter.reset();
});
it.each<ResponseActionsApiCommandNames>(['isolate', 'unisolate'])(
'should return menu item for displaying %s',
async (command) => {
if (command === 'unisolate') {
apiMock.responseProvider.getAgentStatus.mockReturnValue({
data: {
'abfe4a35-d5b4-42a0-a539-bd054c791769': agentStatusMocks.generateAgentStatus({
isolated: true,
}),
},
});
}
const { result, waitForValueToChange } = render();
await waitForValueToChange(() => result.current);
expect(result.current).toEqual([
buildExpectedMenuItemResult({
...(command === 'unisolate' ? { name: UNISOLATE_HOST } : {}),
}),
]);
}
);
it('should call `closePopover` callback when menu item `onClick` is called', async () => {
const { result, waitForValueToChange } = render();
await waitForValueToChange(() => result.current);
result.current[0].onClick!({} as unknown as React.MouseEvent);
expect(hookProps.closePopover).toHaveBeenCalled();
});
it('should NOT return the menu item for Events', () => {
useAgentStatusHookMock.mockImplementation(() => {
return jest.fn(() => {
return { data: {} };
});
hookProps.detailsData = endpointAlertDataMock.generateAlertDetailsItemDataForAgentType('foo', {
'kibana.alert.rule.uuid': undefined,
});
setFeatureFlags(true);
const { result } = renderHook(
() => {
return useHostIsolationAction({
closePopover: jest.fn(),
detailsData: endpointAlertDataMock.generateAlertDetailsItemDataForAgentType('foo', {
'kibana.alert.rule.uuid': undefined,
}),
isHostIsolationPanelOpen: false,
onAddIsolationStatusClick: jest.fn(),
});
},
{ wrapper: createReactQueryWrapper() }
);
const { result } = render();
expect(result.current).toHaveLength(0);
});
// FIXME:PT refactor describe below - its not actually testing the component! Tests seem to be for `useAgentStatusHook()`
describe.each([
['useGetSentinelOneAgentStatus', useGetSentinelOneAgentStatusMock],
['useGetAgentStatus', useGetAgentStatusMock],
])('works with %s hook', (name, hook) => {
const render = (agentTypeAlert: ResponseActionAgentType) =>
renderHook(
() =>
useHostIsolationAction({
closePopover: jest.fn(),
detailsData:
endpointAlertDataMock.generateAlertDetailsItemDataForAgentType(agentTypeAlert),
isHostIsolationPanelOpen: false,
onAddIsolationStatusClick: jest.fn(),
it('should NOT return menu item if user does not have authz', async () => {
authMockSetter.set({
canIsolateHost: false,
canUnIsolateHost: false,
});
const { result } = render();
expect(result.current).toHaveLength(0);
});
it('should NOT attempt to get Agent status if host does not support response actions', async () => {
hookProps.detailsData = [];
render();
expect(apiMock.responseProvider.getAgentStatus).not.toHaveBeenCalled();
});
it('should return disabled menu item while loading agent status', async () => {
const { result } = render();
expect(result.current).toEqual([
buildExpectedMenuItemResult({
disabled: true,
toolTipContent: LOADING_ENDPOINT_DATA_TOOLTIP,
}),
]);
});
it.each(['endpoint', 'non-endpoint'])(
'should return disabled menu item if %s host agent is unenrolled',
async (type) => {
apiMock.responseProvider.getAgentStatus.mockReturnValue({
data: {
'abfe4a35-d5b4-42a0-a539-bd054c791769': agentStatusMocks.generateAgentStatus({
status: HostStatus.UNENROLLED,
}),
{
wrapper: createReactQueryWrapper(),
}
);
beforeEach(() => {
useAgentStatusHookMock.mockImplementation(() => hook);
setFeatureFlags(true);
});
afterEach(() => {
jest.clearAllMocks();
(ExperimentalFeaturesServiceMock.get as jest.Mock).mockReset();
});
it(`${name} is invoked as 'enabled' when SentinelOne alert and FF enabled`, () => {
render('sentinel_one');
expect(hook).toHaveBeenCalledWith(['abfe4a35-d5b4-42a0-a539-bd054c791769'], 'sentinel_one', {
enabled: true,
},
});
});
it(`${name} is invoked as 'enabled' when Crowdstrike alert and FF enabled`, () => {
render('crowdstrike');
if (type === 'non-endpoint') {
hookProps.detailsData = endpointAlertDataMock.generateSentinelOneAlertDetailsItemData();
}
const { result, waitForValueToChange } = render();
await waitForValueToChange(() => result.current);
expect(hook).toHaveBeenCalledWith(['abfe4a35-d5b4-42a0-a539-bd054c791769'], 'crowdstrike', {
enabled: true,
});
});
expect(result.current).toEqual([
buildExpectedMenuItemResult({
disabled: true,
toolTipContent:
type === 'endpoint' ? HOST_ENDPOINT_UNENROLLED_TOOLTIP : NOT_FROM_ENDPOINT_HOST_TOOLTIP,
}),
]);
}
);
it(`${name} is invoked as 'disabled' when SentinelOne alert and FF disabled`, () => {
setFeatureFlags(false);
render('sentinel_one');
it('should call isolate API when agent is currently NOT isolated', async () => {
const { result, waitForValueToChange } = render();
await waitForValueToChange(() => result.current);
result.current[0].onClick!({} as unknown as React.MouseEvent);
expect(hook).toHaveBeenCalledWith(['abfe4a35-d5b4-42a0-a539-bd054c791769'], 'sentinel_one', {
enabled: false,
});
});
expect(hookProps.onAddIsolationStatusClick).toHaveBeenCalledWith('isolateHost');
});
it(`${name} is invoked as 'disabled' when Crowdstrike alert and FF disabled`, () => {
setFeatureFlags(false);
render('crowdstrike');
it('should call un-isolate API when agent is currently isolated', async () => {
apiMock.responseProvider.getAgentStatus.mockReturnValue(
agentStatusMocks.generateAgentStatusApiResponse({
data: { 'abfe4a35-d5b4-42a0-a539-bd054c791769': { isolated: true } },
})
);
const { result, waitForValueToChange } = render();
await waitForValueToChange(() => result.current);
result.current[0].onClick!({} as unknown as React.MouseEvent);
expect(hook).toHaveBeenCalledWith(['abfe4a35-d5b4-42a0-a539-bd054c791769'], 'crowdstrike', {
enabled: false,
});
});
it(`${name} is invoked as 'disabled' when endpoint alert`, () => {
render('endpoint');
expect(hook).toHaveBeenCalledWith(['abfe4a35-d5b4-42a0-a539-bd054c791769'], 'endpoint', {
enabled: false,
});
});
expect(hookProps.onAddIsolationStatusClick).toHaveBeenCalledWith('unisolateHost');
});
});

View file

@ -12,16 +12,13 @@ import {
NOT_FROM_ENDPOINT_HOST_TOOLTIP,
} from '../../responder';
import { useAlertResponseActionsSupport } from '../../../../hooks/endpoint/use_alert_response_actions_support';
import { useIsExperimentalFeatureEnabled } from '../../../../hooks/use_experimental_features';
import type { AgentStatusInfo } from '../../../../../../common/endpoint/types';
import { HostStatus } from '../../../../../../common/endpoint/types';
import { useEndpointHostIsolationStatus } from './use_host_isolation_status';
import { ISOLATE_HOST, UNISOLATE_HOST } from './translations';
import { useUserPrivileges } from '../../../user_privileges';
import type { AlertTableContextMenuItem } from '../../../../../detections/components/alerts_table/types';
import { useAgentStatusHook } from '../../../../../management/hooks/agents/use_get_agent_status';
import { useGetAgentStatus } from '../../../../../management/hooks/agents/use_get_agent_status';
interface UseHostIsolationActionProps {
export interface UseHostIsolationActionProps {
closePopover: () => void;
detailsData: TimelineEventsDetailsItem[] | null;
isHostIsolationPanelOpen: boolean;
@ -44,87 +41,39 @@ export const useHostIsolationAction = ({
agentSupport: { isolate: isolationSupported },
},
} = useAlertResponseActionsSupport(detailsData);
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
const useAgentStatus = useAgentStatusHook();
const { canIsolateHost, canUnIsolateHost } = useUserPrivileges().endpointPrivileges;
const isEndpointAgent = useMemo(() => {
return agentType === 'endpoint';
}, [agentType]);
const {
loading: loadingHostIsolationStatus,
isIsolated,
agentStatus,
capabilities,
} = useEndpointHostIsolationStatus({
agentId,
agentType,
const { data, isLoading, isFetched } = useGetAgentStatus(agentId, agentType, {
enabled: hostSupportsResponseActions,
});
const { data: externalAgentData } = useAgentStatus([agentId], agentType, {
enabled: hostSupportsResponseActions && !isEndpointAgent,
});
const externalAgentStatus = externalAgentData?.[agentId];
const isHostIsolated = useMemo((): boolean => {
if (!isEndpointAgent) {
return Boolean(externalAgentStatus?.isolated);
}
return isIsolated;
}, [isEndpointAgent, isIsolated, externalAgentStatus?.isolated]);
const agentStatus = data?.[agentId];
const doesHostSupportIsolation = useMemo(() => {
// With Elastic Defend Endpoint, we check that the actual `endpoint` agent on
// this host reported that capability
if (agentType === 'endpoint') {
return capabilities.includes('isolation');
}
return hostSupportsResponseActions && isolationSupported;
}, [hostSupportsResponseActions, isolationSupported]);
return Boolean(externalAgentStatus?.found && isolationSupported);
}, [agentType, externalAgentStatus?.found, isolationSupported, capabilities]);
const isHostIsolated = useMemo(() => {
return Boolean(agentStatus?.isolated);
}, [agentStatus?.isolated]);
const isolateHostHandler = useCallback(() => {
closePopover();
if (!isHostIsolated) {
onAddIsolationStatusClick('isolateHost');
} else {
onAddIsolationStatusClick('unisolateHost');
if (doesHostSupportIsolation) {
if (!isHostIsolated) {
onAddIsolationStatusClick('isolateHost');
} else {
onAddIsolationStatusClick('unisolateHost');
}
}
}, [closePopover, isHostIsolated, onAddIsolationStatusClick]);
}, [closePopover, doesHostSupportIsolation, isHostIsolated, onAddIsolationStatusClick]);
const isHostAgentUnEnrolled = useMemo<boolean>(() => {
if (!hostSupportsResponseActions) {
return true;
}
if (isEndpointAgent) {
return agentStatus === HostStatus.UNENROLLED;
}
// NON-Endpoint agent types
// 8.15 use FF for computing if action is enabled
if (agentStatusClientEnabled) {
return externalAgentStatus?.status === HostStatus.UNENROLLED;
}
// else use the old way
if (!externalAgentStatus) {
return true;
}
const { isUninstalled, isPendingUninstall } = externalAgentStatus as AgentStatusInfo[string];
return isUninstalled || isPendingUninstall;
}, [
hostSupportsResponseActions,
isEndpointAgent,
agentStatusClientEnabled,
externalAgentStatus,
agentStatus,
]);
return (
!hostSupportsResponseActions ||
!agentStatus?.found ||
agentStatus.status === HostStatus.UNENROLLED
);
}, [hostSupportsResponseActions, agentStatus]);
return useMemo<AlertTableContextMenuItem[]>(() => {
// If not an Alert OR user has no Authz, then don't show the menu item at all
@ -147,14 +96,15 @@ export const useHostIsolationAction = ({
// support response actions, then show that as the tooltip. Else, just show the normal "enroll" message
menuItem.toolTipContent =
agentType && unsupportedReason ? unsupportedReason : NOT_FROM_ENDPOINT_HOST_TOOLTIP;
} else if (isEndpointAgent && loadingHostIsolationStatus) {
} else if (isLoading || !isFetched) {
menuItem.disabled = true;
menuItem.toolTipContent = LOADING_ENDPOINT_DATA_TOOLTIP;
} else if (isHostAgentUnEnrolled) {
menuItem.disabled = true;
menuItem.toolTipContent = isEndpointAgent
? HOST_ENDPOINT_UNENROLLED_TOOLTIP
: NOT_FROM_ENDPOINT_HOST_TOOLTIP;
menuItem.toolTipContent =
agentType === 'endpoint'
? HOST_ENDPOINT_UNENROLLED_TOOLTIP
: NOT_FROM_ENDPOINT_HOST_TOOLTIP;
}
return [menuItem];
@ -167,8 +117,8 @@ export const useHostIsolationAction = ({
isHostIsolationPanelOpen,
isolateHostHandler,
doesHostSupportIsolation,
isEndpointAgent,
loadingHostIsolationStatus,
isLoading,
isFetched,
agentType,
unsupportedReason,
]);

View file

@ -107,25 +107,6 @@ describe('use responder action data hooks', () => {
expect(onClickMock).not.toHaveBeenCalled();
});
});
describe('and agentType is NOT Endpoint', () => {
beforeEach(() => {
alertDetailItemData = endpointAlertDataMock.generateSentinelOneAlertDetailsItemData();
});
it('should show action when agentType is supported', () => {
expect(renderHook().result.current).toEqual(getExpectedResponderActionData());
});
it('should NOT call the endpoint host metadata api', () => {
renderHook();
const wasMetadataApiCalled = appContextMock.coreStart.http.get.mock.calls.some(([path]) => {
return (path as unknown as string).includes(HOST_METADATA_LIST_ROUTE);
});
expect(wasMetadataApiCalled).toBe(false);
});
it.each([...RESPONSE_ACTION_AGENT_TYPE])(
'should show action disabled with tooltip for %s if agent id field is missing',
@ -150,6 +131,25 @@ describe('use responder action data hooks', () => {
);
});
describe('and agentType is NOT Endpoint', () => {
beforeEach(() => {
alertDetailItemData = endpointAlertDataMock.generateSentinelOneAlertDetailsItemData();
});
it('should show action when agentType is supported', () => {
expect(renderHook().result.current).toEqual(getExpectedResponderActionData());
});
it('should NOT call the endpoint host metadata api', () => {
renderHook();
const wasMetadataApiCalled = appContextMock.coreStart.http.get.mock.calls.some(([path]) => {
return (path as unknown as string).includes(HOST_METADATA_LIST_ROUTE);
});
expect(wasMetadataApiCalled).toBe(false);
});
});
describe('and agentType IS Endpoint', () => {
let metadataApiMocks: ReturnType<typeof endpointMetadataHttpMocks>;

View file

@ -133,8 +133,6 @@ const useResponderDataForEndpointHost = (
endpointAgentId: string,
enabled: boolean = true
): ResponderDataForEndpointHost => {
// FIXME:PT is this the correct API to call? or should we call the agent status api instead
const {
data: endpointHostInfo,
isFetching,

View file

@ -21,6 +21,8 @@ import { ACTION_STATUS_ROUTE } from '../../../../../common/endpoint/constants';
export const fetchPendingActionsByAgentId = (
agentIds: PendingActionsRequestQuery['agent_ids']
): Promise<PendingActionsResponse> => {
// FIXME:PT Delete method now that we are using new internal API (team issue: 9783)
return KibanaServices.get().http.get<PendingActionsResponse>(ACTION_STATUS_ROUTE, {
version: '2023-10-31',
query: {

View file

@ -32,7 +32,7 @@ export const PanelHeader: FC = () => {
const title = (
<EuiFlexGroup responsive gutterSize="s">
<EuiFlexItem grow={false} data-test-subj="flyoutHostIsolationHeaderTitle">
{isolateAction === 'isolateHost' ? <>{ISOLATE_HOST}</> : <>{UNISOLATE_HOST}</>}
{isolateAction === 'isolateHost' ? ISOLATE_HOST : UNISOLATE_HOST}
</EuiFlexItem>
{showTechPreviewBadge && (
<EuiFlexItem grow={false}>

View file

@ -18,14 +18,8 @@ import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys';
import { LeftPanelInsightsTab } from '../../left';
import { TestProviders } from '../../../../common/mock';
import { ENTITIES_TAB_ID } from '../../left/components/entities_details';
import { useGetEndpointDetails } from '../../../../management/hooks';
import {
useAgentStatusHook,
useGetAgentStatus,
useGetSentinelOneAgentStatus,
} from '../../../../management/hooks/agents/use_get_agent_status';
import { useGetAgentStatus } from '../../../../management/hooks/agents/use_get_agent_status';
import { type ExpandableFlyoutApi, useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD } from '../../../../../common/endpoint/service/response_actions/constants';
jest.mock('../../../../management/hooks');
jest.mock('../../../../management/hooks/agents/use_get_agent_status');
@ -35,13 +29,7 @@ jest.mock('@kbn/expandable-flyout', () => ({
ExpandableFlyoutProvider: ({ children }: React.PropsWithChildren<{}>) => <>{children}</>,
}));
const useGetSentinelOneAgentStatusMock = useGetSentinelOneAgentStatus as jest.Mock;
const useGetAgentStatusMock = useGetAgentStatus as jest.Mock;
const useAgentStatusHookMock = useAgentStatusHook as jest.Mock;
const hooksToMock: Record<string, jest.Mock> = {
useGetSentinelOneAgentStatus: useGetSentinelOneAgentStatusMock,
useGetAgentStatus: useGetAgentStatusMock,
};
const flyoutContextValue = {
openLeftPanel: jest.fn(),
@ -105,7 +93,10 @@ describe('<HighlightedFieldsCell />', () => {
});
it('should render agent status cell if field is `agent.status`', () => {
(useGetEndpointDetails as jest.Mock).mockReturnValue({});
useGetAgentStatusMock.mockReturnValue({
isFetched: true,
isLoading: false,
});
const { getByTestId } = render(
<TestProviders>
<HighlightedFieldsCell values={['value']} field={'agent.status'} />
@ -115,55 +106,43 @@ describe('<HighlightedFieldsCell />', () => {
expect(getByTestId(HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID)).toBeInTheDocument();
});
// TODO: 8.15 simplify when `agentStatusClientEnabled` FF is enabled and removed
it.each(Object.keys(hooksToMock))(
`should render SentinelOne agent status cell if field is agent.status and 'originalField' is ${RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.sentinel_one} with %s hook`,
(hookName) => {
const hook = hooksToMock[hookName];
useAgentStatusHookMock.mockImplementation(() => hook);
it('should render SentinelOne agent status cell if field is agent.status and `originalField` is `observer.serial_number`', () => {
useGetAgentStatusMock.mockReturnValue({
isFetched: true,
isLoading: false,
});
(hook as jest.Mock).mockReturnValue({
isFetched: true,
isLoading: false,
});
const { getByTestId } = render(
<TestProviders>
<HighlightedFieldsCell
values={['value']}
field={'agent.status'}
originalField="observer.serial_number"
/>
</TestProviders>
);
const { getByTestId } = render(
<TestProviders>
<HighlightedFieldsCell
values={['value']}
field={'agent.status'}
originalField={RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.sentinel_one}
/>
</TestProviders>
);
expect(getByTestId(HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID)).toBeInTheDocument();
});
expect(getByTestId(HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID)).toBeInTheDocument();
}
);
it.each(Object.keys(hooksToMock))(
`should render Crowdstrike agent status cell if field is agent.status and 'originalField' is ${RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.crowdstrike} with %s hook`,
(hookName) => {
const hook = hooksToMock[hookName];
useAgentStatusHookMock.mockImplementation(() => hook);
it('should render Crowdstrike agent status cell if field is agent.status and `originalField` is `crowdstrike.event.DeviceId`', () => {
useGetAgentStatusMock.mockReturnValue({
isFetched: true,
isLoading: false,
});
(hook as jest.Mock).mockReturnValue({
isFetched: true,
isLoading: false,
});
const { getByTestId } = render(
<TestProviders>
<HighlightedFieldsCell
values={['value']}
field={'agent.status'}
originalField="crowdstrike.event.DeviceId"
/>
</TestProviders>
);
const { getByTestId } = render(
<TestProviders>
<HighlightedFieldsCell
values={['value']}
field={'agent.status'}
originalField={RESPONSE_ACTIONS_ALERT_AGENT_ID_FIELD.crowdstrike}
/>
</TestProviders>
);
expect(getByTestId(HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID)).toBeInTheDocument();
}
);
expect(getByTestId(HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID)).toBeInTheDocument();
});
it('should not render if values is null', () => {
const { container } = render(
<TestProviders>

View file

@ -6,15 +6,11 @@
*/
import type { VFC } from 'react';
import React, { memo, useCallback, useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { EuiFlexItem, EuiLink } from '@elastic/eui';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import {
AgentStatus,
EndpointAgentStatusById,
} from '../../../../common/components/endpoint/agents/agent_status';
import { AgentStatus } from '../../../../common/components/endpoint/agents/agent_status';
import { useDocumentDetailsContext } from '../../shared/context';
import {
AGENT_STATUS_FIELD_NAME,
@ -81,33 +77,7 @@ export interface HighlightedFieldsCellProps {
values: string[] | null | undefined;
}
const FieldsAgentStatus = memo(
({ value, agentType }: { value: string | undefined; agentType: ResponseActionAgentType }) => {
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
if (agentType !== 'endpoint' || agentStatusClientEnabled) {
return (
<AgentStatus
agentId={String(value ?? '')}
agentType={agentType}
data-test-subj={HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID}
/>
);
} else {
// TODO: remove usage of `EndpointAgentStatusById` when `agentStatusClientEnabled` FF is enabled and removed
return (
<EndpointAgentStatusById
endpointAgentId={String(value ?? '')}
data-test-subj={HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID}
/>
);
}
}
);
FieldsAgentStatus.displayName = 'FieldsAgentStatus';
/**
* console.log('c::*, values != null
* Renders a component in the highlighted fields table cell based on the field name
*/
export const HighlightedFieldsCell: VFC<HighlightedFieldsCellProps> = ({
@ -146,7 +116,11 @@ export const HighlightedFieldsCell: VFC<HighlightedFieldsCellProps> = ({
{field === HOST_NAME_FIELD_NAME || field === USER_NAME_FIELD_NAME ? (
<LinkFieldCell value={value} />
) : field === AGENT_STATUS_FIELD_NAME ? (
<FieldsAgentStatus value={value} agentType={agentType} />
<AgentStatus
agentId={String(value ?? '')}
agentType={agentType}
data-test-subj={HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID}
/>
) : (
<span data-test-subj={HIGHLIGHTED_FIELDS_BASIC_CELL_TEST_ID}>{value}</span>
)}

View file

@ -11,6 +11,8 @@ import React from 'react';
import { mockObservedHostData } from '../../mocks';
import { policyFields } from './endpoint_policy_fields';
jest.mock('../../../../management/hooks/agents/use_get_agent_status');
const TestWrapper = ({ el }: { el: JSX.Element | undefined }) => <>{el}</>;
jest.mock(

View file

@ -10,7 +10,7 @@ import { EuiHealth } from '@elastic/eui';
import type { EntityTableRows } from '../../shared/components/entity_table/types';
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
import { EndpointAgentStatus } from '../../../../common/components/endpoint/agents/agent_status';
import { AgentStatus } from '../../../../common/components/endpoint/agents/agent_status';
import { getEmptyTagValue } from '../../../../common/components/empty_value';
import type { HostItem } from '../../../../../common/search_strategy';
import { HostPolicyResponseActionStatus } from '../../../../../common/search_strategy';
@ -57,8 +57,9 @@ export const policyFields: EntityTableRows<ObservedEntityData<HostItem>> = [
label: i18n.FLEET_AGENT_STATUS,
render: (hostData: ObservedEntityData<HostItem>) =>
hostData.details.endpoint?.hostInfo ? (
<EndpointAgentStatus
endpointHostInfo={hostData.details.endpoint?.hostInfo}
<AgentStatus
agentId={hostData.details.endpoint?.hostInfo.metadata.agent.id}
agentType="endpoint"
data-test-subj="endpointHostAgentStatus"
/>
) : (

View file

@ -41,5 +41,5 @@ export const MANAGEMENT_DEFAULT_SORT_ORDER = 'desc';
export const MANAGEMENT_DEFAULT_SORT_FIELD = 'created_at';
// --[ DEFAULTS ]---------------------------------------------------------------------------
/** The default polling interval to start all polling pages */
/** The default polling interval for API calls that require a refresh interval */
export const DEFAULT_POLL_INTERVAL = 10000;

View file

@ -10,6 +10,7 @@ import { EuiDescriptionList } from '@elastic/eui';
import { v4 as uuidV4 } from 'uuid';
import { i18n } from '@kbn/i18n';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { getAgentStatusText } from '../../../../common/components/endpoint/agents/agent_status/translations';
import type { HostInfo, PendingActionsResponse } from '../../../../../common/endpoint/types';
import type { EndpointCommandDefinitionMeta } from '../types';
import { useGetEndpointPendingActionsSummary } from '../../../hooks/response_actions/use_get_endpoint_pending_actions_summary';
@ -19,7 +20,6 @@ import type { CommandExecutionComponentProps } from '../../console/types';
import { FormattedError } from '../../formatted_error';
import { ConsoleCodeBlock } from '../../console/components/console_code_block';
import { POLICY_STATUS_TO_TEXT } from '../../../pages/endpoint_hosts/view/host_constants';
import { getAgentStatusText } from '../../../../common/components/endpoint/agents/agent_status_text';
export const EndpointStatusActionResult = memo<
CommandExecutionComponentProps<
@ -28,6 +28,7 @@ export const EndpointStatusActionResult = memo<
apiCalled?: boolean;
endpointDetails?: HostInfo;
detailsFetchError?: IHttpFetchError;
// FIXME:PT remove this and use new API/TYpe (team issue: 9783)
endpointPendingActions?: PendingActionsResponse;
},
EndpointCommandDefinitionMeta

View file

@ -10,10 +10,7 @@ import React from 'react';
import type { AppContextTestRender } from '../../../../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../../../../common/mock/endpoint';
import { AgentInfo } from './agent_info';
import {
useAgentStatusHook,
useGetAgentStatus,
} from '../../../../../hooks/agents/use_get_agent_status';
import { useGetAgentStatus } from '../../../../../hooks/agents/use_get_agent_status';
import type { ResponseActionAgentType } from '../../../../../../../common/endpoint/service/response_actions/constants';
import { RESPONSE_ACTION_AGENT_TYPE } from '../../../../../../../common/endpoint/service/response_actions/constants';
import type { Platform } from '../platforms';
@ -22,7 +19,6 @@ import { HostStatus } from '../../../../../../../common/endpoint/types';
jest.mock('../../../../../hooks/agents/use_get_agent_status');
const getAgentStatusMock = useGetAgentStatus as jest.Mock;
const useAgentStatusHookMock = useAgentStatusHook as jest.Mock;
describe('Responder header Agent Info', () => {
let render: (
@ -54,7 +50,6 @@ describe('Responder header Agent Info', () => {
));
getAgentStatusMock.mockReturnValue({ data: {} });
useAgentStatusHookMock.mockImplementation(() => useGetAgentStatus);
});
afterEach(() => {

View file

@ -6,22 +6,21 @@
*/
import React, { memo } from 'react';
import type { ResponseActionAgentType } from '../../../../../../../common/endpoint/service/response_actions/constants';
import { AgentStatus } from '../../../../../../common/components/endpoint/agents/agent_status';
import { useAgentStatusHook } from '../../../../../hooks/agents/use_get_agent_status';
import type { ThirdPartyAgentInfo } from '../../../../../../../common/types';
import { useGetAgentStatus } from '../../../../../hooks/agents/use_get_agent_status';
import { HeaderAgentInfo } from '../header_agent_info';
import type { Platform } from '../platforms';
interface AgentInfoProps {
agentId: ThirdPartyAgentInfo['agent']['id'];
agentType: ThirdPartyAgentInfo['agent']['type'];
platform: ThirdPartyAgentInfo['host']['os']['family'];
hostName: ThirdPartyAgentInfo['host']['name'];
agentId: string;
agentType: ResponseActionAgentType;
platform: string;
hostName: string;
}
export const AgentInfo = memo<AgentInfoProps>(({ agentId, platform, hostName, agentType }) => {
const getAgentStatus = useAgentStatusHook();
const { data } = getAgentStatus([agentId], agentType);
const { data } = useGetAgentStatus(agentId, agentType);
const agentStatus = data?.[agentId];
const lastCheckin = agentStatus ? agentStatus.lastSeen : '';

View file

@ -10,15 +10,17 @@ import { EndpointActionGenerator } from '../../../../../../../common/endpoint/da
import type { HostInfo } from '../../../../../../../common/endpoint/types';
import type { AppContextTestRender } from '../../../../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../../../../common/mock/endpoint';
import { useGetEndpointDetails } from '../../../../../hooks/endpoint/use_get_endpoint_details';
import { useGetEndpointDetails as _useGetEndpointDetails } from '../../../../../hooks/endpoint/use_get_endpoint_details';
import { useGetEndpointPendingActionsSummary } from '../../../../../hooks/response_actions/use_get_endpoint_pending_actions_summary';
import { mockEndpointDetailsApiResult } from '../../../../../pages/endpoint_hosts/store/mock_endpoint_result_list';
import { HeaderEndpointInfo } from './header_endpoint_info';
import { agentStatusGetHttpMock } from '../../../../../mocks';
import { waitFor } from '@testing-library/react';
jest.mock('../../../../../hooks/endpoint/use_get_endpoint_details');
jest.mock('../../../../../hooks/response_actions/use_get_endpoint_pending_actions_summary');
const getEndpointDetails = useGetEndpointDetails as jest.Mock;
const useGetEndpointDetailsMock = _useGetEndpointDetails as jest.Mock;
const getPendingActions = useGetEndpointPendingActionsSummary as jest.Mock;
describe('Responder header endpoint info', () => {
@ -27,12 +29,12 @@ describe('Responder header endpoint info', () => {
let mockedContext: AppContextTestRender;
let endpointDetails: HostInfo;
beforeEach(() => {
beforeEach(async () => {
mockedContext = createAppRootMockRenderer();
render = () =>
(renderResult = mockedContext.render(<HeaderEndpointInfo endpointId={'1234'} />));
endpointDetails = mockEndpointDetailsApiResult();
getEndpointDetails.mockReturnValue({ data: endpointDetails });
useGetEndpointDetailsMock.mockReturnValue({ data: endpointDetails });
getPendingActions.mockReturnValue({
data: {
data: [
@ -42,7 +44,11 @@ describe('Responder header endpoint info', () => {
],
},
});
const apiMock = agentStatusGetHttpMock(mockedContext.coreStart.http);
render();
await waitFor(() => {
expect(apiMock.responseProvider.getAgentStatus).toHaveBeenCalled();
});
});
afterEach(() => {
@ -56,7 +62,7 @@ describe('Responder header endpoint info', () => {
const agentStatus = await renderResult.findByTestId(
'responderHeaderEndpointAgentIsolationStatus'
);
expect(agentStatus.textContent).toBe(`UnhealthyIsolating`);
expect(agentStatus.textContent).toBe(`Healthy`);
});
it('should show last checkin time', async () => {
const lastUpdated = await renderResult.findByTestId('responderHeaderLastSeen');

View file

@ -7,7 +7,7 @@
import React, { memo } from 'react';
import { EuiSkeletonText } from '@elastic/eui';
import { EndpointAgentStatus } from '../../../../../../common/components/endpoint/agents/agent_status';
import { AgentStatus } from '../../../../../../common/components/endpoint/agents/agent_status';
import { HeaderAgentInfo } from '../header_agent_info';
import { useGetEndpointDetails } from '../../../../../hooks';
import type { Platform } from '../platforms';
@ -35,8 +35,9 @@ export const HeaderEndpointInfo = memo<HeaderEndpointInfoProps>(({ endpointId })
hostName={endpointDetails.metadata.host.name}
lastCheckin={endpointDetails.last_checkin}
>
<EndpointAgentStatus
endpointHostInfo={endpointDetails}
<AgentStatus
agentId={endpointId}
agentType="endpoint"
data-test-subj="responderHeaderEndpointAgentIsolationStatus"
/>
</HeaderAgentInfo>

View file

@ -1,49 +0,0 @@
/*
* 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 { AgentStatus } from '../../../../../../common/components/endpoint/agents/agent_status';
import { useAgentStatusHook } from '../../../../../hooks/agents/use_get_agent_status';
import { useIsExperimentalFeatureEnabled } from '../../../../../../common/hooks/use_experimental_features';
import type { ThirdPartyAgentInfo } from '../../../../../../../common/types';
import { HeaderAgentInfo } from '../header_agent_info';
import type { Platform } from '../platforms';
interface HeaderSentinelOneInfoProps {
agentId: ThirdPartyAgentInfo['agent']['id'];
agentType: ThirdPartyAgentInfo['agent']['type'];
platform: ThirdPartyAgentInfo['host']['os']['family'];
hostName: ThirdPartyAgentInfo['host']['name'];
}
export const HeaderSentinelOneInfo = memo<HeaderSentinelOneInfoProps>(
({ agentId, agentType, platform, hostName }) => {
const isSentinelOneV1Enabled = useIsExperimentalFeatureEnabled(
'sentinelOneManualHostActionsEnabled'
);
const getAgentStatus = useAgentStatusHook();
const { data } = getAgentStatus([agentId], 'sentinel_one', { enabled: isSentinelOneV1Enabled });
const agentStatus = data?.[agentId];
const lastCheckin = agentStatus ? agentStatus.lastSeen : '';
return (
<HeaderAgentInfo
platform={platform.toLowerCase() as Platform}
hostName={hostName}
lastCheckin={lastCheckin}
>
<AgentStatus
agentId={agentId}
agentType={agentType}
data-test-subj="responderHeaderSentinelOneAgentIsolationStatus"
/>
</HeaderAgentInfo>
);
}
);
HeaderSentinelOneInfo.displayName = 'HeaderSentinelOneInfo';

View file

@ -8,101 +8,62 @@
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import { RESPONSE_ACTION_AGENT_TYPE } from '../../../../../common/endpoint/service/response_actions/constants';
import React from 'react';
import type { HostInfo } from '../../../../../common/endpoint/types';
import { HostStatus } from '../../../../../common/endpoint/types';
import type { AppContextTestRender } from '../../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../../common/mock/endpoint';
import {
useAgentStatusHook,
useGetAgentStatus,
useGetSentinelOneAgentStatus,
} from '../../../hooks/agents/use_get_agent_status';
import { useGetEndpointDetails } from '../../../hooks/endpoint/use_get_endpoint_details';
import { mockEndpointDetailsApiResult } from '../../../pages/endpoint_hosts/store/mock_endpoint_result_list';
import { OfflineCallout } from './offline_callout';
jest.mock('../../../hooks/endpoint/use_get_endpoint_details');
jest.mock('../../../hooks/agents/use_get_agent_status');
const getEndpointDetails = useGetEndpointDetails as jest.Mock;
const getSentinelOneAgentStatus = useGetSentinelOneAgentStatus as jest.Mock;
const getAgentStatus = useGetAgentStatus as jest.Mock;
const useAgentStatusHookMock = useAgentStatusHook as jest.Mock;
import { agentStatusGetHttpMock } from '../../../mocks';
import { agentStatusMocks } from '../../../../../common/endpoint/service/response_actions/mocks/agent_status.mocks';
import { waitFor } from '@testing-library/react';
describe('Responder offline callout', () => {
// TODO: 8.15 remove the sentinelOneAgentStatus hook when `agentStatusClientEnabled` is enabled and removed
describe.each([
[useGetSentinelOneAgentStatus, getSentinelOneAgentStatus],
[useGetAgentStatus, getAgentStatus],
])('works with %s hook', (hook, mockHook) => {
let render: (agentType?: ResponseActionAgentType) => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let mockedContext: AppContextTestRender;
let endpointDetails: HostInfo;
let render: (agentType?: ResponseActionAgentType) => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let mockedContext: AppContextTestRender;
let apiMocks: ReturnType<typeof agentStatusGetHttpMock>;
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
render = (agentType?: ResponseActionAgentType) =>
(renderResult = mockedContext.render(
<OfflineCallout
endpointId={'1234'}
agentType={agentType || 'endpoint'}
hostName="Host name"
/>
));
endpointDetails = mockEndpointDetailsApiResult();
getEndpointDetails.mockReturnValue({ data: endpointDetails });
mockHook.mockReturnValue({ data: {} });
useAgentStatusHookMock.mockImplementation(() => hook);
render();
});
afterEach(() => {
jest.clearAllMocks();
});
it.each(RESPONSE_ACTION_AGENT_TYPE)(
'should be visible when agent type is %s and host is offline',
(agentType) => {
if (agentType === 'endpoint') {
getEndpointDetails.mockReturnValue({
data: { ...endpointDetails, host_status: HostStatus.OFFLINE },
});
} else {
mockHook.mockReturnValue({
data: {
'1234': {
status: HostStatus.OFFLINE,
},
},
});
}
render(agentType);
const callout = renderResult.queryByTestId('offlineCallout');
expect(callout).toBeTruthy();
}
);
it.each(RESPONSE_ACTION_AGENT_TYPE)(
'should not be visible when agent type is %s and host is online',
(agentType) => {
if (agentType === 'endpoint') {
getEndpointDetails.mockReturnValue({
data: { ...endpointDetails, host_status: HostStatus.HEALTHY },
});
} else {
mockHook.mockReturnValue({
data: {
'1234': {
status: HostStatus.HEALTHY,
},
},
});
}
render(agentType);
const callout = renderResult.queryByTestId('offlineCallout');
expect(callout).toBeFalsy();
}
);
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
apiMocks = agentStatusGetHttpMock(mockedContext.coreStart.http);
render = (agentType?: ResponseActionAgentType) =>
(renderResult = mockedContext.render(
<OfflineCallout
endpointId={'abfe4a35-d5b4-42a0-a539-bd054c791769'}
agentType={agentType || 'endpoint'}
hostName="Host name"
/>
));
});
it.each(RESPONSE_ACTION_AGENT_TYPE)(
'should be visible when agent type is %s and host is offline',
async (agentType) => {
apiMocks.responseProvider.getAgentStatus.mockReturnValue({
data: {
'abfe4a35-d5b4-42a0-a539-bd054c791769': agentStatusMocks.generateAgentStatus({
agentType,
status: HostStatus.OFFLINE,
}),
},
});
render(agentType);
await waitFor(() => {
expect(apiMocks.responseProvider.getAgentStatus).toHaveBeenCalled();
});
expect(renderResult.getByTestId('offlineCallout')).toBeTruthy();
}
);
it.each(RESPONSE_ACTION_AGENT_TYPE)(
'should NOT be visible when agent type is %s and host is online',
async (agentType) => {
render(agentType);
await waitFor(() => {
expect(apiMocks.responseProvider.getAgentStatus).toHaveBeenCalled();
});
expect(renderResult.queryByTestId('offlineCallout')).toBeNull();
}
);
});

View file

@ -5,15 +5,12 @@
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import React, { memo } from 'react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { isAgentTypeAndActionSupported } from '../../../../common/lib/endpoint';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useAgentStatusHook } from '../../../hooks/agents/use_get_agent_status';
import { useGetAgentStatus } from '../../../hooks/agents/use_get_agent_status';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import { useGetEndpointDetails } from '../../../hooks';
import { HostStatus } from '../../../../../common/endpoint/types';
interface OfflineCalloutProps {
@ -23,45 +20,9 @@ interface OfflineCalloutProps {
}
export const OfflineCallout = memo<OfflineCalloutProps>(({ agentType, endpointId, hostName }) => {
const isEndpointAgent = agentType === 'endpoint';
const isSentinelOneAgent = agentType === 'sentinel_one';
const isCrowdstrikeAgent = agentType === 'crowdstrike';
const getAgentStatus = useAgentStatusHook();
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
const { data } = useGetAgentStatus(endpointId, agentType);
const isAgentTypeEnabled = useMemo(() => {
return isAgentTypeAndActionSupported(agentType);
}, [agentType]);
const { data: endpointDetails } = useGetEndpointDetails(endpointId, {
refetchInterval: 10000,
enabled: isEndpointAgent && !agentStatusClientEnabled,
});
const { data } = getAgentStatus([endpointId], agentType, {
enabled:
(isEndpointAgent && agentStatusClientEnabled) || (!isEndpointAgent && isAgentTypeEnabled),
});
const showOfflineCallout = useMemo(
() =>
(isEndpointAgent && endpointDetails?.host_status === HostStatus.OFFLINE) ||
(isSentinelOneAgent && data?.[endpointId].status === HostStatus.OFFLINE) ||
(isCrowdstrikeAgent && data?.[endpointId].status === HostStatus.OFFLINE),
[
data,
endpointDetails?.host_status,
endpointId,
isEndpointAgent,
isCrowdstrikeAgent,
isSentinelOneAgent,
]
);
if ((isEndpointAgent && !endpointDetails) || (isAgentTypeEnabled && !data)) {
return null;
}
if (showOfflineCallout) {
if (data?.[endpointId].status === HostStatus.OFFLINE) {
return (
<>
<EuiCallOut

View file

@ -0,0 +1,29 @@
/*
* 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 { agentStatusMocks } from '../../../../../common/endpoint/service/response_actions/mocks/agent_status.mocks';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import type { AgentStatusRecords } from '../../../../../common/endpoint/types';
const useGetAgentStatusMock = jest.fn(
(agentIds: string[] | string, agentType: ResponseActionAgentType) => {
const agentsIdList = Array.isArray(agentIds) ? agentIds : [agentIds];
return {
data: agentsIdList.reduce<AgentStatusRecords>((acc, agentId) => {
acc[agentId] = agentStatusMocks.generateAgentStatus({ agentType, agentId });
return acc;
}, {}),
isLoading: false,
isFetched: true,
isFetching: false,
};
}
);
export { useGetAgentStatusMock as useGetAgentStatus };

View file

@ -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 type { AppContextTestRender } from '../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
import { useGetAgentStatus } from './use_get_agent_status';
import { agentStatusGetHttpMock } from '../../mocks';
import { AGENT_STATUS_ROUTE } from '../../../../common/endpoint/constants';
import type { RenderHookResult } from '@testing-library/react-hooks/src/types';
describe('useGetAgentStatus hook', () => {
let httpMock: AppContextTestRender['coreStart']['http'];
let agentIdsProp: Parameters<typeof useGetAgentStatus>[0];
let optionsProp: Parameters<typeof useGetAgentStatus>[2];
let apiMock: ReturnType<typeof agentStatusGetHttpMock>;
let renderHook: () => RenderHookResult<unknown, ReturnType<typeof useGetAgentStatus>>;
beforeEach(() => {
const appTestContext = createAppRootMockRenderer();
httpMock = appTestContext.coreStart.http;
apiMock = agentStatusGetHttpMock(httpMock);
renderHook = () => {
return appTestContext.renderHook<unknown, ReturnType<typeof useGetAgentStatus>>(() =>
useGetAgentStatus(agentIdsProp, 'endpoint', optionsProp)
);
};
agentIdsProp = '1-2-3';
optionsProp = undefined;
});
it('should accept a single agent id (string)', () => {
renderHook();
expect(httpMock.get).toHaveBeenCalledWith(AGENT_STATUS_ROUTE, {
query: { agentIds: ['1-2-3'], agentType: 'endpoint' },
version: '1',
});
});
it('should accept multiple agent ids (array)', () => {
agentIdsProp = ['1', '2', '3'];
renderHook();
expect(httpMock.get).toHaveBeenCalledWith(AGENT_STATUS_ROUTE, {
query: { agentIds: ['1', '2', '3'], agentType: 'endpoint' },
version: '1',
});
});
it('should only use agentIds that are not empty strings', () => {
agentIdsProp = ['', '1', ''];
renderHook();
expect(httpMock.get).toHaveBeenCalledWith(AGENT_STATUS_ROUTE, {
query: { agentIds: ['1'], agentType: 'endpoint' },
version: '1',
});
});
it('should return expected data', async () => {
const { result, waitForValueToChange } = renderHook();
await waitForValueToChange(() => result.current);
expect(result.current.data).toEqual({
'1-2-3': {
agentId: '1-2-3',
agentType: 'endpoint',
found: true,
isolated: false,
lastSeen: expect.any(String),
pendingActions: {},
status: 'healthy',
},
});
});
it('should NOT call agent status api if list of agent ids is empty', async () => {
agentIdsProp = ['', ' '];
const { result, waitForValueToChange } = renderHook();
await waitForValueToChange(() => result.current);
expect(result.current.data).toEqual({});
expect(apiMock.responseProvider.getAgentStatus).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,61 @@
/*
* 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 type { SentinelOneGetAgentsResponse } from '@kbn/stack-connectors-plugin/common/sentinelone/types';
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import { DEFAULT_POLL_INTERVAL } from '../../common/constants';
import { AGENT_STATUS_ROUTE } from '../../../../common/endpoint/constants';
import type { AgentStatusRecords, AgentStatusApiResponse } from '../../../../common/endpoint/types';
import { useHttp } from '../../../common/lib/kibana';
interface ErrorType {
statusCode: number;
message: string;
meta: ActionTypeExecutorResult<SentinelOneGetAgentsResponse>;
}
/**
* Retrieve the status of a supported host's agent type
* @param agentIds
* @param agentType
* @param options
*/
export const useGetAgentStatus = (
agentIds: string[] | string,
agentType: ResponseActionAgentType,
options: Omit<UseQueryOptions<AgentStatusRecords, IHttpFetchError<ErrorType>>, 'queryFn'> = {}
): UseQueryResult<AgentStatusRecords, IHttpFetchError<ErrorType>> => {
const http = useHttp();
const agentIdList = (Array.isArray(agentIds) ? agentIds : [agentIds]).filter(
(agentId) => agentId.trim().length
);
return useQuery<AgentStatusRecords, IHttpFetchError<ErrorType>>({
queryKey: ['get-agent-status', agentIdList],
refetchInterval: DEFAULT_POLL_INTERVAL,
...options,
queryFn: () => {
if (agentIdList.length === 0) {
return {};
}
return http
.get<AgentStatusApiResponse>(AGENT_STATUS_ROUTE, {
version: '1',
query: {
agentIds: agentIdList,
agentType,
},
})
.then((response) => response.data);
},
});
};

View file

@ -1,83 +0,0 @@
/*
* 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 type { SentinelOneGetAgentsResponse } from '@kbn/stack-connectors-plugin/common/sentinelone/types';
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { AGENT_STATUS_ROUTE } from '../../../../common/endpoint/constants';
import type { AgentStatusInfo, AgentStatusRecords } from '../../../../common/endpoint/types';
import { useHttp } from '../../../common/lib/kibana';
interface ErrorType {
statusCode: number;
message: string;
meta: ActionTypeExecutorResult<SentinelOneGetAgentsResponse>;
}
// TODO: 8.15: Remove `useGetSentinelOneAgentStatus` function when `agentStatusClientEnabled` is enabled/removed
export const useGetSentinelOneAgentStatus = (
agentIds: string[],
agentType?: string,
options: UseQueryOptions<AgentStatusInfo, IHttpFetchError<ErrorType>> = {}
): UseQueryResult<AgentStatusInfo, IHttpFetchError<ErrorType>> => {
const http = useHttp();
return useQuery<AgentStatusInfo, IHttpFetchError<ErrorType>>({
queryKey: ['get-agent-status', agentIds],
refetchInterval: 5000,
...options,
enabled: agentType === 'sentinel_one',
queryFn: () =>
http
.get<{ data: AgentStatusInfo }>(AGENT_STATUS_ROUTE, {
version: '1',
query: {
agentIds,
// 8.13 sentinel_one support via internal API
agentType: agentType ? agentType : 'sentinel_one',
},
})
.then((response) => response.data),
});
};
// 8.14, 8.15 used for fetching agent status
export const useGetAgentStatus = (
agentIds: string[],
agentType: string,
options: UseQueryOptions<AgentStatusRecords, IHttpFetchError<ErrorType>> = {}
): UseQueryResult<AgentStatusRecords, IHttpFetchError<ErrorType>> => {
const http = useHttp();
return useQuery<AgentStatusRecords, IHttpFetchError<ErrorType>>({
queryKey: ['get-agent-status', agentIds],
// TODO: remove this refetchInterval and instead override it where called, via options.
refetchInterval: 5000,
...options,
queryFn: () =>
http
.get<{ data: AgentStatusRecords }>(AGENT_STATUS_ROUTE, {
version: '1',
query: {
agentIds: agentIds.filter((agentId) => agentId.trim().length),
agentType,
},
})
.then((response) => response.data),
});
};
export const useAgentStatusHook = ():
| typeof useGetAgentStatus
| typeof useGetSentinelOneAgentStatus => {
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
// 8.15 use agent status client hook if `agentStatusClientEnabled` FF enabled
return !agentStatusClientEnabled ? useGetSentinelOneAgentStatus : useGetAgentStatus;
};

View file

@ -18,7 +18,6 @@ import { useUserPrivileges } from '../../common/components/user_privileges';
import {
ActionLogButton,
getEndpointConsoleCommands,
HeaderEndpointInfo,
OfflineCallout,
} from '../components/endpoint_responder';
import { useConsoleManager } from '../components/console';
@ -54,7 +53,6 @@ export const useWithShowResponder = (): ShowResponseActionsConsole => {
const responseActionsCrowdstrikeManualHostIsolationEnabled = useIsExperimentalFeatureEnabled(
'responseActionsCrowdstrikeManualHostIsolationEnabled'
);
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
return useCallback(
(props: ResponderInfoProps) => {
@ -89,22 +87,14 @@ export const useWithShowResponder = (): ShowResponseActionsConsole => {
'data-test-subj': `${agentType}ResponseActionsConsole`,
storagePrefix: 'xpack.securitySolution.Responder',
TitleComponent: () => {
if (agentStatusClientEnabled || agentType !== 'endpoint') {
return (
<AgentInfo
agentId={agentId}
agentType={agentType}
hostName={hostName}
platform={platform}
/>
);
}
// TODO: 8.15 remove this if block when agentStatusClientEnabled is enabled/removed
if (agentType === 'endpoint') {
return <HeaderEndpointInfo endpointId={agentId} />;
}
return null;
return (
<AgentInfo
agentId={agentId}
agentType={agentType}
hostName={hostName}
platform={platform}
/>
);
},
};
@ -157,7 +147,6 @@ export const useWithShowResponder = (): ShowResponseActionsConsole => {
endpointPrivileges,
isEnterpriseLicense,
consoleManager,
agentStatusClientEnabled,
]
);
};

View file

@ -0,0 +1,41 @@
/*
* 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 type { HttpFetchOptionsWithPath } from '@kbn/core-http-browser';
import type { Mutable } from 'utility-types';
import { agentStatusMocks } from '../../../common/endpoint/service/response_actions/mocks/agent_status.mocks';
import type { EndpointAgentStatusRequestQueryParams } from '../../../common/api/endpoint/agent/get_agent_status_route';
import type { ResponseProvidersInterface } from '../../common/mock/endpoint';
import { httpHandlerMockFactory } from '../../common/mock/endpoint/http_handler_mock_factory';
import type { AgentStatusApiResponse, AgentStatusRecords } from '../../../common/endpoint/types';
import { AGENT_STATUS_ROUTE } from '../../../common/endpoint/constants';
export type AgentStatusHttpMocksInterface = ResponseProvidersInterface<{
getAgentStatus: (options: HttpFetchOptionsWithPath) => AgentStatusApiResponse;
}>;
export const agentStatusGetHttpMock = httpHandlerMockFactory<AgentStatusHttpMocksInterface>([
{
id: 'getAgentStatus',
method: 'get',
path: AGENT_STATUS_ROUTE,
handler: (options): AgentStatusApiResponse => {
const queryOptions = options.query as Mutable<EndpointAgentStatusRequestQueryParams>;
const agentType = queryOptions.agentType || 'endpoint';
const agentIds = Array.isArray(queryOptions.agentIds)
? queryOptions.agentIds
: [queryOptions.agentIds];
return {
data: agentIds.reduce<AgentStatusRecords>((acc, agentId) => {
acc[agentId] = agentStatusMocks.generateAgentStatus({ agentId, agentType });
return acc;
}, {}),
};
},
},
]);

View file

@ -8,3 +8,4 @@
export * from './fleet_mocks';
export * from './trusted_apps_http_mocks';
export * from './exceptions_list_http_mocks';
export * from './agent_status_http_mocks';

View file

@ -141,10 +141,6 @@ export type EndpointIsolationRequestStateChange = Action<'endpointIsolationReque
payload: EndpointState['isolationRequestState'];
};
export type EndpointPendingActionsStateChanged = Action<'endpointPendingActionsStateChanged'> & {
payload: EndpointState['endpointPendingActions'];
};
export type LoadMetadataTransformStats = Action<'loadMetadataTransformStats'>;
export type MetadataTransformStatsChanged = Action<'metadataTransformStatsChanged'> & {
@ -173,7 +169,6 @@ export type EndpointAction =
| ServerFailedToReturnEndpointsTotal
| EndpointIsolationRequest
| EndpointIsolationRequestStateChange
| EndpointPendingActionsStateChanged
| LoadMetadataTransformStats
| MetadataTransformStatsChanged
| ServerFinishedInitialization;

View file

@ -11,7 +11,7 @@ import {
} from '../../../../../common/endpoint/constants';
import type { Immutable } from '../../../../../common/endpoint/types';
import { DEFAULT_POLL_INTERVAL } from '../../../common/constants';
import { createLoadedResourceState, createUninitialisedResourceState } from '../../../state';
import { createUninitialisedResourceState } from '../../../state';
import type { EndpointState } from '../types';
export const initialEndpointPageState = (): Immutable<EndpointState> => {
@ -41,7 +41,6 @@ export const initialEndpointPageState = (): Immutable<EndpointState> => {
endpointsTotal: 0,
endpointsTotalError: undefined,
isolationRequestState: createUninitialisedResourceState(),
endpointPendingActions: createLoadedResourceState(new Map()),
metadataTransformStats: createUninitialisedResourceState(),
isInitialized: false,
};

View file

@ -72,10 +72,6 @@ describe('EndpointList store concerns', () => {
isolationRequestState: {
type: 'UninitialisedResourceState',
},
endpointPendingActions: {
data: new Map(),
type: 'LoadedResourceState',
},
metadataTransformStats: createUninitialisedResourceState(),
});
});

View file

@ -138,7 +138,6 @@ describe('endpoint list middleware', () => {
await Promise.all([
waitForAction('serverReturnedEndpointList'),
waitForAction('endpointPendingActionsStateChanged'),
waitForAction('serverReturnedMetadataPatterns'),
waitForAction('serverCancelledPolicyItemsLoading'),
waitForAction('serverReturnedEndpointExistValue'),
@ -236,42 +235,6 @@ describe('endpoint list middleware', () => {
});
});
describe('handle Endpoint Pending Actions state actions', () => {
let mockedApis: ReturnType<typeof endpointPageHttpMock>;
beforeEach(() => {
mockedApis = endpointPageHttpMock(fakeHttpServices);
});
it('should include all agents ids from the list when calling API', async () => {
const loadingPendingActions = waitForAction('endpointPendingActionsStateChanged', {
validate: (action) => isLoadedResourceState(action.payload),
});
dispatchUserChangedUrlToEndpointList();
await loadingPendingActions;
expect(mockedApis.responseProvider.pendingActions).toHaveBeenCalledWith({
path: expect.any(String),
version: '2023-10-31',
query: {
agent_ids: [
'0dc3661d-6e67-46b0-af39-6f12b025fcb0',
'fe16dda9-7f34-434c-9824-b4844880f410',
'f412728b-929c-48d5-bdb6-5a1298e3e607',
'd0405ddc-1e7c-48f0-93d7-d55f954bd745',
'46d78dd2-aedf-4d3f-b3a9-da445f1fd25f',
'5aafa558-26b8-4bb4-80e2-ac0644d77a3f',
'edac2c58-1748-40c3-853c-8fab48c333d7',
'06b7223a-bb2a-428a-9021-f1c0d2267ada',
'b8daa43b-7f73-4684-9221-dbc8b769405e',
'fbc06310-7d41-46b8-a5ea-ceed8a993b1a',
],
},
});
});
});
describe('handles metadata transform stats actions', () => {
const dispatchLoadTransformStats = () => {
dispatch({

View file

@ -30,7 +30,6 @@ import type {
ResponseActionApiResponse,
} from '../../../../../common/endpoint/types';
import { isolateHost, unIsolateHost } from '../../../../common/lib/endpoint/endpoint_isolation';
import { fetchPendingActionsByAgentId } from '../../../../common/lib/endpoint/endpoint_pending_actions';
import type { ImmutableMiddlewareAPI, ImmutableMiddlewareFactory } from '../../../../common/store';
import type { AppAction } from '../../../../common/store/actions';
import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies/policies';
@ -45,13 +44,7 @@ import {
sendGetEndpointSecurityPackage,
} from '../../../services/policies/ingest';
import type { GetPolicyListResponse } from '../../policy/types';
import type {
AgentIdsPendingActions,
EndpointState,
PolicyIds,
TransformStats,
TransformStatsResponse,
} from '../types';
import type { EndpointState, PolicyIds, TransformStats, TransformStatsResponse } from '../types';
import type { EndpointPackageInfoStateChanged } from './action';
import {
endpointPackageInfo,
@ -62,7 +55,6 @@ import {
getMetadataTransformStats,
isMetadataTransformStatsLoading,
isOnEndpointPage,
listData,
nonExistingPolicies,
patterns,
searchBarQuery,
@ -301,47 +293,6 @@ async function getEndpointPackageInfo(
}
}
/**
* retrieves the Endpoint pending actions for all the existing endpoints being displayed on the list
* or the details tab.
*
* @param store
*/
const loadEndpointsPendingActions = async ({
getState,
dispatch,
}: EndpointPageStore): Promise<void> => {
const state = getState();
const listEndpoints = listData(state);
const agentsIds = new Set<string>();
for (const endpointInfo of listEndpoints) {
agentsIds.add(endpointInfo.metadata.elastic.agent.id);
}
if (agentsIds.size === 0) {
return;
}
try {
const { data: pendingActions } = await fetchPendingActionsByAgentId(Array.from(agentsIds));
const agentIdToPendingActions: AgentIdsPendingActions = new Map();
for (const pendingAction of pendingActions) {
agentIdToPendingActions.set(pendingAction.agent_id, pendingAction.pending_actions);
}
dispatch({
type: 'endpointPendingActionsStateChanged',
payload: createLoadedResourceState(agentIdToPendingActions),
});
} catch (error) {
// TODO should handle the error instead of logging it to the browser
// Also this is an anti-pattern we shouldn't use
logError(error);
}
};
async function endpointListMiddleware({
store,
coreStart,
@ -380,8 +331,6 @@ async function endpointListMiddleware({
payload: endpointResponse,
});
loadEndpointsPendingActions(store);
dispatchIngestPolicies({ http: coreStart.http, hosts: endpointResponse.data, store });
} catch (error) {
dispatch({

View file

@ -5,11 +5,7 @@
* 2.0.
*/
import type {
EndpointPackageInfoStateChanged,
EndpointPendingActionsStateChanged,
MetadataTransformStatsChanged,
} from './action';
import type { EndpointPackageInfoStateChanged, MetadataTransformStatsChanged } from './action';
import {
getCurrentIsolationRequestState,
hasSelectedEndpoint,
@ -29,19 +25,6 @@ type CaseReducer<T extends AppAction> = (
action: Immutable<T>
) => Immutable<EndpointState>;
const handleEndpointPendingActionsStateChanged: CaseReducer<EndpointPendingActionsStateChanged> = (
state,
action
) => {
if (isOnEndpointPage(state)) {
return {
...state,
endpointPendingActions: action.payload,
};
}
return state;
};
const handleEndpointPackageInfoStateChanged: CaseReducer<EndpointPackageInfoStateChanged> = (
state,
action
@ -109,8 +92,6 @@ export const endpointListReducer: StateReducer = (state = initialEndpointPageSta
...state,
patternsError: action.payload,
};
} else if (action.type === 'endpointPendingActionsStateChanged') {
return handleEndpointPendingActionsStateChanged(state, action);
} else if (action.type === 'serverReturnedPoliciesForOnboarding') {
return {
...state,

View file

@ -11,11 +11,7 @@ import { createSelector } from 'reselect';
import { matchPath } from 'react-router-dom';
import { decode } from '@kbn/rison';
import type { Query } from '@kbn/es-query';
import type {
EndpointPendingActions,
EndpointSortableField,
Immutable,
} from '../../../../../common/endpoint/types';
import type { EndpointSortableField, Immutable } from '../../../../../common/endpoint/types';
import type { EndpointIndexUIQueryParams, EndpointState } from '../types';
import { extractListPaginationParams } from '../../../common/routing';
import {
@ -31,7 +27,6 @@ import {
} from '../../../state';
import type { ServerApiError } from '../../../../common/types';
import { EndpointDetailsTabsTypes } from '../view/details/components/endpoint_details_tabs';
export const listData = (state: Immutable<EndpointState>) => state.hosts;
@ -234,17 +229,6 @@ export const getIsolationRequestError: (
}
});
export const getIsOnEndpointDetailsActivityLog: (state: Immutable<EndpointState>) => boolean =
createSelector(uiQueryParams, (searchParams) => {
return searchParams.show === EndpointDetailsTabsTypes.activityLog;
});
export const getEndpointPendingActionsState = (
state: Immutable<EndpointState>
): Immutable<EndpointState['endpointPendingActions']> => {
return state.endpointPendingActions;
};
export const getMetadataTransformStats = (state: Immutable<EndpointState>) =>
state.metadataTransformStats;
@ -253,24 +237,3 @@ export const metadataTransformStats = (state: Immutable<EndpointState>) =>
export const isMetadataTransformStatsLoading = (state: Immutable<EndpointState>) =>
isLoadingResourceState(state.metadataTransformStats);
/**
* Returns a function (callback) that can be used to retrieve the list of pending actions against
* an endpoint currently displayed in the endpoint list
*/
export const getEndpointPendingActionsCallback: (
state: Immutable<EndpointState>
) => (endpointId: string) => EndpointPendingActions['pending_actions'] = createSelector(
getEndpointPendingActionsState,
(pendingActionsState) => {
return (endpointId: string) => {
let response: EndpointPendingActions['pending_actions'] = {};
if (isLoadedResourceState(pendingActionsState)) {
response = pendingActionsState.data.get(endpointId) ?? {};
}
return response;
};
}
);

View file

@ -71,12 +71,6 @@ export interface EndpointState {
endpointsTotalError?: ServerApiError;
/** Host isolation request state for a single endpoint */
isolationRequestState: AsyncResourceState<ResponseActionApiResponse>;
/**
* Holds a map of `agentId` to `EndpointPendingActions` that is used by both the list and details view
* Getting pending endpoint actions is "supplemental" data, so there is no need to show other Async
* states other than Loaded
*/
endpointPendingActions: AsyncResourceState<AgentIdsPendingActions>;
// Metadata transform stats to checking transform state
metadataTransformStats: AsyncResourceState<TransformStats[]>;
isInitialized: boolean;

View file

@ -17,18 +17,10 @@ import {
import React, { memo, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { isPolicyOutOfDate } from '../../utils';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import {
AgentStatus,
EndpointAgentStatus,
} from '../../../../../common/components/endpoint/agents/agent_status';
import { AgentStatus } from '../../../../../common/components/endpoint/agents/agent_status';
import type { HostInfo } from '../../../../../../common/endpoint/types';
import { useEndpointSelector } from '../hooks';
import {
getEndpointPendingActionsCallback,
nonExistingPolicies,
uiQueryParams,
} from '../../store/selectors';
import { nonExistingPolicies, uiQueryParams } from '../../store/selectors';
import { POLICY_STATUS_TO_BADGE_COLOR } from '../host_constants';
import { FormattedDate } from '../../../../../common/components/formatted_date';
import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler';
@ -50,13 +42,11 @@ interface EndpointDetailsContentProps {
export const EndpointDetailsContent = memo<EndpointDetailsContentProps>(
({ hostInfo, policyInfo }) => {
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
const queryParams = useEndpointSelector(uiQueryParams);
const policyStatus = useMemo(
() => hostInfo.metadata.Endpoint.policy.applied.status,
[hostInfo]
);
const getHostPendingActions = useEndpointSelector(getEndpointPendingActionsCallback);
const missingPolicies = useEndpointSelector(nonExistingPolicies);
const policyResponseRoutePath = useMemo(() => {
@ -92,15 +82,7 @@ export const EndpointDetailsContent = memo<EndpointDetailsContentProps>(
/>
</ColumnTitle>
),
// TODO: 8.15 remove `EndpointAgentStatus` when `agentStatusClientEnabled` FF is enabled and removed
description: agentStatusClientEnabled ? (
<AgentStatus agentId={hostInfo.metadata.agent.id} agentType="endpoint" />
) : (
<EndpointAgentStatus
pendingActions={getHostPendingActions(hostInfo.metadata.agent.id)}
endpointHostInfo={hostInfo}
/>
),
description: <AgentStatus agentId={hostInfo.metadata.agent.id} agentType="endpoint" />,
},
{
title: (
@ -198,15 +180,7 @@ export const EndpointDetailsContent = memo<EndpointDetailsContentProps>(
),
},
];
}, [
hostInfo,
agentStatusClientEnabled,
getHostPendingActions,
policyInfo,
missingPolicies,
policyStatus,
policyStatusClickHandler,
]);
}, [hostInfo, policyInfo, missingPolicies, policyStatus, policyStatusClickHandler]);
return (
<div>

View file

@ -55,6 +55,8 @@ import { getUserPrivilegesMockDefaultValue } from '../../../../common/components
import { ENDPOINT_CAPABILITIES } from '../../../../../common/endpoint/service/response_actions/constants';
import { getEndpointPrivilegesInitialStateMock } from '../../../../common/components/user_privileges/endpoint/mocks';
import { useGetEndpointDetails } from '../../../hooks/endpoint/use_get_endpoint_details';
import { useGetAgentStatus as _useGetAgentStatus } from '../../../hooks/agents/use_get_agent_status';
import { agentStatusMocks } from '../../../../../common/endpoint/service/response_actions/mocks/agent_status.mocks';
const mockUserPrivileges = useUserPrivileges as jest.Mock;
// not sure why this can't be imported from '../../../../common/mock/formatted_relative';
@ -80,6 +82,9 @@ jest.mock('../../../services/policies/ingest', () => {
};
});
jest.mock('../../../hooks/agents/use_get_agent_status');
const useGetAgentStatusMock = _useGetAgentStatus as jest.Mock;
const mockUseUiSetting$ = useUiSetting$ as jest.Mock;
const timepickerRanges = [
{
@ -325,6 +330,18 @@ describe('when on the endpoint list page', () => {
endpointsResults: hostListData,
endpointPackagePolicies: ingestPackagePolicies,
});
useGetAgentStatusMock.mockImplementation((agentId, agentType) => {
return {
data: {
[agentId]: agentStatusMocks.generateAgentStatus({
agentType,
}),
},
isLoading: false,
isFetched: true,
};
});
});
});
afterEach(() => {
@ -347,18 +364,19 @@ describe('when on the endpoint list page', () => {
const total = await renderResult.findByTestId('endpointListTableTotal');
expect(total.textContent).toEqual('Showing 5 endpoints');
});
it('should display correct status', async () => {
it('should agent status', async () => {
const renderResult = render();
await reactTestingLibrary.act(async () => {
await middlewareSpy.waitForAction('serverReturnedEndpointList');
});
const hostStatuses = await renderResult.findAllByTestId('rowHostStatus');
expect(hostStatuses[0].textContent).toEqual('Unhealthy');
expect(hostStatuses[0].textContent).toEqual('Healthy');
expect(hostStatuses[1].textContent).toEqual('Healthy');
expect(hostStatuses[2].textContent).toEqual('Offline');
expect(hostStatuses[3].textContent).toEqual('Updating');
expect(hostStatuses[4].textContent).toEqual('Inactive');
expect(hostStatuses[2].textContent).toEqual('Healthy');
expect(hostStatuses[3].textContent).toEqual('Healthy');
expect(hostStatuses[4].textContent).toEqual('Healthy');
});
it('should display correct policy status', async () => {

View file

@ -33,21 +33,19 @@ import type {
CreatePackagePolicyRouteState,
} from '@kbn/fleet-plugin/public';
import { isPolicyOutOfDate } from '../utils';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useGetAgentStatus } from '../../../hooks/agents/use_get_agent_status';
import { TransformFailedCallout } from './components/transform_failed_callout';
import type { EndpointIndexUIQueryParams } from '../types';
import { EndpointListNavLink } from './components/endpoint_list_nav_link';
import {
AgentStatus,
EndpointAgentStatus,
} from '../../../../common/components/endpoint/agents/agent_status';
import { AgentStatus } from '../../../../common/components/endpoint/agents/agent_status';
import { EndpointDetailsFlyout } from './details';
import * as selectors from '../store/selectors';
import { getEndpointPendingActionsCallback, nonExistingPolicies } from '../store/selectors';
import { nonExistingPolicies } from '../store/selectors';
import { useEndpointSelector } from './hooks';
import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants';
import type { CreateStructuredSelector } from '../../../../common/store';
import type {
AgentStatusRecords,
HostInfo,
HostInfoInterface,
Immutable,
@ -81,13 +79,12 @@ const StyledDatePicker = styled.div`
`;
interface GetEndpointListColumnsProps {
agentStatusClientEnabled: boolean;
missingPolicies: ReturnType<typeof nonExistingPolicies>;
backToEndpointList: PolicyDetailsRouteState['backLink'];
getHostPendingActions: ReturnType<typeof getEndpointPendingActionsCallback>;
queryParams: Immutable<EndpointIndexUIQueryParams>;
search: string;
getAppUrl: ReturnType<typeof useAppUrl>['getAppUrl'];
agentStatusRecords: AgentStatusRecords;
}
const columnWidths: Record<
@ -106,13 +103,12 @@ const columnWidths: Record<
};
const getEndpointListColumns = ({
agentStatusClientEnabled,
missingPolicies,
backToEndpointList,
getHostPendingActions,
queryParams,
search,
getAppUrl,
agentStatusRecords,
}: GetEndpointListColumnsProps): Array<EuiBasicTableColumn<Immutable<HostInfo>>> => {
const lastActiveColumnName = i18n.translate('xpack.securitySolution.endpoint.list.lastActive', {
defaultMessage: 'Last active',
@ -156,13 +152,10 @@ const getEndpointListColumns = ({
}),
sortable: true,
render: (hostStatus: HostInfo['host_status'], endpointInfo) => {
// TODO: 8.15 remove `EndpointAgentStatus` when `agentStatusClientEnabled` FF is enabled and removed
return agentStatusClientEnabled ? (
<AgentStatus agentId={endpointInfo.metadata.agent.id} agentType="endpoint" />
) : (
<EndpointAgentStatus
endpointHostInfo={endpointInfo}
pendingActions={getHostPendingActions(endpointInfo.metadata.agent.id)}
return (
<AgentStatus
statusInfo={agentStatusRecords[endpointInfo.metadata.agent.id]}
agentType="endpoint"
data-test-subj="rowHostStatus"
/>
);
@ -323,8 +316,6 @@ const stateHandleDeployEndpointsClick: AgentPolicyDetailsDeployAgentAction = {
};
export const EndpointList = () => {
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
const history = useHistory();
const {
listData,
@ -349,7 +340,6 @@ export const EndpointList = () => {
isInitialized,
} = useEndpointSelector(selector);
const missingPolicies = useEndpointSelector(nonExistingPolicies);
const getHostPendingActions = useEndpointSelector(getEndpointPendingActionsCallback);
const {
canReadEndpointList,
canAccessFleet,
@ -509,26 +499,23 @@ export const EndpointList = () => {
};
}, []);
const { data: agentStatusRecords } = useGetAgentStatus(
listData.map((rowItem) => rowItem.metadata.agent.id),
'endpoint',
{ enabled: hasListData }
);
const columns = useMemo(
() =>
getEndpointListColumns({
agentStatusClientEnabled,
backToEndpointList,
getAppUrl,
missingPolicies,
getHostPendingActions,
queryParams,
search,
agentStatusRecords: agentStatusRecords ?? {},
}),
[
agentStatusClientEnabled,
backToEndpointList,
getAppUrl,
getHostPendingActions,
missingPolicies,
queryParams,
search,
]
[agentStatusRecords, backToEndpointList, getAppUrl, missingPolicies, queryParams, search]
);
const sorting = useMemo(

View file

@ -14,9 +14,12 @@ import { TestProviders } from '../../../../common/mock';
import { EndpointOverview } from '.';
import type { EndpointFields } from '../../../../../common/search_strategy/security_solution/hosts';
import { EndpointMetadataGenerator } from '../../../../../common/endpoint/data_generators/endpoint_metadata_generator';
import { set } from 'lodash';
import { useGetAgentStatus as _useGetAgentStatus } from '../../../../management/hooks/agents/use_get_agent_status';
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../management/hooks/agents/use_get_agent_status');
const useGetAgentStatusMock = _useGetAgentStatus as jest.Mock;
describe('EndpointOverview Component', () => {
let endpointData: EndpointFields;
@ -59,7 +62,7 @@ describe('EndpointOverview Component', () => {
endpointData?.hostInfo?.metadata.Endpoint.policy.applied.status
);
expect(findData.at(2).text()).toContain(endpointData?.hostInfo?.metadata.agent.version); // contain because drag adds a space
expect(findData.at(3).text()).toEqual('HealthyIsolated');
expect(findData.at(3).text()).toEqual('Healthy');
});
test('it renders with null data', () => {
@ -71,19 +74,10 @@ describe('EndpointOverview Component', () => {
});
test('it shows isolation status', () => {
set(endpointData.hostInfo ?? {}, 'metadata.Endpoint.state.isolation', true);
const status = useGetAgentStatusMock(endpointData.hostInfo?.metadata.agent.id, 'endpoint');
status.data[endpointData.hostInfo!.metadata.agent.id].isolated = true;
useGetAgentStatusMock.mockReturnValue(status);
render();
expect(findData.at(3).text()).toEqual('HealthyIsolated');
});
// FIXME: un-skip once pending isolation status are supported again
test.skip.each([
['isolate', 'Isolating'],
['unisolate', 'Releasing'],
])('it shows pending %s status', (action, expectedLabel) => {
set(endpointData.hostInfo ?? {}, 'metadata.Endpoint.state.isolation', true);
endpointData.pendingActions![action] = 1;
render();
expect(findData.at(3).text()).toEqual(`Healthy${expectedLabel}`);
});
});

View file

@ -9,11 +9,7 @@ import { EuiHealth } from '@elastic/eui';
import { getOr } from 'lodash/fp';
import React, { useCallback, useMemo } from 'react';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import {
AgentStatus,
EndpointAgentStatus,
} from '../../../../common/components/endpoint/agents/agent_status';
import { AgentStatus } from '../../../../common/components/endpoint/agents/agent_status';
import { OverviewDescriptionList } from '../../../../common/components/overview_description_list';
import type { DescriptionList } from '../../../../../common/utility_types';
import { getEmptyTagValue } from '../../../../common/components/empty_value';
@ -29,7 +25,6 @@ interface Props {
}
export const EndpointOverview = React.memo<Props>(({ contextID, data, scopeId }) => {
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
const getDefaultRenderer = useCallback(
(fieldName: string, fieldData: EndpointFields, attrName: string) => (
<DefaultFieldRenderer
@ -82,23 +77,15 @@ export const EndpointOverview = React.memo<Props>(({ contextID, data, scopeId })
{
title: i18n.FLEET_AGENT_STATUS,
description:
// TODO: 8.15 remove `EndpointAgentStatus` when `agentStatusClientEnabled` FF is enabled and removed
data != null && data.hostInfo ? (
agentStatusClientEnabled ? (
<AgentStatus agentId={data.hostInfo.metadata.agent.id} agentType="endpoint" />
) : (
<EndpointAgentStatus
endpointHostInfo={data.hostInfo}
data-test-subj="endpointHostAgentStatus"
/>
)
<AgentStatus agentId={data.hostInfo.metadata.agent.id} agentType="endpoint" />
) : (
getEmptyTagValue()
),
},
],
];
}, [agentStatusClientEnabled, data, getDefaultRenderer]);
}, [data, getDefaultRenderer]);
return (
<>

View file

@ -2,12 +2,7 @@
exports[`Netflow renders correctly against snapshot 1`] = `
<DocumentFragment>
.c12 svg {
position: relative;
top: -1px;
}
.c10,
.c10,
.c10 * {
display: inline-block;
max-width: 100%;
@ -111,6 +106,11 @@ tr:hover .c2:focus::before {
vertical-align: top;
}
.c12 svg {
position: relative;
top: -1px;
}
.c21 {
margin-right: 5px;
}

View file

@ -13,16 +13,12 @@ import { isEmpty, isNumber } from 'lodash/fp';
import React from 'react';
import { css } from '@emotion/css';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import type { BrowserField } from '../../../../../common/containers/source';
import {
ALERT_HOST_CRITICALITY,
ALERT_USER_CRITICALITY,
} from '../../../../../../common/field_maps/field_names';
import {
AgentStatus,
EndpointAgentStatusById,
} from '../../../../../common/components/endpoint/agents/agent_status';
import { AgentStatus } from '../../../../../common/components/endpoint/agents/agent_status';
import { INDICATOR_REFERENCE } from '../../../../../../common/cti/constants';
import { DefaultDraggable } from '../../../../../common/components/draggables';
import { Bytes, BYTES_FORMAT } from './bytes';
@ -107,8 +103,6 @@ const FormattedFieldValueComponent: React.FC<{
value,
linkValue,
}) => {
const agentStatusClientEnabled = useIsExperimentalFeatureEnabled('agentStatusClientEnabled');
if (isObjectArray || asPlainText) {
return <span data-test-subj={`formatted-field-${fieldName}`}>{value}</span>;
} else if (fieldType === IP_FIELD_TYPE) {
@ -292,17 +286,12 @@ const FormattedFieldValueComponent: React.FC<{
/>
);
} else if (fieldName === AGENT_STATUS_FIELD_NAME) {
return agentStatusClientEnabled ? (
return (
<AgentStatus
agentId={String(value ?? '')}
agentType="endpoint"
data-test-subj="endpointHostAgentStatus"
/>
) : (
<EndpointAgentStatusById
endpointAgentId={String(value ?? '')}
data-test-subj="endpointHostAgentStatus"
/>
);
} else if (
[

View file

@ -16,11 +16,6 @@ exports[`netflowRowRenderer renders correctly against snapshot 1`] = `
border-radius: 4px;
}
.c14 svg {
position: relative;
top: -1px;
}
.c12,
.c12 * {
display: inline-block;
@ -125,6 +120,11 @@ tr:hover .c4:focus::before {
vertical-align: top;
}
.c14 svg {
position: relative;
top: -1px;
}
.c23 {
margin-right: 5px;
}

View file

@ -48,6 +48,7 @@ import { createCasesClientMock } from '@kbn/cases-plugin/server/client/mocks';
import type { AddVersionOpts, VersionedRouteConfig } from '@kbn/core-http-server';
import { unsecuredActionsClientMock } from '@kbn/actions-plugin/server/unsecured_actions_client/unsecured_actions_client.mock';
import type { PluginStartContract } from '@kbn/actions-plugin/server';
import type { Mutable } from 'utility-types';
import { responseActionsClientMock } from '../services/actions/clients/mocks';
import { getEndpointAuthzInitialStateMock } from '../../../common/endpoint/service/authz/mocks';
import { createMockConfig, requestContextMock } from '../../lib/detection_engine/routes/__mocks__';
@ -264,7 +265,7 @@ export interface HttpApiTestSetupMock<P = any, Q = any, B = any> {
httpResponseMock: ReturnType<typeof httpServerMock.createResponseFactory>;
httpHandlerContextMock: ReturnType<typeof requestContextMock.convertContext>;
getEsClientMock: (type?: 'internalUser' | 'currentUser') => ElasticsearchClientMock;
createRequestMock: (options?: RequestFixtureOptions<P, Q, B>) => KibanaRequest<P, Q, B>;
createRequestMock: (options?: RequestFixtureOptions<P, Q, B>) => Mutable<KibanaRequest<P, Q, B>>;
/** Retrieves the handler that was registered with the `router` for a given `method` and `path` */
getRegisteredRouteHandler: (method: RouterMethod, path: string) => RequestHandler;
/** Retrieves the route handler configuration that was registered with the router */
@ -334,7 +335,9 @@ export const createHttpApiTestSetupMock = <P = any, Q = any, B = any>(): HttpApi
httpHandlerContextMock,
httpResponseMock,
createRequestMock: (options: RequestFixtureOptions<P, Q, B> = {}): KibanaRequest<P, Q, B> => {
createRequestMock: (
options: RequestFixtureOptions<P, Q, B> = {}
): Mutable<KibanaRequest<P, Q, B>> => {
return httpServerMock.createKibanaRequest<P, Q, B>(options);
},

View file

@ -12,12 +12,33 @@ import { registerAgentStatusRoute } from './agent_status_handler';
import { AGENT_STATUS_ROUTE } from '../../../../common/endpoint/constants';
import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
import type { EndpointAgentStatusRequestQueryParams } from '../../../../common/api/endpoint/agent/get_agent_status_route';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import { RESPONSE_ACTION_AGENT_TYPE } from '../../../../common/endpoint/service/response_actions/constants';
import type { ExperimentalFeatures } from '../../../../common';
import { agentServiceMocks as mockAgentService } from '../../services/agent/mocks';
import { getAgentStatusClient as _getAgentStatusClient } from '../../services';
import type { DeepMutable } from '../../../../common/endpoint/types';
jest.mock('../../services', () => {
const realModule = jest.requireActual('../../services');
return {
...realModule,
getAgentStatusClient: jest.fn((agentType: ResponseActionAgentType) => {
return mockAgentService.createClient(agentType);
}),
};
});
const getAgentStatusClientMock = _getAgentStatusClient as jest.Mock;
describe('Agent Status API route handler', () => {
let apiTestSetup: HttpApiTestSetupMock<never, EndpointAgentStatusRequestQueryParams>;
let apiTestSetup: HttpApiTestSetupMock<never, DeepMutable<EndpointAgentStatusRequestQueryParams>>;
let httpRequestMock: ReturnType<
HttpApiTestSetupMock<never, EndpointAgentStatusRequestQueryParams>['createRequestMock']
HttpApiTestSetupMock<
never,
DeepMutable<EndpointAgentStatusRequestQueryParams>
>['createRequestMock']
>;
let httpHandlerContextMock: HttpApiTestSetupMock<
never,
@ -43,60 +64,55 @@ describe('Agent Status API route handler', () => {
apiTestSetup.endpointAppContextMock.experimentalFeatures = {
...apiTestSetup.endpointAppContextMock.experimentalFeatures,
responseActionsSentinelOneV1Enabled: true,
responseActionsCrowdstrikeManualHostIsolationEnabled: false,
agentStatusClientEnabled: false,
responseActionsCrowdstrikeManualHostIsolationEnabled: true,
};
registerAgentStatusRoute(apiTestSetup.routerMock, apiTestSetup.endpointAppContextMock);
});
it('should error if the sentinel_one feature flag is turned off', async () => {
apiTestSetup.endpointAppContextMock.experimentalFeatures = {
...apiTestSetup.endpointAppContextMock.experimentalFeatures,
responseActionsSentinelOneV1Enabled: false,
responseActionsCrowdstrikeManualHostIsolationEnabled: false,
};
it.each`
agentType | featureFlag
${'sentinel_one'} | ${'responseActionsSentinelOneV1Enabled'}
${'crowdstrike'} | ${'responseActionsCrowdstrikeManualHostIsolationEnabled'}
`(
'should error if the $agentType feature flag ($featureFlag) is turned off',
async ({
agentType,
featureFlag,
}: {
agentType: ResponseActionAgentType;
featureFlag: keyof ExperimentalFeatures;
}) => {
apiTestSetup.endpointAppContextMock.experimentalFeatures = {
...apiTestSetup.endpointAppContextMock.experimentalFeatures,
[featureFlag]: false,
};
httpRequestMock.query.agentType = agentType;
await apiTestSetup
.getRegisteredVersionedRoute('get', AGENT_STATUS_ROUTE, '1')
.routeHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
expect(httpResponseMock.customError).toHaveBeenCalledWith({
statusCode: 400,
body: expect.any(CustomHttpRequestError),
});
});
it.each(RESPONSE_ACTION_AGENT_TYPE)('should accept agent type of %s', async (agentType) => {
// @ts-expect-error `query.*` is not mutable
httpRequestMock.query.agentType = agentType;
// TODO TC: Temporary workaround to catch thrown error while Crowdstrike status is not yet supported
try {
await apiTestSetup
.getRegisteredVersionedRoute('get', AGENT_STATUS_ROUTE, '1')
.routeHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
} catch (error) {
if (agentType === 'crowdstrike') {
expect(error.message).toBe('Agent type [crowdstrike] does not support agent status');
}
}
if (agentType !== 'crowdstrike') {
expect(httpResponseMock.ok).toHaveBeenCalled();
}
});
it('should accept agent type of `endpoint` when FF is disabled', async () => {
apiTestSetup.endpointAppContextMock.experimentalFeatures = {
...apiTestSetup.endpointAppContextMock.experimentalFeatures,
responseActionsSentinelOneV1Enabled: false,
};
// @ts-expect-error `query.*` is not mutable
httpRequestMock.query.agentType = 'endpoint';
expect(httpResponseMock.customError).toHaveBeenCalledWith({
statusCode: 400,
body: expect.any(CustomHttpRequestError),
});
}
);
it.each(RESPONSE_ACTION_AGENT_TYPE)('should accept agent type of %s', async (agentType) => {
httpRequestMock.query.agentType = agentType;
await apiTestSetup
.getRegisteredVersionedRoute('get', AGENT_STATUS_ROUTE, '1')
.routeHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
expect(httpResponseMock.ok).toHaveBeenCalled();
expect(getAgentStatusClientMock).toHaveBeenCalledWith(agentType, {
esClient: (await httpHandlerContextMock.core).elasticsearch.client.asInternalUser,
soClient: (await httpHandlerContextMock.core).savedObjects.client,
connectorActionsClient: (await httpHandlerContextMock.actions).getActionsClient(),
endpointService: apiTestSetup.endpointAppContextMock.service,
});
});
it('should return status code 200 with expected payload', async () => {
@ -109,43 +125,21 @@ describe('Agent Status API route handler', () => {
data: {
one: {
agentType: 'sentinel_one',
found: false,
found: true,
agentId: 'one',
isUninstalled: false,
isPendingUninstall: false,
isolated: false,
lastSeen: '',
pendingActions: {
execute: 0,
'get-file': 0,
isolate: 0,
'kill-process': 0,
'running-processes': 0,
'suspend-process': 0,
unisolate: 0,
upload: 0,
},
status: 'unenrolled',
lastSeen: expect.any(String),
pendingActions: {},
status: 'healthy',
},
two: {
agentType: 'sentinel_one',
found: false,
found: true,
agentId: 'two',
isUninstalled: false,
isPendingUninstall: false,
isolated: false,
lastSeen: '',
pendingActions: {
execute: 0,
'get-file': 0,
isolate: 0,
'kill-process': 0,
'running-processes': 0,
'suspend-process': 0,
unisolate: 0,
upload: 0,
},
status: 'unenrolled',
lastSeen: expect.any(String),
pendingActions: {},
status: 'healthy',
},
},
},

View file

@ -6,7 +6,6 @@
*/
import type { RequestHandler } from '@kbn/core/server';
import { getSentinelOneAgentStatus } from '../../services/agent/agent_status';
import { errorHandler } from '../error_handler';
import type { EndpointAgentStatusRequestQueryParams } from '../../../../common/api/endpoint/agent/get_agent_status_route';
import { EndpointAgentStatusRequestSchema } from '../../../../common/api/endpoint/agent/get_agent_status_route';
@ -59,6 +58,10 @@ export const getAgentStatusRouteHandler = (
const { agentType = 'endpoint', agentIds: _agentIds } = request.query;
const agentIds = Array.isArray(_agentIds) ? _agentIds : [_agentIds];
logger.debug(
`Retrieving status for: agentType [${agentType}], agentIds: [${agentIds.join(', ')}]`
);
// Note: because our API schemas are defined as module static variables (as opposed to a
// `getter` function), we need to include this additional validation here, since
// `agent_type` is included in the schema independent of the feature flag
@ -77,36 +80,17 @@ export const getAgentStatusRouteHandler = (
const esClient = (await context.core).elasticsearch.client.asInternalUser;
const soClient = (await context.core).savedObjects.client;
const connectorActionsClient = (await context.actions).getActionsClient();
const agentStatusClient = getAgentStatusClient(agentType, {
esClient,
soClient,
connectorActionsClient,
endpointService: endpointContext.service,
connectorActionsClient:
agentType === 'crowdstrike' ? (await context.actions).getActionsClient() : undefined,
});
// 8.15: use the new `agentStatusClientEnabled` FF enabled
const data = endpointContext.experimentalFeatures.agentStatusClientEnabled
? await agentStatusClient.getAgentStatuses(agentIds)
: agentType === 'sentinel_one'
? await getSentinelOneAgentStatus({
agentType,
agentIds,
logger,
connectorActionsClient: (await context.actions).getActionsClient(),
})
: [];
logger.debug(
`Retrieving status for: agentType [${agentType}], agentIds: [${agentIds.join(', ')}]`
);
const data = await agentStatusClient.getAgentStatuses(agentIds);
try {
return response.ok({
body: {
data,
},
});
return response.ok({ body: { data } });
} catch (e) {
return errorHandler(logger, response, e);
}

View file

@ -1,184 +0,0 @@
/*
* 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 type { GetAgentStatusOptions } from './agent_status';
import { getSentinelOneAgentStatus, SENTINEL_ONE_NETWORK_STATUS } from './agent_status';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { sentinelOneMock } from '../actions/clients/sentinelone/mocks';
import { responseActionsClientMock } from '../actions/clients/mocks';
describe('Endpoint Get Agent Status service', () => {
let agentStatusOptions: GetAgentStatusOptions;
beforeEach(() => {
agentStatusOptions = {
agentType: 'sentinel_one',
agentIds: ['1', '2'],
logger: loggingSystemMock.create().get('getSentinelOneAgentStatus'),
connectorActionsClient: sentinelOneMock.createConnectorActionsClient(),
};
});
it('should throw error if unable to access stack connectors', async () => {
(agentStatusOptions.connectorActionsClient.getAll as jest.Mock).mockImplementation(async () => {
throw new Error('boom');
});
const getStatusResponsePromise = getSentinelOneAgentStatus(agentStatusOptions);
await expect(getStatusResponsePromise).rejects.toHaveProperty(
'message',
'Unable to retrieve list of stack connectors: boom'
);
await expect(getStatusResponsePromise).rejects.toHaveProperty('statusCode', 400);
});
it('should throw error if no SentinelOne connector is registered', async () => {
(agentStatusOptions.connectorActionsClient.getAll as jest.Mock).mockResolvedValue([]);
const getStatusResponsePromise = getSentinelOneAgentStatus(agentStatusOptions);
await expect(getStatusResponsePromise).rejects.toHaveProperty(
'message',
'No SentinelOne stack connector found'
);
await expect(getStatusResponsePromise).rejects.toHaveProperty('statusCode', 400);
});
it('should send api request to SentinelOne', async () => {
await getSentinelOneAgentStatus(agentStatusOptions);
expect(agentStatusOptions.connectorActionsClient.execute).toHaveBeenCalledWith({
actionId: 's1-connector-instance-id',
params: {
subAction: 'getAgents',
subActionParams: {
uuids: '1,2',
},
},
});
});
it('should throw if api call to SentinelOne failed', async () => {
(agentStatusOptions.connectorActionsClient.execute as jest.Mock).mockResolvedValue(
responseActionsClientMock.createConnectorActionExecuteResponse({
status: 'error',
serviceMessage: 'boom',
})
);
const getStatusResponsePromise = getSentinelOneAgentStatus(agentStatusOptions);
await expect(getStatusResponsePromise).rejects.toHaveProperty(
'message',
'Attempt retrieve agent information from to SentinelOne failed: boom'
);
await expect(getStatusResponsePromise).rejects.toHaveProperty('statusCode', 500);
});
it('should return expected output', async () => {
agentStatusOptions.agentIds = ['aaa', 'bbb', 'ccc', 'invalid'];
(agentStatusOptions.connectorActionsClient.execute as jest.Mock).mockResolvedValue(
responseActionsClientMock.createConnectorActionExecuteResponse({
data: sentinelOneMock.createGetAgentsResponse([
sentinelOneMock.createSentinelOneAgentDetails({
networkStatus: SENTINEL_ONE_NETWORK_STATUS.DISCONNECTED, // Isolated
uuid: 'aaa',
}),
sentinelOneMock.createSentinelOneAgentDetails({
networkStatus: SENTINEL_ONE_NETWORK_STATUS.DISCONNECTING, // Releasing
uuid: 'bbb',
}),
sentinelOneMock.createSentinelOneAgentDetails({
networkStatus: SENTINEL_ONE_NETWORK_STATUS.CONNECTING, // isolating
uuid: 'ccc',
}),
]),
})
);
await expect(getSentinelOneAgentStatus(agentStatusOptions)).resolves.toEqual({
aaa: {
agentType: 'sentinel_one',
found: true,
agentId: 'aaa',
isUninstalled: false,
isPendingUninstall: false,
isolated: true,
lastSeen: '2023-12-26T21:35:35.986596Z',
pendingActions: {
execute: 0,
'get-file': 0,
isolate: 0,
'kill-process': 0,
'running-processes': 0,
'suspend-process': 0,
unisolate: 0,
upload: 0,
},
status: 'healthy',
},
bbb: {
agentType: 'sentinel_one',
found: true,
agentId: 'bbb',
isUninstalled: false,
isPendingUninstall: false,
isolated: false,
lastSeen: '2023-12-26T21:35:35.986596Z',
pendingActions: {
execute: 0,
'get-file': 0,
isolate: 1,
'kill-process': 0,
'running-processes': 0,
'suspend-process': 0,
unisolate: 0,
upload: 0,
},
status: 'healthy',
},
ccc: {
agentType: 'sentinel_one',
found: true,
agentId: 'ccc',
isUninstalled: false,
isPendingUninstall: false,
isolated: false,
lastSeen: '2023-12-26T21:35:35.986596Z',
pendingActions: {
execute: 0,
'get-file': 0,
isolate: 0,
'kill-process': 0,
'running-processes': 0,
'suspend-process': 0,
unisolate: 1,
upload: 0,
},
status: 'healthy',
},
invalid: {
agentType: 'sentinel_one',
found: false,
agentId: 'invalid',
isUninstalled: false,
isPendingUninstall: false,
isolated: false,
lastSeen: '',
pendingActions: {
execute: 0,
'get-file': 0,
isolate: 0,
'kill-process': 0,
'running-processes': 0,
'suspend-process': 0,
unisolate: 0,
upload: 0,
},
status: 'unenrolled',
},
});
});
});

View file

@ -1,137 +0,0 @@
/*
* 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 type { ActionsClient } from '@kbn/actions-plugin/server';
import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types';
import {
SENTINELONE_CONNECTOR_ID,
SUB_ACTION,
} from '@kbn/stack-connectors-plugin/common/sentinelone/constants';
import type { Logger } from '@kbn/core/server';
import { keyBy, merge } from 'lodash';
import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
import type { SentinelOneGetAgentsResponse } from '@kbn/stack-connectors-plugin/common/sentinelone/types';
import { stringify } from '../../utils/stringify';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import type { AgentStatusInfo } from '../../../../common/endpoint/types';
import { HostStatus } from '../../../../common/endpoint/types';
import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
export interface GetAgentStatusOptions {
// NOTE: only sentinel_one currently supported
agentType: ResponseActionAgentType;
agentIds: string[];
connectorActionsClient: ActionsClient;
logger: Logger;
}
export const getSentinelOneAgentStatus = async ({
agentType,
agentIds,
connectorActionsClient,
logger,
}: GetAgentStatusOptions): Promise<AgentStatusInfo> => {
let connectorList: ConnectorWithExtraFindData[] = [];
try {
connectorList = await connectorActionsClient.getAll();
} catch (err) {
throw new CustomHttpRequestError(
`Unable to retrieve list of stack connectors: ${err.message}`,
// failure here is likely due to Authz, but because we don't have a good way to determine that,
// the `statusCode` below is set to `400` instead of `401`.
400,
err
);
}
const connector = connectorList.find(({ actionTypeId, isDeprecated, isMissingSecrets }) => {
return actionTypeId === SENTINELONE_CONNECTOR_ID && !isDeprecated && !isMissingSecrets;
});
if (!connector) {
throw new CustomHttpRequestError(`No SentinelOne stack connector found`, 400, connectorList);
}
logger.debug(`Using SentinelOne stack connector: ${connector.name} (${connector.id})`);
const agentDetailsResponse = (await connectorActionsClient.execute({
actionId: connector.id,
params: {
subAction: SUB_ACTION.GET_AGENTS,
subActionParams: {
uuids: agentIds.filter((agentId) => agentId.trim().length).join(','),
},
},
})) as ActionTypeExecutorResult<SentinelOneGetAgentsResponse>;
if (agentDetailsResponse.status === 'error') {
logger.error(stringify(agentDetailsResponse));
throw new CustomHttpRequestError(
`Attempt retrieve agent information from to SentinelOne failed: ${
agentDetailsResponse.serviceMessage || agentDetailsResponse.message
}`,
500,
agentDetailsResponse
);
}
const agentDetailsById = keyBy(agentDetailsResponse.data?.data, 'uuid');
logger.debug(`Response from SentinelOne API:\n${stringify(agentDetailsById)}`);
return agentIds.reduce<AgentStatusInfo>((acc, agentId) => {
const thisAgentDetails = agentDetailsById[agentId];
const thisAgentStatus = {
agentType,
agentId,
found: false,
isolated: false,
isPendingUninstall: false,
isUninstalled: false,
lastSeen: '',
pendingActions: {
execute: 0,
upload: 0,
unisolate: 0,
isolate: 0,
'get-file': 0,
'kill-process': 0,
'suspend-process': 0,
'running-processes': 0,
},
status: HostStatus.UNENROLLED,
};
if (thisAgentDetails) {
merge(thisAgentStatus, {
found: true,
lastSeen: thisAgentDetails.updatedAt,
isPendingUninstall: thisAgentDetails.isPendingUninstall,
isUninstalled: thisAgentDetails.isUninstalled,
isolated: thisAgentDetails.networkStatus === SENTINEL_ONE_NETWORK_STATUS.DISCONNECTED,
status: !thisAgentDetails.isActive ? HostStatus.OFFLINE : HostStatus.HEALTHY,
pendingActions: {
isolate:
thisAgentDetails.networkStatus === SENTINEL_ONE_NETWORK_STATUS.DISCONNECTING ? 1 : 0,
unisolate:
thisAgentDetails.networkStatus === SENTINEL_ONE_NETWORK_STATUS.CONNECTING ? 1 : 0,
},
});
}
acc[agentId] = thisAgentStatus;
return acc;
}, {});
};
export enum SENTINEL_ONE_NETWORK_STATUS {
CONNECTING = 'connecting',
CONNECTED = 'connected',
DISCONNECTING = 'disconnecting',
DISCONNECTED = 'disconnected',
}

View file

@ -0,0 +1,29 @@
/*
* 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 type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import type { AgentStatusRecords } from '../../../../common/endpoint/types';
import { agentStatusMocks } from '../../../../common/endpoint/service/response_actions/mocks/agent_status.mocks';
import type { AgentStatusClientInterface } from '..';
const createClientMock = (
agentType: ResponseActionAgentType = 'endpoint'
): jest.Mocked<AgentStatusClientInterface> => {
return {
getAgentStatuses: jest.fn(async (agentIds) => {
return agentIds.reduce<AgentStatusRecords>((acc, agentId) => {
acc[agentId] = agentStatusMocks.generateAgentStatus({ agentId, agentType });
return acc;
}, {});
}),
};
};
export const agentServiceMocks = Object.freeze({
...agentStatusMocks,
createClient: createClientMock,
});